mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] remove vendored 9router (now fetched from npm at build), remove Copilot, and fix subs + tools
This commit is contained in:
+2
-4
@@ -15,10 +15,8 @@ electron/build-staging/
|
||||
electron/node_modules/
|
||||
electron/package-lock.json
|
||||
|
||||
# 9Router build artifacts
|
||||
9router/node_modules/
|
||||
9router/.next/
|
||||
9router/package-lock.json
|
||||
# Router is fetched from npm at build time into electron/build-staging/router.
|
||||
# No local router/ directory is tracked.
|
||||
|
||||
# Pre-installed MCP server dependencies
|
||||
backend/npm-servers/*/node_modules/
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
# 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*
|
||||
@@ -1,38 +0,0 @@
|
||||
# 9Router environment contract
|
||||
# This file reflects actual runtime usage in the current codebase.
|
||||
|
||||
# Required
|
||||
JWT_SECRET=change-me-to-a-long-random-secret
|
||||
INITIAL_PASSWORD=change-me
|
||||
DATA_DIR=/var/lib/9router
|
||||
|
||||
# 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
|
||||
OBSERVABILITY_ENABLED=true
|
||||
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://9router.com
|
||||
# Backward-compatible/public variables:
|
||||
NEXT_PUBLIC_BASE_URL=http://localhost:20128
|
||||
NEXT_PUBLIC_CLOUD_URL=https://9router.com
|
||||
|
||||
# Optional outbound proxy variables for upstream provider calls
|
||||
# Lowercase variants are also supported: http_proxy, https_proxy, all_proxy, no_proxy
|
||||
# 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
|
||||
|
||||
# Currently unused by application runtime (kept as reference)
|
||||
# INSTANCE_NAME=9router
|
||||
Vendored
-2
@@ -1,2 +0,0 @@
|
||||
version: 2
|
||||
updates: []
|
||||
-70
@@ -1,70 +0,0 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
# Uncomment to also build on master pushes (rolling latest).
|
||||
# Pair with concurrency + paths below to avoid excessive builds.
|
||||
# branches:
|
||||
# - master
|
||||
# paths:
|
||||
# - 'src/**'
|
||||
# - 'open-sse/**'
|
||||
# - 'public/**'
|
||||
# - 'package*.json'
|
||||
# - 'next.config.*'
|
||||
# - 'Dockerfile'
|
||||
workflow_dispatch:
|
||||
|
||||
# Uncomment if re-enabling master push trigger.
|
||||
# concurrency:
|
||||
# group: docker-${{ github.ref }}
|
||||
# cancel-in-progress: true
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=sha,prefix=
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
|
||||
platforms: linux/amd64
|
||||
provenance: false
|
||||
sbom: false
|
||||
@@ -1,70 +0,0 @@
|
||||
# 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
|
||||
|
||||
# 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
|
||||
test/*
|
||||
bin/*
|
||||
open-sse/test/*
|
||||
RM.vn.md
|
||||
RM.md
|
||||
cursor/*
|
||||
PUBLIC.md
|
||||
Thanks.md
|
||||
PUBLIC.en.md
|
||||
PR/*
|
||||
package-lock.json
|
||||
|
||||
|
||||
#Ignore vscode AI rules
|
||||
.github/instructions/codacy.instructions.md
|
||||
README1.md
|
||||
deploy.sh
|
||||
ecosystem.config.*
|
||||
start.sh
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
[submodule "src/mitm/dev"]
|
||||
path = src/mitm/dev
|
||||
url = https://github.com/decolua/9router-dev.git
|
||||
@@ -1,60 +0,0 @@
|
||||
# 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/
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Vendored
-20
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
# Unreleased
|
||||
|
||||
## Features
|
||||
- Added API key visibility toggle (eye icon) to Endpoint dashboard page for improved UX and security.
|
||||
|
||||
# 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.
|
||||
@@ -1,35 +0,0 @@
|
||||
FROM node:20-alpine 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 . ./
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
LABEL org.opencontainers.image.title="9router"
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=20128
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/open-sse ./open-sse
|
||||
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Fix permissions at runtime (handles mounted volumes)
|
||||
RUN printf '#!/bin/sh\nchown -R node:node /app/data 2>/dev/null; exec su-exec node "$@"\n' > /entrypoint.sh && chmod +x /entrypoint.sh
|
||||
RUN apk add --no-cache su-exec
|
||||
|
||||
EXPOSE 20128
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["node", "server.js"]
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024-2026 decolua and contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
-1211
File diff suppressed because it is too large
Load Diff
@@ -1,3 +0,0 @@
|
||||
.wrangler/*
|
||||
node_modules/*
|
||||
**node_modules/*
|
||||
@@ -1,25 +0,0 @@
|
||||
# 9Router Cloud Worker
|
||||
|
||||
Deploy your own Cloudflare Worker to access 9Router from anywhere.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
# 1. Login to Cloudflare
|
||||
npm install -g wrangler
|
||||
wrangler login
|
||||
|
||||
# 2. Install dependencies
|
||||
cd app/cloud
|
||||
npm install
|
||||
|
||||
# 3. Create KV & D1, then paste IDs into wrangler.toml
|
||||
wrangler kv namespace create KV
|
||||
wrangler d1 create proxy-db
|
||||
|
||||
# 4. Init database & deploy
|
||||
wrangler d1 execute proxy-db --remote --file=./migrations/0001_init.sql
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
Copy your Worker URL → 9Router Dashboard → **Endpoint** → **Setup Cloud** → paste → **Save** → **Enable Cloud**.
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"open-sse": ["../open-sse"],
|
||||
"open-sse/*": ["../open-sse/*"]
|
||||
},
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"target": "ESNext"
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
-- Migration: Create machines table
|
||||
CREATE TABLE IF NOT EXISTS machines (
|
||||
machineId TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Index for faster lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_machines_updatedAt ON machines(updatedAt);
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"name": "9router-cloud",
|
||||
"version": "0.2.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "9Router Cloud Worker - Self-hosted Cloudflare Worker proxy",
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy"
|
||||
},
|
||||
"dependencies": {
|
||||
"open-sse": "file:../open-sse"
|
||||
},
|
||||
"devDependencies": {
|
||||
"wrangler": "^3.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { errorResponse } from "open-sse/utils/error.js";
|
||||
import { extractBearerToken, parseApiKey } from "../utils/apiKey.js";
|
||||
import * as log from "../utils/logger.js";
|
||||
|
||||
export async function handleCacheClear(request, env) {
|
||||
const apiKey = extractBearerToken(request);
|
||||
if (!apiKey) {
|
||||
return errorResponse(401, "Missing API key");
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
|
||||
// Get machineId from API key or body
|
||||
let machineId = body.machineId;
|
||||
if (!machineId) {
|
||||
const parsed = await parseApiKey(apiKey);
|
||||
machineId = parsed?.machineId;
|
||||
}
|
||||
|
||||
if (!machineId) {
|
||||
return errorResponse(400, "Missing machineId");
|
||||
}
|
||||
|
||||
// No cache layer to clear anymore
|
||||
log.info("CACHE", `Cache clear requested for machine: ${machineId} (no-op)`);
|
||||
|
||||
return new Response(JSON.stringify({ success: true, machineId, message: "No cache layer" }), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*"
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(500, error.message);
|
||||
}
|
||||
}
|
||||
@@ -1,305 +0,0 @@
|
||||
import { getModelInfoCore } from "open-sse/services/model.js";
|
||||
import { handleChatCore } from "open-sse/handlers/chatCore.js";
|
||||
import { errorResponse } from "open-sse/utils/error.js";
|
||||
import { checkFallbackError, isAccountUnavailable, getUnavailableUntil, getEarliestRateLimitedUntil, formatRetryAfter } from "open-sse/services/accountFallback.js";
|
||||
import { getComboModelsFromData, handleComboChat } from "open-sse/services/combo.js";
|
||||
import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
|
||||
import * as log from "../utils/logger.js";
|
||||
import { refreshTokenByProvider } from "../services/tokenRefresh.js";
|
||||
import { parseApiKey, extractBearerToken } from "../utils/apiKey.js";
|
||||
import { getMachineData, saveMachineData } from "../services/storage.js";
|
||||
|
||||
const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
|
||||
|
||||
async function getModelInfo(modelStr, machineId, env) {
|
||||
const data = await getMachineData(machineId, env);
|
||||
return getModelInfoCore(modelStr, data?.modelAliases || {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle chat request
|
||||
* @param {Request} request
|
||||
* @param {Object} env
|
||||
* @param {Object} ctx
|
||||
* @param {string|null} machineIdOverride - machineId from URL (old format) or null (new format - extract from key)
|
||||
*/
|
||||
export async function handleChat(request, env, ctx, machineIdOverride = null) {
|
||||
if (request.method === "OPTIONS") {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Determine machineId: from URL (old) or from API key (new)
|
||||
let machineId = machineIdOverride;
|
||||
|
||||
if (!machineId) {
|
||||
// New format: extract machineId from API key
|
||||
const apiKey = extractBearerToken(request);
|
||||
if (!apiKey) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key");
|
||||
|
||||
const parsed = await parseApiKey(apiKey);
|
||||
if (!parsed) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key format");
|
||||
|
||||
if (!parsed.isNewFormat || !parsed.machineId) {
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, "API key does not contain machineId. Use /{machineId}/v1/... endpoint for old format keys.");
|
||||
}
|
||||
|
||||
machineId = parsed.machineId;
|
||||
}
|
||||
|
||||
if (!await validateApiKey(request, machineId, env)) {
|
||||
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
|
||||
}
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body");
|
||||
}
|
||||
|
||||
log.info("CHAT", `${machineId} | ${body.model}`, { stream: body.stream !== false });
|
||||
|
||||
const modelStr = body.model;
|
||||
if (!modelStr) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model");
|
||||
|
||||
// Check if model is a combo
|
||||
const data = await getMachineData(machineId, env);
|
||||
const comboModels = getComboModelsFromData(modelStr, data?.combos || []);
|
||||
|
||||
if (comboModels) {
|
||||
log.info("COMBO", `"${modelStr}" with ${comboModels.length} models`);
|
||||
return handleComboChat({
|
||||
body,
|
||||
models: comboModels,
|
||||
handleSingleModel: (reqBody, model) => handleSingleModelChat(reqBody, model, machineId, env),
|
||||
log
|
||||
});
|
||||
}
|
||||
|
||||
// Single model request
|
||||
return handleSingleModelChat(body, modelStr, machineId, env);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle single model chat request
|
||||
*/
|
||||
async function handleSingleModelChat(body, modelStr, machineId, env) {
|
||||
const modelInfo = await getModelInfo(modelStr, machineId, env);
|
||||
if (!modelInfo.provider) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format");
|
||||
|
||||
const { provider, model } = modelInfo;
|
||||
log.info("MODEL", `${provider.toUpperCase()} | ${model}`);
|
||||
|
||||
let excludeConnectionId = null;
|
||||
let lastError = null;
|
||||
let lastStatus = null;
|
||||
|
||||
while (true) {
|
||||
const credentials = await getProviderCredentials(machineId, provider, env, excludeConnectionId);
|
||||
if (!credentials || credentials.allRateLimited) {
|
||||
if (credentials?.allRateLimited) {
|
||||
const retryAfterSec = Math.ceil((new Date(credentials.retryAfter).getTime() - Date.now()) / 1000);
|
||||
const errorMsg = lastError || credentials.lastError || "Unavailable";
|
||||
const msg = `[${provider}/${model}] ${errorMsg} (${credentials.retryAfterHuman})`;
|
||||
const status = lastStatus || Number(credentials.lastErrorCode) || HTTP_STATUS.SERVICE_UNAVAILABLE;
|
||||
log.warn("CHAT", `${provider.toUpperCase()} | ${msg}`);
|
||||
return new Response(
|
||||
JSON.stringify({ error: { message: msg } }),
|
||||
{ status, headers: { "Content-Type": "application/json", "Retry-After": String(Math.max(retryAfterSec, 1)) } }
|
||||
);
|
||||
}
|
||||
if (!excludeConnectionId) {
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`);
|
||||
}
|
||||
log.warn("CHAT", `${provider.toUpperCase()} | no more accounts`);
|
||||
return new Response(
|
||||
JSON.stringify({ error: lastError || "All accounts unavailable" }),
|
||||
{ status: lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
log.debug("CHAT", `account=${credentials.id}`, { provider });
|
||||
|
||||
const refreshedCredentials = await checkAndRefreshToken(machineId, provider, credentials, env);
|
||||
|
||||
// Use shared chatCore
|
||||
const result = await handleChatCore({
|
||||
body,
|
||||
modelInfo: { provider, model },
|
||||
credentials: refreshedCredentials,
|
||||
log,
|
||||
onCredentialsRefreshed: async (newCreds) => {
|
||||
await updateCredentials(machineId, credentials.id, newCreds, env);
|
||||
},
|
||||
onRequestSuccess: async () => {
|
||||
// Clear error status only if currently has error (optimization)
|
||||
await clearAccountError(machineId, credentials.id, credentials, env);
|
||||
}
|
||||
});
|
||||
|
||||
if (result.success) return result.response;
|
||||
|
||||
const { shouldFallback } = checkFallbackError(result.status, result.error);
|
||||
|
||||
if (shouldFallback) {
|
||||
log.warn("FALLBACK", `${provider.toUpperCase()} | ${credentials.id} | ${result.status}`);
|
||||
await markAccountUnavailable(machineId, credentials.id, result.status, result.error, env);
|
||||
excludeConnectionId = credentials.id;
|
||||
lastError = result.error;
|
||||
lastStatus = result.status;
|
||||
continue;
|
||||
}
|
||||
|
||||
return result.response;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAndRefreshToken(machineId, provider, credentials, env) {
|
||||
if (!credentials.expiresAt) return credentials;
|
||||
|
||||
const expiresAt = new Date(credentials.expiresAt).getTime();
|
||||
if (expiresAt - Date.now() >= TOKEN_EXPIRY_BUFFER_MS) return credentials;
|
||||
|
||||
log.debug("TOKEN", `${provider.toUpperCase()} | expiring, refreshing`);
|
||||
|
||||
const newCredentials = await refreshTokenByProvider(provider, credentials);
|
||||
if (newCredentials?.accessToken) {
|
||||
await updateCredentials(machineId, credentials.id, newCredentials, env);
|
||||
return {
|
||||
...credentials,
|
||||
accessToken: newCredentials.accessToken,
|
||||
refreshToken: newCredentials.refreshToken || credentials.refreshToken,
|
||||
expiresAt: newCredentials.expiresIn
|
||||
? new Date(Date.now() + newCredentials.expiresIn * 1000).toISOString()
|
||||
: credentials.expiresAt
|
||||
};
|
||||
}
|
||||
|
||||
return credentials;
|
||||
}
|
||||
|
||||
async function validateApiKey(request, machineId, env) {
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
if (!authHeader?.startsWith("Bearer ")) return false;
|
||||
|
||||
const apiKey = authHeader.slice(7);
|
||||
const data = await getMachineData(machineId, env);
|
||||
return data?.apiKeys?.some(k => k.key === apiKey) || false;
|
||||
}
|
||||
|
||||
async function getProviderCredentials(machineId, provider, env, excludeConnectionId = null) {
|
||||
const data = await getMachineData(machineId, env);
|
||||
if (!data?.providers) return null;
|
||||
|
||||
const providerConnections = Object.entries(data.providers)
|
||||
.filter(([connId, conn]) => {
|
||||
if (conn.provider !== provider || !conn.isActive) return false;
|
||||
if (excludeConnectionId && connId === excludeConnectionId) return false;
|
||||
if (isAccountUnavailable(conn.rateLimitedUntil)) return false;
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => (a[1].priority || 999) - (b[1].priority || 999));
|
||||
|
||||
if (providerConnections.length === 0) {
|
||||
// Check if accounts exist but all rate limited
|
||||
const allConnections = Object.entries(data.providers)
|
||||
.filter(([, conn]) => conn.provider === provider && conn.isActive)
|
||||
.map(([, conn]) => conn);
|
||||
const earliest = getEarliestRateLimitedUntil(allConnections);
|
||||
if (earliest) {
|
||||
const rateLimitedConns = allConnections.filter(c => c.rateLimitedUntil && new Date(c.rateLimitedUntil).getTime() > Date.now());
|
||||
const earliestConn = rateLimitedConns.sort((a, b) => new Date(a.rateLimitedUntil) - new Date(b.rateLimitedUntil))[0];
|
||||
return {
|
||||
allRateLimited: true,
|
||||
retryAfter: earliest,
|
||||
retryAfterHuman: formatRetryAfter(earliest),
|
||||
lastError: earliestConn?.lastError || null,
|
||||
lastErrorCode: earliestConn?.errorCode || null
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const [connectionId, connection] = providerConnections[0];
|
||||
|
||||
return {
|
||||
id: connectionId,
|
||||
apiKey: connection.apiKey,
|
||||
accessToken: connection.accessToken,
|
||||
refreshToken: connection.refreshToken,
|
||||
expiresAt: connection.expiresAt,
|
||||
projectId: connection.projectId,
|
||||
copilotToken: connection.providerSpecificData?.copilotToken,
|
||||
providerSpecificData: connection.providerSpecificData,
|
||||
// Include current status for optimization check
|
||||
status: connection.status,
|
||||
lastError: connection.lastError,
|
||||
rateLimitedUntil: connection.rateLimitedUntil
|
||||
};
|
||||
}
|
||||
|
||||
async function markAccountUnavailable(machineId, connectionId, status, errorText, env) {
|
||||
const data = await getMachineData(machineId, env);
|
||||
if (!data?.providers?.[connectionId]) return;
|
||||
|
||||
const conn = data.providers[connectionId];
|
||||
const backoffLevel = conn.backoffLevel || 0;
|
||||
const { cooldownMs, newBackoffLevel } = checkFallbackError(status, errorText, backoffLevel);
|
||||
const rateLimitedUntil = getUnavailableUntil(cooldownMs);
|
||||
const reason = typeof errorText === "string" ? errorText.slice(0, 100) : "Provider error";
|
||||
|
||||
data.providers[connectionId].rateLimitedUntil = rateLimitedUntil;
|
||||
data.providers[connectionId].status = "unavailable";
|
||||
data.providers[connectionId].lastError = reason;
|
||||
data.providers[connectionId].errorCode = status || null;
|
||||
data.providers[connectionId].lastErrorAt = new Date().toISOString();
|
||||
data.providers[connectionId].backoffLevel = newBackoffLevel ?? backoffLevel;
|
||||
data.providers[connectionId].updatedAt = new Date().toISOString();
|
||||
|
||||
await saveMachineData(machineId, data, env);
|
||||
log.warn("ACCOUNT", `${connectionId} | unavailable until ${rateLimitedUntil} (backoff=${newBackoffLevel ?? backoffLevel})`);
|
||||
}
|
||||
|
||||
async function clearAccountError(machineId, connectionId, currentCredentials, env) {
|
||||
// Only update if currently has error status (optimization)
|
||||
const hasError = currentCredentials.status === "unavailable" ||
|
||||
currentCredentials.lastError ||
|
||||
currentCredentials.rateLimitedUntil;
|
||||
|
||||
if (!hasError) return; // Skip if already clean
|
||||
|
||||
const data = await getMachineData(machineId, env);
|
||||
if (!data?.providers?.[connectionId]) return;
|
||||
|
||||
data.providers[connectionId].status = "active";
|
||||
data.providers[connectionId].lastError = null;
|
||||
data.providers[connectionId].lastErrorAt = null;
|
||||
data.providers[connectionId].rateLimitedUntil = null;
|
||||
data.providers[connectionId].backoffLevel = 0;
|
||||
data.providers[connectionId].updatedAt = new Date().toISOString();
|
||||
|
||||
await saveMachineData(machineId, data, env);
|
||||
log.info("ACCOUNT", `${connectionId} | error cleared`);
|
||||
}
|
||||
|
||||
async function updateCredentials(machineId, connectionId, newCredentials, env) {
|
||||
const data = await getMachineData(machineId, env);
|
||||
if (!data?.providers?.[connectionId]) return;
|
||||
|
||||
data.providers[connectionId].accessToken = newCredentials.accessToken;
|
||||
if (newCredentials.refreshToken) data.providers[connectionId].refreshToken = newCredentials.refreshToken;
|
||||
if (newCredentials.expiresIn) {
|
||||
data.providers[connectionId].expiresAt = new Date(Date.now() + newCredentials.expiresIn * 1000).toISOString();
|
||||
data.providers[connectionId].expiresIn = newCredentials.expiresIn;
|
||||
}
|
||||
data.providers[connectionId].updatedAt = new Date().toISOString();
|
||||
|
||||
await saveMachineData(machineId, data, env);
|
||||
log.debug("TOKEN", `credentials updated | ${connectionId}`);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import * as log from "../utils/logger.js";
|
||||
|
||||
const RETENTION_DAYS = 7;
|
||||
|
||||
/**
|
||||
* Cleanup old machine data from D1
|
||||
* Runs daily via cron trigger
|
||||
*/
|
||||
export async function handleCleanup(env) {
|
||||
const cutoffDate = new Date(Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
log.info("CLEANUP", `Deleting records older than ${cutoffDate}`);
|
||||
|
||||
try {
|
||||
const result = await env.DB.prepare("DELETE FROM machines WHERE updatedAt < ?")
|
||||
.bind(cutoffDate)
|
||||
.run();
|
||||
|
||||
log.info("CLEANUP", `Deleted ${result.meta?.changes || 0} old records`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deleted: result.meta?.changes || 0,
|
||||
cutoffDate
|
||||
};
|
||||
} catch (error) {
|
||||
log.error("CLEANUP", error.message);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { errorResponse } from "open-sse/utils/error.js";
|
||||
|
||||
const CORS_HEADERS = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*"
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle POST /{machineId}/v1/messages/count_tokens
|
||||
* Mock token count response based on content length
|
||||
*/
|
||||
export async function handleCountTokens(request, env) {
|
||||
let body;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse(400, "Invalid JSON body");
|
||||
}
|
||||
|
||||
// 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 }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
import { getModelInfoCore } from "open-sse/services/model.js";
|
||||
import { handleEmbeddingsCore } from "open-sse/handlers/embeddingsCore.js";
|
||||
import { errorResponse } from "open-sse/utils/error.js";
|
||||
import {
|
||||
checkFallbackError,
|
||||
isAccountUnavailable,
|
||||
getEarliestRateLimitedUntil,
|
||||
getUnavailableUntil,
|
||||
formatRetryAfter
|
||||
} from "open-sse/services/accountFallback.js";
|
||||
import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
|
||||
import * as log from "../utils/logger.js";
|
||||
import { parseApiKey, extractBearerToken } from "../utils/apiKey.js";
|
||||
import { getMachineData, saveMachineData } from "../services/storage.js";
|
||||
|
||||
/**
|
||||
* Handle POST /v1/embeddings and /{machineId}/v1/embeddings requests.
|
||||
*
|
||||
* Follows the same auth + fallback pattern as handleChat:
|
||||
* 1. Resolve machineId (from URL or API key)
|
||||
* 2. Validate API key
|
||||
* 3. Parse model → provider/model
|
||||
* 4. Get provider credentials with fallback loop
|
||||
* 5. Delegate to handleEmbeddingsCore (open-sse)
|
||||
*
|
||||
* @param {Request} request
|
||||
* @param {object} env - Cloudflare env bindings
|
||||
* @param {object} ctx - Execution context
|
||||
* @param {string|null} machineIdOverride - From URL path (old format), or null (new format)
|
||||
*/
|
||||
export async function handleEmbeddings(request, env, ctx, machineIdOverride = null) {
|
||||
if (request.method === "OPTIONS") {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve machineId
|
||||
let machineId = machineIdOverride;
|
||||
|
||||
if (!machineId) {
|
||||
const apiKey = extractBearerToken(request);
|
||||
if (!apiKey) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key");
|
||||
|
||||
const parsed = await parseApiKey(apiKey);
|
||||
if (!parsed) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key format");
|
||||
|
||||
if (!parsed.isNewFormat || !parsed.machineId) {
|
||||
return errorResponse(
|
||||
HTTP_STATUS.BAD_REQUEST,
|
||||
"API key does not contain machineId. Use /{machineId}/v1/... endpoint for old format keys."
|
||||
);
|
||||
}
|
||||
machineId = parsed.machineId;
|
||||
}
|
||||
|
||||
// Validate API key
|
||||
if (!await validateApiKey(request, machineId, env)) {
|
||||
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
|
||||
}
|
||||
|
||||
// Parse body
|
||||
let body;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body");
|
||||
}
|
||||
|
||||
const modelStr = body.model;
|
||||
if (!modelStr) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model");
|
||||
|
||||
if (!body.input) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: input");
|
||||
|
||||
log.info("EMBEDDINGS", `${machineId} | ${modelStr}`);
|
||||
|
||||
// Resolve model info
|
||||
const data = await getMachineData(machineId, env);
|
||||
const modelInfo = await getModelInfoCore(modelStr, data?.modelAliases || {});
|
||||
if (!modelInfo.provider) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format");
|
||||
|
||||
const { provider, model } = modelInfo;
|
||||
log.info("EMBEDDINGS_MODEL", `${provider.toUpperCase()} | ${model}`);
|
||||
|
||||
// Provider credential + fallback loop (mirrors handleChat)
|
||||
let excludeConnectionId = null;
|
||||
let lastError = null;
|
||||
let lastStatus = null;
|
||||
|
||||
while (true) {
|
||||
const credentials = await getProviderCredentials(machineId, provider, env, excludeConnectionId);
|
||||
|
||||
if (!credentials || credentials.allRateLimited) {
|
||||
if (credentials?.allRateLimited) {
|
||||
const retryAfterSec = Math.ceil(
|
||||
(new Date(credentials.retryAfter).getTime() - Date.now()) / 1000
|
||||
);
|
||||
const errorMsg = lastError || credentials.lastError || "Unavailable";
|
||||
const msg = `[${provider}/${model}] ${errorMsg} (${credentials.retryAfterHuman})`;
|
||||
const status = lastStatus || Number(credentials.lastErrorCode) || HTTP_STATUS.SERVICE_UNAVAILABLE;
|
||||
log.warn("EMBEDDINGS", `${provider.toUpperCase()} | ${msg}`);
|
||||
return new Response(
|
||||
JSON.stringify({ error: { message: msg } }),
|
||||
{
|
||||
status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Retry-After": String(Math.max(retryAfterSec, 1))
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
if (!excludeConnectionId) {
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`);
|
||||
}
|
||||
log.warn("EMBEDDINGS", `${provider.toUpperCase()} | no more accounts`);
|
||||
return new Response(
|
||||
JSON.stringify({ error: lastError || "All accounts unavailable" }),
|
||||
{
|
||||
status: lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
log.debug("EMBEDDINGS", `account=${credentials.id}`, { provider });
|
||||
|
||||
const result = await handleEmbeddingsCore({
|
||||
body,
|
||||
modelInfo: { provider, model },
|
||||
credentials,
|
||||
log,
|
||||
onCredentialsRefreshed: async (newCreds) => {
|
||||
await updateCredentials(machineId, credentials.id, newCreds, env);
|
||||
},
|
||||
onRequestSuccess: async () => {
|
||||
await clearAccountError(machineId, credentials.id, credentials, env);
|
||||
}
|
||||
});
|
||||
|
||||
if (result.success) return result.response;
|
||||
|
||||
const { shouldFallback } = checkFallbackError(result.status, result.error);
|
||||
|
||||
if (shouldFallback) {
|
||||
log.warn("EMBEDDINGS_FALLBACK", `${provider.toUpperCase()} | ${credentials.id} | ${result.status}`);
|
||||
await markAccountUnavailable(machineId, credentials.id, result.status, result.error, env);
|
||||
excludeConnectionId = credentials.id;
|
||||
lastError = result.error;
|
||||
lastStatus = result.status;
|
||||
continue;
|
||||
}
|
||||
|
||||
return result.response;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers (same as chat.js) ───────────────────────────────────────────────
|
||||
|
||||
async function validateApiKey(request, machineId, env) {
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
if (!authHeader?.startsWith("Bearer ")) return false;
|
||||
|
||||
const apiKey = authHeader.slice(7);
|
||||
const data = await getMachineData(machineId, env);
|
||||
return data?.apiKeys?.some(k => k.key === apiKey) || false;
|
||||
}
|
||||
|
||||
async function getProviderCredentials(machineId, provider, env, excludeConnectionId = null) {
|
||||
const data = await getMachineData(machineId, env);
|
||||
if (!data?.providers) return null;
|
||||
|
||||
const providerConnections = Object.entries(data.providers)
|
||||
.filter(([connId, conn]) => {
|
||||
if (conn.provider !== provider || !conn.isActive) return false;
|
||||
if (excludeConnectionId && connId === excludeConnectionId) return false;
|
||||
if (isAccountUnavailable(conn.rateLimitedUntil)) return false;
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => (a[1].priority || 999) - (b[1].priority || 999));
|
||||
|
||||
if (providerConnections.length === 0) {
|
||||
const allConnections = Object.entries(data.providers)
|
||||
.filter(([, conn]) => conn.provider === provider && conn.isActive)
|
||||
.map(([, conn]) => conn);
|
||||
const earliest = getEarliestRateLimitedUntil(allConnections);
|
||||
if (earliest) {
|
||||
const rateLimitedConns = allConnections.filter(
|
||||
c => c.rateLimitedUntil && new Date(c.rateLimitedUntil).getTime() > Date.now()
|
||||
);
|
||||
const earliestConn = rateLimitedConns.sort(
|
||||
(a, b) => new Date(a.rateLimitedUntil) - new Date(b.rateLimitedUntil)
|
||||
)[0];
|
||||
return {
|
||||
allRateLimited: true,
|
||||
retryAfter: earliest,
|
||||
retryAfterHuman: formatRetryAfter(earliest),
|
||||
lastError: earliestConn?.lastError || null,
|
||||
lastErrorCode: earliestConn?.errorCode || null
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const [connectionId, connection] = providerConnections[0];
|
||||
return {
|
||||
id: connectionId,
|
||||
apiKey: connection.apiKey,
|
||||
accessToken: connection.accessToken,
|
||||
refreshToken: connection.refreshToken,
|
||||
expiresAt: connection.expiresAt,
|
||||
projectId: connection.projectId,
|
||||
providerSpecificData: connection.providerSpecificData,
|
||||
status: connection.status,
|
||||
lastError: connection.lastError,
|
||||
rateLimitedUntil: connection.rateLimitedUntil
|
||||
};
|
||||
}
|
||||
|
||||
async function markAccountUnavailable(machineId, connectionId, status, errorText, env) {
|
||||
const data = await getMachineData(machineId, env);
|
||||
if (!data?.providers?.[connectionId]) return;
|
||||
|
||||
const conn = data.providers[connectionId];
|
||||
const backoffLevel = conn.backoffLevel || 0;
|
||||
const { cooldownMs, newBackoffLevel } = checkFallbackError(status, errorText, backoffLevel);
|
||||
const rateLimitedUntil = getUnavailableUntil(cooldownMs);
|
||||
const reason = typeof errorText === "string" ? errorText.slice(0, 100) : "Provider error";
|
||||
|
||||
data.providers[connectionId].rateLimitedUntil = rateLimitedUntil;
|
||||
data.providers[connectionId].status = "unavailable";
|
||||
data.providers[connectionId].lastError = reason;
|
||||
data.providers[connectionId].errorCode = status || null;
|
||||
data.providers[connectionId].lastErrorAt = new Date().toISOString();
|
||||
data.providers[connectionId].backoffLevel = newBackoffLevel ?? backoffLevel;
|
||||
data.providers[connectionId].updatedAt = new Date().toISOString();
|
||||
|
||||
await saveMachineData(machineId, data, env);
|
||||
log.warn("EMBEDDINGS_ACCOUNT", `${connectionId} | unavailable until ${rateLimitedUntil}`);
|
||||
}
|
||||
|
||||
async function clearAccountError(machineId, connectionId, currentCredentials, env) {
|
||||
const hasError =
|
||||
currentCredentials.status === "unavailable" ||
|
||||
currentCredentials.lastError ||
|
||||
currentCredentials.rateLimitedUntil;
|
||||
|
||||
if (!hasError) return;
|
||||
|
||||
const data = await getMachineData(machineId, env);
|
||||
if (!data?.providers?.[connectionId]) return;
|
||||
|
||||
data.providers[connectionId].status = "active";
|
||||
data.providers[connectionId].lastError = null;
|
||||
data.providers[connectionId].lastErrorAt = null;
|
||||
data.providers[connectionId].rateLimitedUntil = null;
|
||||
data.providers[connectionId].backoffLevel = 0;
|
||||
data.providers[connectionId].updatedAt = new Date().toISOString();
|
||||
|
||||
await saveMachineData(machineId, data, env);
|
||||
log.info("EMBEDDINGS_ACCOUNT", `${connectionId} | error cleared`);
|
||||
}
|
||||
|
||||
async function updateCredentials(machineId, connectionId, newCredentials, env) {
|
||||
const data = await getMachineData(machineId, env);
|
||||
if (!data?.providers?.[connectionId]) return;
|
||||
|
||||
data.providers[connectionId].accessToken = newCredentials.accessToken;
|
||||
if (newCredentials.refreshToken)
|
||||
data.providers[connectionId].refreshToken = newCredentials.refreshToken;
|
||||
if (newCredentials.expiresIn) {
|
||||
data.providers[connectionId].expiresAt = new Date(
|
||||
Date.now() + newCredentials.expiresIn * 1000
|
||||
).toISOString();
|
||||
data.providers[connectionId].expiresIn = newCredentials.expiresIn;
|
||||
}
|
||||
data.providers[connectionId].updatedAt = new Date().toISOString();
|
||||
|
||||
await saveMachineData(machineId, data, env);
|
||||
log.debug("EMBEDDINGS_TOKEN", `credentials updated | ${connectionId}`);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
// CF headers to remove
|
||||
const CF_HEADERS = [
|
||||
"cf-connecting-ip", "cf-connecting-ip6", "cf-ray", "cf-visitor",
|
||||
"cf-ipcountry", "cf-tracking-id", "cf-connecting-ip6-policy",
|
||||
"x-real-ip", "x-forwarded-for", "x-forwarded-proto", "x-forwarded-host"
|
||||
];
|
||||
|
||||
// Forward request to any endpoint
|
||||
export async function handleForward(request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const clientIp = request.headers.get("CF-Connecting-IP") || "";
|
||||
const { targetUrl, headers = {}, body } = await request.json();
|
||||
|
||||
if (!targetUrl) {
|
||||
return new Response(JSON.stringify({ error: "targetUrl is required" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
|
||||
// Filter out CF headers from input
|
||||
const cleanHeaders = {};
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (!CF_HEADERS.includes(key.toLowerCase())) {
|
||||
cleanHeaders[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Set standard forwarding headers
|
||||
cleanHeaders["X-Client-IP"] = clientIp;
|
||||
cleanHeaders["X-Forwarded-Proto"] = url.protocol.replace(":", "");
|
||||
cleanHeaders["X-Forwarded-Host"] = url.host;
|
||||
cleanHeaders["X-From-Worker"] = "1";
|
||||
|
||||
console.log("[FORWARD] Target:", targetUrl);
|
||||
console.log("[FORWARD] Headers:", JSON.stringify(cleanHeaders));
|
||||
|
||||
// Create Request object to have more control over headers
|
||||
const outgoingRequest = new Request(targetUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...cleanHeaders
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
// Use fetch with cf options to minimize auto-added headers
|
||||
const response = await fetch(outgoingRequest, {
|
||||
cf: {
|
||||
// Disable automatic features that add headers
|
||||
scrapeShield: false,
|
||||
minify: false,
|
||||
mirage: false,
|
||||
polish: "off"
|
||||
}
|
||||
});
|
||||
|
||||
// Stream response back to client
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
"Content-Type": response.headers.get("Content-Type") || "application/json",
|
||||
"Access-Control-Allow-Origin": "*"
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[FORWARD] Error:", error.message);
|
||||
return new Response(JSON.stringify({ error: error.message }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
import { connect } from "cloudflare:sockets";
|
||||
|
||||
// Forward request via raw TCP socket (bypasses CF auto headers)
|
||||
export async function handleForwardRaw(request) {
|
||||
try {
|
||||
const { targetUrl, headers = {}, body } = await request.json();
|
||||
|
||||
if (!targetUrl) {
|
||||
return new Response(JSON.stringify({ error: "targetUrl is required" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
|
||||
const url = new URL(targetUrl);
|
||||
const host = url.hostname;
|
||||
const port = url.port || (url.protocol === "https:" ? 443 : 80);
|
||||
const path = url.pathname + url.search;
|
||||
const isHttps = url.protocol === "https:";
|
||||
|
||||
console.log("[FORWARD_RAW] Connecting to:", host, port, isHttps ? "(TLS)" : "");
|
||||
|
||||
// Connect to target server
|
||||
let secureSocket;
|
||||
if (isHttps) {
|
||||
// For HTTPS, connect directly with TLS enabled
|
||||
console.log("[FORWARD_RAW] Creating TLS socket...");
|
||||
secureSocket = connect({
|
||||
hostname: host,
|
||||
port: parseInt(port),
|
||||
secureTransport: "on"
|
||||
});
|
||||
console.log("[FORWARD_RAW] TLS socket created");
|
||||
} else {
|
||||
secureSocket = connect({ hostname: host, port: parseInt(port) });
|
||||
}
|
||||
|
||||
console.log("[FORWARD_RAW] Socket object:", secureSocket);
|
||||
console.log("[FORWARD_RAW] Socket opened:", secureSocket.opened);
|
||||
|
||||
// Wait for socket to be ready
|
||||
try {
|
||||
console.log("[FORWARD_RAW] Waiting for socket to open...");
|
||||
await secureSocket.opened;
|
||||
console.log("[FORWARD_RAW] Socket opened successfully");
|
||||
} catch (openError) {
|
||||
console.error("[FORWARD_RAW] Socket open error:", openError.message);
|
||||
throw openError;
|
||||
}
|
||||
|
||||
console.log("[FORWARD_RAW] Getting writer and reader...");
|
||||
const writer = secureSocket.writable.getWriter();
|
||||
const reader = secureSocket.readable.getReader();
|
||||
console.log("[FORWARD_RAW] Writer and reader obtained");
|
||||
|
||||
// Build raw HTTP request
|
||||
const bodyStr = JSON.stringify(body);
|
||||
const requestHeaders = {
|
||||
"Host": host,
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": new TextEncoder().encode(bodyStr).length.toString(),
|
||||
"Connection": "close",
|
||||
...headers
|
||||
};
|
||||
|
||||
// Build HTTP request string
|
||||
let httpRequest = `POST ${path} HTTP/1.1\r\n`;
|
||||
for (const [key, value] of Object.entries(requestHeaders)) {
|
||||
httpRequest += `${key}: ${value}\r\n`;
|
||||
}
|
||||
httpRequest += `\r\n${bodyStr}`;
|
||||
|
||||
console.log("[FORWARD_RAW] Sending request:", httpRequest.substring(0, 300));
|
||||
console.log("[FORWARD_RAW] Full request length:", httpRequest.length);
|
||||
|
||||
// Send request
|
||||
try {
|
||||
console.log("[FORWARD_RAW] Writing to socket...");
|
||||
await writer.write(new TextEncoder().encode(httpRequest));
|
||||
console.log("[FORWARD_RAW] Write complete, closing writer...");
|
||||
await writer.close();
|
||||
console.log("[FORWARD_RAW] Writer closed");
|
||||
} catch (writeError) {
|
||||
console.error("[FORWARD_RAW] Write error:", writeError.message);
|
||||
throw writeError;
|
||||
}
|
||||
|
||||
// Read response with timeout
|
||||
console.log("[FORWARD_RAW] Starting to read response...");
|
||||
let responseData = new Uint8Array(0);
|
||||
let attempts = 0;
|
||||
const maxAttempts = 100; // 10 seconds max
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
console.log("[FORWARD_RAW] Reading attempt:", attempts);
|
||||
const { done, value } = await reader.read();
|
||||
console.log("[FORWARD_RAW] Read result - done:", done, "value length:", value?.length);
|
||||
if (done) break;
|
||||
if (value) {
|
||||
const newData = new Uint8Array(responseData.length + value.length);
|
||||
newData.set(responseData);
|
||||
newData.set(value, responseData.length);
|
||||
responseData = newData;
|
||||
|
||||
// Check if we have complete response (has headers end marker)
|
||||
const text = new TextDecoder().decode(responseData);
|
||||
if (text.includes("\r\n\r\n")) {
|
||||
// Check if we have Content-Length and received all body
|
||||
const headerEnd = text.indexOf("\r\n\r\n");
|
||||
const headers = text.substring(0, headerEnd).toLowerCase();
|
||||
const contentLengthMatch = headers.match(/content-length:\s*(\d+)/);
|
||||
if (contentLengthMatch) {
|
||||
const expectedLength = parseInt(contentLengthMatch[1]);
|
||||
const bodyReceived = text.length - headerEnd - 4;
|
||||
if (bodyReceived >= expectedLength) {
|
||||
console.log("[FORWARD_RAW] Complete response received");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
attempts++;
|
||||
}
|
||||
|
||||
console.log("[FORWARD_RAW] Read loop finished, total bytes:", responseData.length);
|
||||
|
||||
const responseText = new TextDecoder().decode(responseData);
|
||||
console.log("[FORWARD_RAW] Response received:", responseText.substring(0, 500));
|
||||
|
||||
// Parse HTTP response
|
||||
const headerEndIndex = responseText.indexOf("\r\n\r\n");
|
||||
if (headerEndIndex === -1) {
|
||||
console.log("[FORWARD_RAW] Full response data:", responseText);
|
||||
throw new Error("Invalid HTTP response - no header end found");
|
||||
}
|
||||
|
||||
const headerPart = responseText.substring(0, headerEndIndex);
|
||||
const bodyPart = responseText.substring(headerEndIndex + 4);
|
||||
|
||||
// Parse status line
|
||||
const statusLine = headerPart.split("\r\n")[0];
|
||||
const statusMatch = statusLine.match(/HTTP\/[\d.]+ (\d+)/);
|
||||
const status = statusMatch ? parseInt(statusMatch[1]) : 200;
|
||||
|
||||
// Parse headers
|
||||
const responseHeaders = {};
|
||||
const headerLines = headerPart.split("\r\n").slice(1);
|
||||
for (const line of headerLines) {
|
||||
const colonIndex = line.indexOf(":");
|
||||
if (colonIndex > 0) {
|
||||
const key = line.substring(0, colonIndex).trim();
|
||||
const value = line.substring(colonIndex + 1).trim();
|
||||
responseHeaders[key.toLowerCase()] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(bodyPart, {
|
||||
status,
|
||||
headers: {
|
||||
"Content-Type": responseHeaders["content-type"] || "application/json",
|
||||
"Access-Control-Allow-Origin": "*"
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("[FORWARD_RAW] Error:", error.message, error.stack);
|
||||
return new Response(JSON.stringify({ error: error.message }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
import * as log from "../utils/logger.js";
|
||||
import { getMachineData, saveMachineData, deleteMachineData } from "../services/storage.js";
|
||||
|
||||
const CORS_HEADERS = {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*"
|
||||
};
|
||||
|
||||
// Removed: WORKER_FIELDS and WORKER_SPECIFIC_FIELDS
|
||||
// Now syncing entire provider based on updatedAt (simpler logic)
|
||||
|
||||
export async function handleSync(request, env, ctx) {
|
||||
const url = new URL(request.url);
|
||||
const machineId = url.pathname.split("/")[2]; // /sync/:machineId
|
||||
|
||||
// Handle CORS preflight
|
||||
if (request.method === "OPTIONS") {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!machineId) {
|
||||
log.warn("SYNC", "Missing machineId in path");
|
||||
return jsonResponse({ error: "Missing machineId" }, 400);
|
||||
}
|
||||
|
||||
// Route by method
|
||||
switch (request.method) {
|
||||
case "GET":
|
||||
return handleGet(machineId, env);
|
||||
case "POST":
|
||||
return handlePost(request, machineId, env);
|
||||
case "DELETE":
|
||||
return handleDelete(machineId, env);
|
||||
default:
|
||||
return jsonResponse({ error: "Method not allowed" }, 405);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /sync/:machineId - Return merged data for Web to update
|
||||
*/
|
||||
async function handleGet(machineId, env) {
|
||||
const data = await getMachineData(machineId, env);
|
||||
|
||||
if (!data) {
|
||||
log.warn("SYNC", "No data found", { machineId });
|
||||
return jsonResponse({ error: "No data found" }, 404);
|
||||
}
|
||||
|
||||
log.info("SYNC", "Data retrieved", { machineId });
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /sync/:machineId - Merge Web data with Worker data
|
||||
* providers stored by ID (supports multiple connections per provider)
|
||||
*/
|
||||
async function handlePost(request, machineId, env) {
|
||||
let body;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
log.warn("SYNC", "Invalid JSON body", { machineId });
|
||||
return jsonResponse({ error: "Invalid JSON body" }, 400);
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!body.providers || !Array.isArray(body.providers)) {
|
||||
log.warn("SYNC", "Missing or invalid providers array", { machineId });
|
||||
return jsonResponse({ error: "Missing providers array" }, 400);
|
||||
}
|
||||
|
||||
const existingData = await getMachineData(machineId, env) || { providers: {}, modelAliases: {}, apiKeys: [] };
|
||||
|
||||
// Merge providers by ID
|
||||
const mergedProviders = {};
|
||||
const changes = { updated: [], fromWorker: [] };
|
||||
|
||||
for (const webProvider of body.providers) {
|
||||
const providerId = webProvider.id;
|
||||
if (!providerId) {
|
||||
log.warn("SYNC", "Provider missing id", { provider: webProvider.provider });
|
||||
continue;
|
||||
}
|
||||
|
||||
const workerProvider = existingData.providers[providerId];
|
||||
|
||||
if (workerProvider) {
|
||||
// Merge: token fields from Worker, config fields from Web
|
||||
mergedProviders[providerId] = mergeProvider(webProvider, workerProvider, changes, providerId);
|
||||
} else {
|
||||
// New provider from Web
|
||||
mergedProviders[providerId] = formatProviderData(webProvider);
|
||||
changes.updated.push(providerId);
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare final data - modelAliases, apiKeys, combos always from Web
|
||||
const finalData = {
|
||||
providers: mergedProviders,
|
||||
modelAliases: body.modelAliases || existingData.modelAliases || {},
|
||||
combos: body.combos || existingData.combos || [],
|
||||
apiKeys: body.apiKeys || existingData.apiKeys || [],
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Store in D1 + invalidate cache
|
||||
await saveMachineData(machineId, finalData, env);
|
||||
|
||||
log.info("SYNC", "Data synced successfully", {
|
||||
machineId,
|
||||
providerCount: Object.keys(mergedProviders).length,
|
||||
changes
|
||||
});
|
||||
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
data: finalData,
|
||||
changes
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /sync/:machineId - Clear cache when Worker is disabled
|
||||
*/
|
||||
async function handleDelete(machineId, env) {
|
||||
await deleteMachineData(machineId, env);
|
||||
|
||||
log.info("SYNC", "Data deleted", { machineId });
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
message: "Data deleted successfully"
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge provider data: compare updatedAt to decide which source to use
|
||||
* Simple logic: newer wins (sync entire provider)
|
||||
*/
|
||||
function mergeProvider(webProvider, workerProvider, changes, providerId) {
|
||||
const webTime = new Date(webProvider.updatedAt || 0).getTime();
|
||||
const workerTime = new Date(workerProvider.updatedAt || 0).getTime();
|
||||
|
||||
let merged;
|
||||
|
||||
if (workerTime > webTime) {
|
||||
// Cloud has newer data - use entire Cloud provider
|
||||
merged = formatProviderData(workerProvider);
|
||||
changes.fromWorker.push(providerId);
|
||||
} else {
|
||||
// Server has newer data - use entire Server provider
|
||||
merged = formatProviderData(webProvider);
|
||||
changes.updated.push(providerId);
|
||||
}
|
||||
|
||||
// Always update timestamp
|
||||
merged.updatedAt = new Date().toISOString();
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format provider data for storage
|
||||
*/
|
||||
function formatProviderData(provider) {
|
||||
return {
|
||||
id: provider.id,
|
||||
provider: provider.provider,
|
||||
authType: provider.authType,
|
||||
name: provider.name,
|
||||
displayName: provider.displayName,
|
||||
email: provider.email,
|
||||
priority: provider.priority,
|
||||
globalPriority: provider.globalPriority,
|
||||
defaultModel: provider.defaultModel,
|
||||
accessToken: provider.accessToken,
|
||||
refreshToken: provider.refreshToken,
|
||||
expiresAt: provider.expiresAt,
|
||||
expiresIn: provider.expiresIn,
|
||||
tokenType: provider.tokenType,
|
||||
scope: provider.scope,
|
||||
idToken: provider.idToken,
|
||||
projectId: provider.projectId,
|
||||
apiKey: provider.apiKey,
|
||||
providerSpecificData: provider.providerSpecificData || {},
|
||||
isActive: provider.isActive,
|
||||
status: provider.status || "active",
|
||||
lastError: provider.lastError || null,
|
||||
lastErrorAt: provider.lastErrorAt || null,
|
||||
errorCode: provider.errorCode || null,
|
||||
rateLimitedUntil: provider.rateLimitedUntil || null,
|
||||
createdAt: provider.createdAt,
|
||||
updatedAt: provider.updatedAt || new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update provider status (called when token refresh fails or API errors)
|
||||
*/
|
||||
export function updateProviderStatus(providers, providerId, status, error = null, errorCode = null) {
|
||||
if (providers[providerId]) {
|
||||
providers[providerId].status = status;
|
||||
providers[providerId].lastError = error;
|
||||
providers[providerId].lastErrorAt = error ? new Date().toISOString() : null;
|
||||
providers[providerId].errorCode = errorCode;
|
||||
providers[providerId].updatedAt = new Date().toISOString();
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create JSON response
|
||||
*/
|
||||
function jsonResponse(data, status = 200) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: CORS_HEADERS
|
||||
});
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { parseApiKey, extractBearerToken } from "../utils/apiKey.js";
|
||||
import { getMachineData } from "../services/storage.js";
|
||||
|
||||
/**
|
||||
* Verify API key endpoint
|
||||
* @param {Request} request
|
||||
* @param {Object} env
|
||||
* @param {string|null} machineIdOverride - machineId from URL (old format) or null (new format)
|
||||
*/
|
||||
export async function handleVerify(request, env, machineIdOverride = null) {
|
||||
const apiKey = extractBearerToken(request);
|
||||
if (!apiKey) {
|
||||
return jsonResponse({ error: "Missing or invalid Authorization header" }, 401);
|
||||
}
|
||||
|
||||
// Determine machineId: from URL (old) or from API key (new)
|
||||
let machineId = machineIdOverride;
|
||||
|
||||
if (!machineId) {
|
||||
const parsed = await parseApiKey(apiKey);
|
||||
if (!parsed) {
|
||||
return jsonResponse({ error: "Invalid API key format" }, 401);
|
||||
}
|
||||
|
||||
if (!parsed.isNewFormat || !parsed.machineId) {
|
||||
return jsonResponse({ error: "API key does not contain machineId" }, 400);
|
||||
}
|
||||
|
||||
machineId = parsed.machineId;
|
||||
}
|
||||
|
||||
const data = await getMachineData(machineId, env);
|
||||
|
||||
if (!data) {
|
||||
return jsonResponse({ error: "Machine not found" }, 404);
|
||||
}
|
||||
|
||||
const isValid = data.apiKeys?.some(k => k.key === apiKey) || false;
|
||||
|
||||
if (!isValid) {
|
||||
return jsonResponse({ error: "Invalid API key" }, 401);
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
valid: true,
|
||||
machineId,
|
||||
providersCount: Object.keys(data.providers || {}).length
|
||||
});
|
||||
}
|
||||
|
||||
function jsonResponse(data, status = 200) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
import { initTranslators } from "open-sse/translator/index.js";
|
||||
import { ollamaModels } from "open-sse/config/ollamaModels.js";
|
||||
import { transformToOllama } from "open-sse/utils/ollamaTransform.js";
|
||||
import * as log from "./utils/logger.js";
|
||||
|
||||
// Static imports for handlers (avoid dynamic import CPU cost)
|
||||
import { handleCleanup } from "./handlers/cleanup.js";
|
||||
import { handleCacheClear } from "./handlers/cache.js";
|
||||
import { handleSync } from "./handlers/sync.js";
|
||||
import { handleChat } from "./handlers/chat.js";
|
||||
import { handleVerify } from "./handlers/verify.js";
|
||||
import { handleTestClaude } from "./handlers/testClaude.js";
|
||||
import { handleForward } from "./handlers/forward.js";
|
||||
import { handleForwardRaw } from "./handlers/forwardRaw.js";
|
||||
import { handleEmbeddings } from "./handlers/embeddings.js";
|
||||
import { createLandingPageResponse } from "./services/landingPage.js";
|
||||
|
||||
// Initialize translators at module load (static imports)
|
||||
initTranslators();
|
||||
|
||||
// Helper to add CORS headers to response
|
||||
function addCorsHeaders(response) {
|
||||
const newHeaders = new Headers(response.headers);
|
||||
newHeaders.set("Access-Control-Allow-Origin", "*");
|
||||
newHeaders.set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
||||
newHeaders.set("Access-Control-Allow-Headers", "*");
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: newHeaders
|
||||
});
|
||||
}
|
||||
|
||||
const worker = {
|
||||
async scheduled(event, env, ctx) {
|
||||
const result = await handleCleanup(env);
|
||||
log.info("SCHEDULED", "Cleanup completed", result);
|
||||
},
|
||||
|
||||
async fetch(request, env, ctx) {
|
||||
const startTime = Date.now();
|
||||
const url = new URL(request.url);
|
||||
let path = url.pathname;
|
||||
|
||||
// Normalize /v1/v1/* → /v1/*
|
||||
if (path.startsWith("/v1/v1/")) {
|
||||
path = path.replace("/v1/v1/", "/v1/");
|
||||
} else if (path === "/v1/v1") {
|
||||
path = "/v1";
|
||||
}
|
||||
|
||||
log.request(request.method, path);
|
||||
|
||||
// CORS preflight
|
||||
if (request.method === "OPTIONS") {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Routes
|
||||
|
||||
// Landing page
|
||||
if (path === "/" && request.method === "GET") {
|
||||
const response = createLandingPageResponse();
|
||||
log.response(response.status, Date.now() - startTime);
|
||||
return response;
|
||||
}
|
||||
|
||||
if (path === "/health" && request.method === "GET") {
|
||||
log.response(200, Date.now() - startTime);
|
||||
return new Response(JSON.stringify({ status: "ok" }), {
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
|
||||
// Ollama compatible - list models
|
||||
if (path === "/api/tags" && request.method === "GET") {
|
||||
log.response(200, Date.now() - startTime);
|
||||
return new Response(JSON.stringify(ollamaModels), {
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
|
||||
if (path === "/cache/clear" && request.method === "POST") {
|
||||
const response = await handleCacheClear(request, env);
|
||||
log.response(response.status, Date.now() - startTime);
|
||||
return response;
|
||||
}
|
||||
|
||||
// Sync provider data by machineId (GET, POST, DELETE)
|
||||
if (path.startsWith("/sync/") && ["GET", "POST", "DELETE"].includes(request.method)) {
|
||||
const response = await handleSync(request, env, ctx);
|
||||
log.response(response.status, Date.now() - startTime);
|
||||
return response;
|
||||
}
|
||||
|
||||
// ========== NEW FORMAT: /v1/... (machineId in API key) ==========
|
||||
|
||||
// New format: /v1/chat/completions
|
||||
if (path === "/v1/chat/completions" && request.method === "POST") {
|
||||
const response = await handleChat(request, env, ctx, null);
|
||||
log.response(response.status, Date.now() - startTime);
|
||||
return addCorsHeaders(response);
|
||||
}
|
||||
|
||||
// New format: /v1/messages (Claude format)
|
||||
if (path === "/v1/messages" && request.method === "POST") {
|
||||
const response = await handleChat(request, env, ctx, null);
|
||||
log.response(response.status, Date.now() - startTime);
|
||||
return addCorsHeaders(response);
|
||||
}
|
||||
|
||||
// New format: /v1/embeddings
|
||||
if (path === "/v1/embeddings" && request.method === "POST") {
|
||||
const response = await handleEmbeddings(request, env, ctx, null);
|
||||
log.response(response.status, Date.now() - startTime);
|
||||
return addCorsHeaders(response);
|
||||
}
|
||||
|
||||
// New format: /v1/responses (OpenAI Responses API - Codex CLI)
|
||||
if (path === "/v1/responses" && request.method === "POST") {
|
||||
const response = await handleChat(request, env, ctx, null);
|
||||
log.response(response.status, Date.now() - startTime);
|
||||
return response;
|
||||
}
|
||||
|
||||
// New format: /v1/verify
|
||||
if (path === "/v1/verify" && request.method === "GET") {
|
||||
const response = await handleVerify(request, env, null);
|
||||
log.response(response.status, Date.now() - startTime);
|
||||
return addCorsHeaders(response);
|
||||
}
|
||||
|
||||
// New format: /v1/api/chat (Ollama format)
|
||||
if (path === "/v1/api/chat" && request.method === "POST") {
|
||||
const clonedReq = request.clone();
|
||||
const body = await clonedReq.json();
|
||||
const response = await handleChat(request, env, ctx, null);
|
||||
const ollamaResponse = transformToOllama(response, body.model || "llama3.2");
|
||||
log.response(200, Date.now() - startTime);
|
||||
return ollamaResponse;
|
||||
}
|
||||
|
||||
// ========== OLD FORMAT: /{machineId}/v1/... ==========
|
||||
|
||||
// Machine ID based chat endpoint
|
||||
if (path.match(/^\/[^\/]+\/v1\/chat\/completions$/) && request.method === "POST") {
|
||||
const machineId = path.split("/")[1];
|
||||
const response = await handleChat(request, env, ctx, machineId);
|
||||
log.response(response.status, Date.now() - startTime);
|
||||
return response;
|
||||
}
|
||||
|
||||
// Machine ID based embeddings endpoint
|
||||
if (path.match(/^\/[^\/]+\/v1\/embeddings$/) && request.method === "POST") {
|
||||
const machineId = path.split("/")[1];
|
||||
const response = await handleEmbeddings(request, env, ctx, machineId);
|
||||
log.response(response.status, Date.now() - startTime);
|
||||
return addCorsHeaders(response);
|
||||
}
|
||||
|
||||
// Machine ID based messages endpoint (Claude format)
|
||||
if (path.match(/^\/[^\/]+\/v1\/messages$/) && request.method === "POST") {
|
||||
const machineId = path.split("/")[1];
|
||||
const response = await handleChat(request, env, ctx, machineId);
|
||||
log.response(response.status, Date.now() - startTime);
|
||||
return response;
|
||||
}
|
||||
|
||||
// Machine ID based api/chat endpoint (Ollama format)
|
||||
if (path.match(/^\/[^\/]+\/v1\/api\/chat$/) && request.method === "POST") {
|
||||
const machineId = path.split("/")[1];
|
||||
const clonedReq = request.clone();
|
||||
const body = await clonedReq.json();
|
||||
const response = await handleChat(request, env, ctx, machineId);
|
||||
const ollamaResponse = transformToOllama(response, body.model || "llama3.2");
|
||||
log.response(200, Date.now() - startTime);
|
||||
return ollamaResponse;
|
||||
}
|
||||
|
||||
// Machine ID based verify endpoint
|
||||
if (path.match(/^\/[^\/]+\/v1\/verify$/) && request.method === "GET") {
|
||||
const machineId = path.split("/")[1];
|
||||
const response = await handleVerify(request, env, machineId);
|
||||
log.response(response.status, Date.now() - startTime);
|
||||
return response;
|
||||
}
|
||||
|
||||
// Test Claude - forward to Anthropic API
|
||||
if (path === "/testClaude" && request.method === "POST") {
|
||||
const response = await handleTestClaude(request);
|
||||
log.response(response.status, Date.now() - startTime);
|
||||
return response;
|
||||
}
|
||||
|
||||
// Forward request to any endpoint
|
||||
if (path === "/forward" && request.method === "POST") {
|
||||
const response = await handleForward(request);
|
||||
log.response(response.status, Date.now() - startTime);
|
||||
return response;
|
||||
}
|
||||
|
||||
// Forward request via raw TCP socket (bypasses CF auto headers)
|
||||
if (path === "/forward-raw" && request.method === "POST") {
|
||||
const response = await handleForwardRaw(request);
|
||||
log.response(response.status, Date.now() - startTime);
|
||||
return response;
|
||||
}
|
||||
|
||||
log.warn("ROUTER", "Not found", { path });
|
||||
return new Response(JSON.stringify({ error: "Not Found" }), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
log.error("ROUTER", error.message, { stack: error.stack });
|
||||
return new Response(JSON.stringify({ error: error.message }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default worker;
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* Landing Page Service
|
||||
* Simple health check page for self-hosted worker
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create landing page response
|
||||
* @returns {Response} HTML response
|
||||
*/
|
||||
export function createLandingPageResponse() {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html><head><title>9Router Worker</title></head>
|
||||
<body style="font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#0a0a0a;color:#fff">
|
||||
<div style="text-align:center">
|
||||
<h1>9Router Worker</h1>
|
||||
<p style="color:#888">Worker is running. Configure this URL in your 9Router dashboard.</p>
|
||||
</div>
|
||||
</body></html>`;
|
||||
|
||||
return new Response(html, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Cache-Control": "public, max-age=3600"
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import * as log from "../utils/logger.js";
|
||||
|
||||
// Request-scoped cache for getMachineData (avoids multiple D1 queries per request)
|
||||
const requestCache = new Map();
|
||||
const CACHE_TTL_MS = 5000;
|
||||
|
||||
/**
|
||||
* Get machine data from D1 (with request-scope caching)
|
||||
* @param {string} machineId
|
||||
* @param {Object} env
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
export async function getMachineData(machineId, env) {
|
||||
const cached = requestCache.get(machineId);
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
const row = await env.DB.prepare("SELECT data FROM machines WHERE machineId = ?")
|
||||
.bind(machineId)
|
||||
.first();
|
||||
|
||||
if (!row) {
|
||||
log.debug("STORAGE", `Not found: ${machineId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = JSON.parse(row.data);
|
||||
requestCache.set(machineId, { data, timestamp: Date.now() });
|
||||
log.debug("STORAGE", `Retrieved: ${machineId}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save machine data to D1
|
||||
* @param {string} machineId
|
||||
* @param {Object} data
|
||||
* @param {Object} env
|
||||
*/
|
||||
export async function saveMachineData(machineId, data, env) {
|
||||
const now = new Date().toISOString();
|
||||
data.updatedAt = now;
|
||||
|
||||
// Upsert to D1
|
||||
await env.DB.prepare(`
|
||||
INSERT INTO machines (machineId, data, updatedAt)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(machineId) DO UPDATE SET data = ?, updatedAt = ?
|
||||
`)
|
||||
.bind(machineId, JSON.stringify(data), now, JSON.stringify(data), now)
|
||||
.run();
|
||||
|
||||
// Update cache after save
|
||||
requestCache.set(machineId, { data, timestamp: Date.now() });
|
||||
log.debug("STORAGE", `Saved: ${machineId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete machine data from D1
|
||||
* @param {string} machineId
|
||||
* @param {Object} env
|
||||
*/
|
||||
export async function deleteMachineData(machineId, env) {
|
||||
await env.DB.prepare("DELETE FROM machines WHERE machineId = ?")
|
||||
.bind(machineId)
|
||||
.run();
|
||||
|
||||
// Clear cache after delete
|
||||
requestCache.delete(machineId);
|
||||
log.debug("STORAGE", `Deleted: ${machineId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update specific fields in machine data (for token refresh, rate limit, etc.)
|
||||
* @param {string} machineId
|
||||
* @param {string} connectionId
|
||||
* @param {Object} updates
|
||||
* @param {Object} env
|
||||
*/
|
||||
export async function updateMachineProvider(machineId, connectionId, updates, env) {
|
||||
const data = await getMachineData(machineId, env);
|
||||
if (!data?.providers?.[connectionId]) return;
|
||||
|
||||
Object.assign(data.providers[connectionId], updates);
|
||||
data.providers[connectionId].updatedAt = new Date().toISOString();
|
||||
|
||||
await saveMachineData(machineId, data, env);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
// Re-export from open-sse with worker logger
|
||||
import * as log from "../utils/logger.js";
|
||||
import {
|
||||
TOKEN_EXPIRY_BUFFER_MS as BUFFER_MS,
|
||||
refreshTokenByProvider as _refreshTokenByProvider
|
||||
} from "open-sse/services/tokenRefresh.js";
|
||||
|
||||
export const TOKEN_EXPIRY_BUFFER_MS = BUFFER_MS;
|
||||
|
||||
export const refreshTokenByProvider = (provider, credentials) =>
|
||||
_refreshTokenByProvider(provider, credentials, log);
|
||||
@@ -1,8 +0,0 @@
|
||||
// Stub for cloud worker - no-op async functions
|
||||
export async function saveRequestUsage() {}
|
||||
export function trackPendingRequest() {}
|
||||
export async function appendRequestLog() {}
|
||||
export async function getUsageDb() { return { data: { history: [] } }; }
|
||||
export async function getUsageHistory() { return []; }
|
||||
export async function getUsageStats() { return {}; }
|
||||
export async function getRecentLogs() { return []; }
|
||||
@@ -1,72 +0,0 @@
|
||||
/**
|
||||
* API Key utilities for Worker
|
||||
* Supports both formats:
|
||||
* - New: sk-{machineId}-{keyId}-{crc8}
|
||||
* - Old: sk-{random8}
|
||||
*/
|
||||
|
||||
const API_KEY_SECRET = "endpoint-proxy-api-key-secret";
|
||||
|
||||
/**
|
||||
* Generate CRC (8-char HMAC) using Web Crypto API
|
||||
*/
|
||||
async function generateCrc(machineId, keyId) {
|
||||
const encoder = new TextEncoder();
|
||||
const keyData = encoder.encode(API_KEY_SECRET);
|
||||
const data = encoder.encode(machineId + keyId);
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
keyData,
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"]
|
||||
);
|
||||
|
||||
const signature = await crypto.subtle.sign("HMAC", key, data);
|
||||
const hashArray = Array.from(new Uint8Array(signature));
|
||||
const hashHex = hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
|
||||
|
||||
return hashHex.slice(0, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse API key and extract machineId + keyId
|
||||
* @param {string} apiKey
|
||||
* @returns {Promise<{ machineId: string, keyId: string, isNewFormat: boolean } | null>}
|
||||
*/
|
||||
export async function parseApiKey(apiKey) {
|
||||
if (!apiKey || !apiKey.startsWith("sk-")) return null;
|
||||
|
||||
const parts = apiKey.split("-");
|
||||
|
||||
// New format: sk-{machineId}-{keyId}-{crc8} = 4 parts
|
||||
if (parts.length === 4) {
|
||||
const [, machineId, keyId, crc] = parts;
|
||||
|
||||
// Verify CRC
|
||||
const expectedCrc = await generateCrc(machineId, keyId);
|
||||
if (crc !== expectedCrc) return null;
|
||||
|
||||
return { machineId, keyId, isNewFormat: true };
|
||||
}
|
||||
|
||||
// Old format: sk-{random8} = 2 parts
|
||||
if (parts.length === 2) {
|
||||
return { machineId: null, keyId: parts[1], isNewFormat: false };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract Bearer token from Authorization header
|
||||
* @param {Request} request
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export function extractBearerToken(request) {
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) return null;
|
||||
return authHeader.slice(7);
|
||||
}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
// Logger utility for worker
|
||||
|
||||
const LOG_LEVELS = {
|
||||
DEBUG: 0,
|
||||
INFO: 1,
|
||||
WARN: 2,
|
||||
ERROR: 3
|
||||
};
|
||||
|
||||
const LEVEL = LOG_LEVELS.INFO;
|
||||
|
||||
// ANSI color codes
|
||||
const COLORS = {
|
||||
reset: "\x1b[0m",
|
||||
red: "\x1b[31m",
|
||||
green: "\x1b[32m",
|
||||
yellow: "\x1b[33m",
|
||||
blue: "\x1b[34m",
|
||||
cyan: "\x1b[36m"
|
||||
};
|
||||
|
||||
function formatTime() {
|
||||
return new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
function formatInline(data) {
|
||||
if (!data) return "";
|
||||
if (typeof data === "string") return data;
|
||||
try {
|
||||
return Object.entries(data).map(([k, v]) => `${k}=${v}`).join(" | ");
|
||||
} catch {
|
||||
return String(data);
|
||||
}
|
||||
}
|
||||
|
||||
export function debug(tag, message, data) {
|
||||
if (LEVEL <= LOG_LEVELS.DEBUG) {
|
||||
const extra = data ? ` | ${formatInline(data)}` : "";
|
||||
console.log(`[${formatTime()}] 🔍 [${tag}] ${message}${extra}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function info(tag, message, data) {
|
||||
if (LEVEL <= LOG_LEVELS.INFO) {
|
||||
const extra = data ? ` | ${formatInline(data)}` : "";
|
||||
console.log(`[${formatTime()}] ℹ️ [${tag}] ${message}${extra}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function warn(tag, message, data) {
|
||||
if (LEVEL <= LOG_LEVELS.WARN) {
|
||||
const extra = data ? ` | ${formatInline(data)}` : "";
|
||||
console.warn(`${COLORS.yellow}[${formatTime()}] ⚠️ [${tag}] ${message}${extra}${COLORS.reset}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function error(tag, message, data) {
|
||||
if (LEVEL <= LOG_LEVELS.ERROR) {
|
||||
const extra = data ? ` | ${formatInline(data)}` : "";
|
||||
console.error(`${COLORS.red}[${formatTime()}] ❌ [${tag}] ${message}${extra}${COLORS.reset}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function request(method, path, extra) {
|
||||
const data = extra ? ` | ${formatInline(extra)}` : "";
|
||||
console.log(`[${formatTime()}] 📥 ${method} ${path}${data}`);
|
||||
}
|
||||
|
||||
export function response(status, duration, extra) {
|
||||
const icon = status < 400 ? "📤" : "💥";
|
||||
const data = extra ? ` | ${formatInline(extra)}` : "";
|
||||
console.log(`[${formatTime()}] ${icon} ${status} (${duration}ms)${data}`);
|
||||
}
|
||||
|
||||
export function stream(event, data) {
|
||||
const extra = data ? ` | ${formatInline(data)}` : "";
|
||||
console.log(`[${formatTime()}] 🌊 [STREAM] ${event}${extra}`);
|
||||
}
|
||||
|
||||
// Mask sensitive data
|
||||
export function maskKey(key) {
|
||||
if (!key || key.length < 8) return "***";
|
||||
return `${key.slice(0, 4)}...${key.slice(-4)}`;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
name = "9router"
|
||||
main = "src/index.js"
|
||||
compatibility_date = "2024-09-23"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
|
||||
[alias]
|
||||
"@/lib/usageDb.js" = "./src/stubs/usageDb.js"
|
||||
|
||||
# Step 3: Paste your KV & D1 IDs here
|
||||
[[kv_namespaces]]
|
||||
binding = "KV"
|
||||
id = "YOUR_KV_NAMESPACE_ID"
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "proxy-db"
|
||||
database_id = "YOUR_D1_DATABASE_ID"
|
||||
@@ -1,557 +0,0 @@
|
||||
# 9Router Architecture
|
||||
|
||||
_Last updated: 2026-02-06_
|
||||
|
||||
## Executive Summary
|
||||
|
||||
9Router 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
|
||||
- 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
|
||||
- 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[9Router 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]
|
||||
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`
|
||||
- `src/app/api/v1/messages/count_tokens/route.js`
|
||||
- `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*`
|
||||
- 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`
|
||||
|
||||
## 3) Persistence Layer
|
||||
|
||||
Primary state DB:
|
||||
|
||||
- `src/lib/localDb.js`
|
||||
- file: `${DATA_DIR}/db.json` (or `~/.9router/db.json` when `DATA_DIR` is unset)
|
||||
- entities: providerConnections, providerNodes, modelAliases, combos, apiKeys, settings, pricing
|
||||
|
||||
Usage DB:
|
||||
|
||||
- `src/lib/usageDb.js`
|
||||
- files: `~/.9router/usage.json`, `~/.9router/log.txt`
|
||||
- note: currently independent from `DATA_DIR`
|
||||
|
||||
## 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
|
||||
- Optional proxy support for upstream calls via env proxy variables (`open-sse/utils/proxyFetch.js`)
|
||||
|
||||
## 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
|
||||
boolean isActive
|
||||
}
|
||||
|
||||
USAGE_ENTRY {
|
||||
string provider
|
||||
string model
|
||||
number prompt_tokens
|
||||
number completion_tokens
|
||||
string connectionId
|
||||
string timestamp
|
||||
}
|
||||
```
|
||||
|
||||
Physical storage files:
|
||||
|
||||
- main state: `${DATA_DIR}/db.json` (or `~/.9router/db.json`)
|
||||
- usage stats: `~/.9router/usage.json`
|
||||
- request log lines: `~/.9router/log.txt`
|
||||
- optional translator/request debug sessions: `<repo>/logs/...`
|
||||
|
||||
## Deployment Topology
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph LocalHost[Developer Host]
|
||||
CLI[CLI Tools]
|
||||
Browser[Dashboard Browser]
|
||||
end
|
||||
|
||||
subgraph ContainerOrProcess[9Router 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/providers*`: provider CRUD, validation, testing
|
||||
- `src/app/api/provider-nodes*`: custom compatible node management
|
||||
- `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/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
|
||||
|
||||
Specialized executors:
|
||||
|
||||
- `antigravity`
|
||||
- `gemini-cli`
|
||||
- `github`
|
||||
- `kiro`
|
||||
- `codex`
|
||||
- `cursor`
|
||||
|
||||
Default executor path:
|
||||
|
||||
- all other providers (including compatible node providers) use `open-sse/executors/default.js`
|
||||
|
||||
## 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 are selected dynamically based on source payload shape and provider target format.
|
||||
|
||||
## 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`
|
||||
- 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
|
||||
- Platform/runtime helpers (not app-specific config): `APPDATA`, `NODE_ENV`, `PORT`, `HOSTNAME`
|
||||
|
||||
## Known Architectural Notes
|
||||
|
||||
1. `usageDb` currently stores under `~/.9router` and does not follow `DATA_DIR`.
|
||||
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.
|
||||
|
||||
## Operational Verification Checklist
|
||||
|
||||
- Build from source: `cd /root/dev/9router && npm run build`
|
||||
- Build Docker image: `cd /root/dev/9router && docker build -t 9router .`
|
||||
- Start service and verify:
|
||||
- `GET /api/settings`
|
||||
- `GET /api/v1/models`
|
||||
- CLI target base URL should be `http://<host>:20128/v1` when `PORT=20128`
|
||||
@@ -1,16 +0,0 @@
|
||||
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;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 740 KiB |
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"open-sse": ["./open-sse"],
|
||||
"open-sse/*": ["./open-sse/*"]
|
||||
},
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
images: {
|
||||
unoptimized: true
|
||||
},
|
||||
env: {},
|
||||
// Allow builds with both Turbopack (Next 16 default) and webpack
|
||||
turbopack: {},
|
||||
webpack: (config, { isServer }) => {
|
||||
// Ignore fs/path modules in browser bundle
|
||||
if (!isServer) {
|
||||
config.resolve.fallback = {
|
||||
...config.resolve.fallback,
|
||||
fs: false,
|
||||
path: false,
|
||||
};
|
||||
}
|
||||
// Stop watching logs directory to prevent HMR during streaming.
|
||||
// Also ignore Windows XP-era junction points in user profile that loop
|
||||
// back on themselves and cause `EPERM: operation not permitted, scandir`
|
||||
// on GitHub Actions runners (cascades into FlightClientEntryPlugin
|
||||
// crash). Local Windows users with normal accounts don't hit this.
|
||||
// Single regex (webpack rejects mixed regex + glob strings in array).
|
||||
config.watchOptions = {
|
||||
...config.watchOptions,
|
||||
ignored: /[\\/](logs|\.next|Application Data|Local Settings)[\\/]/,
|
||||
};
|
||||
// Disable webpack's user-profile + node_modules snapshotting that
|
||||
// triggers the same EPERM scan during `next build` on CI Windows.
|
||||
config.snapshot = {
|
||||
...(config.snapshot || {}),
|
||||
managedPaths: [],
|
||||
immutablePaths: [],
|
||||
};
|
||||
return config;
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
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;
|
||||
@@ -1,8 +0,0 @@
|
||||
node_modules/
|
||||
*.log
|
||||
.DS_Store
|
||||
test/
|
||||
*.test.js
|
||||
.env
|
||||
.env.*
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
import { platform, arch } from "os";
|
||||
|
||||
// === Gemini CLI ===
|
||||
export const GEMINI_CLI_VERSION = "0.31.0";
|
||||
export const GEMINI_CLI_API_CLIENT = "google-genai-sdk/1.41.0 gl-node/v22.19.0";
|
||||
|
||||
export function geminiCLIUserAgent(model = "unknown") {
|
||||
const os = platform() === "win32" ? "windows" : platform();
|
||||
return `GeminiCLI/${GEMINI_CLI_VERSION}/${model || "unknown"} (${os}; ${arch()})`;
|
||||
}
|
||||
|
||||
// === GitHub Copilot ===
|
||||
export const GITHUB_COPILOT = {
|
||||
VSCODE_VERSION: "1.110.0",
|
||||
COPILOT_CHAT_VERSION: "0.38.0",
|
||||
USER_AGENT: "GitHubCopilotChat/0.38.0",
|
||||
API_VERSION: "2025-04-01",
|
||||
};
|
||||
|
||||
// === Antigravity enums ===
|
||||
export const IDE_TYPE = {
|
||||
UNSPECIFIED: 0,
|
||||
JETSKI: 10,
|
||||
ANTIGRAVITY: 9,
|
||||
PLUGINS: 7
|
||||
};
|
||||
|
||||
export const PLATFORM = {
|
||||
UNSPECIFIED: 0,
|
||||
DARWIN_AMD64: 1,
|
||||
DARWIN_ARM64: 2,
|
||||
LINUX_AMD64: 3,
|
||||
LINUX_ARM64: 4,
|
||||
WINDOWS_AMD64: 5
|
||||
};
|
||||
|
||||
export const PLUGIN_TYPE = {
|
||||
UNSPECIFIED: 0,
|
||||
CLOUD_CODE: 1,
|
||||
GEMINI: 2
|
||||
};
|
||||
|
||||
export function getPlatformEnum() {
|
||||
const os = platform();
|
||||
const architecture = arch();
|
||||
if (os === "darwin") return architecture === "arm64" ? PLATFORM.DARWIN_ARM64 : PLATFORM.DARWIN_AMD64;
|
||||
if (os === "linux") return architecture === "arm64" ? PLATFORM.LINUX_ARM64 : PLATFORM.LINUX_AMD64;
|
||||
if (os === "win32") return PLATFORM.WINDOWS_AMD64;
|
||||
return PLATFORM.UNSPECIFIED;
|
||||
}
|
||||
|
||||
export function getPlatformUserAgent() {
|
||||
return `antigravity/1.104.0 ${platform()}/${arch()}`;
|
||||
}
|
||||
|
||||
export const CLIENT_METADATA = {
|
||||
ideType: IDE_TYPE.ANTIGRAVITY,
|
||||
platform: getPlatformEnum(),
|
||||
pluginType: PLUGIN_TYPE.GEMINI
|
||||
};
|
||||
|
||||
// Internal anti-loop header
|
||||
export const INTERNAL_REQUEST_HEADER = { name: "x-request-source", value: "local" };
|
||||
|
||||
// Antigravity chat/stream headers
|
||||
export const ANTIGRAVITY_HEADERS = {
|
||||
"User-Agent": `antigravity/1.107.0 ${platform()}/${arch()}`
|
||||
};
|
||||
|
||||
// Cloud Code Assist API
|
||||
export const CLOUD_CODE_API = {
|
||||
loadCodeAssist: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
|
||||
onboardUser: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser",
|
||||
};
|
||||
|
||||
export const LOAD_CODE_ASSIST_HEADERS = {
|
||||
"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_TYPE.ANTIGRAVITY, platform: getPlatformEnum(), pluginType: PLUGIN_TYPE.GEMINI }),
|
||||
};
|
||||
|
||||
export const LOAD_CODE_ASSIST_METADATA = {
|
||||
ideType: IDE_TYPE.ANTIGRAVITY,
|
||||
platform: getPlatformEnum(),
|
||||
pluginType: PLUGIN_TYPE.GEMINI,
|
||||
};
|
||||
|
||||
// System prompts
|
||||
export const CLAUDE_SYSTEM_PROMPT = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
|
||||
export const ANTIGRAVITY_DEFAULT_SYSTEM = "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**";
|
||||
|
||||
// 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://api.anthropic.com/v1/oauth/token",
|
||||
auth: "https://api.anthropic.com/v1/oauth/authorize"
|
||||
},
|
||||
qwen: {
|
||||
token: "https://chat.qwen.ai/api/v1/oauth2/token",
|
||||
auth: "https://chat.qwen.ai/api/v1/oauth2/device/code"
|
||||
},
|
||||
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"
|
||||
}
|
||||
};
|
||||
|
||||
// Generate Kimi OAuth custom headers
|
||||
export function buildKimiHeaders() {
|
||||
return {
|
||||
"X-Msh-Platform": "9router",
|
||||
"X-Msh-Version": "2.1.2",
|
||||
"X-Msh-Device-Model": typeof process !== "undefined" ? `${process.platform} ${process.arch}` : "unknown",
|
||||
"X-Msh-Device-Id": `kimi-${Date.now()}`
|
||||
};
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
// 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`;
|
||||
@@ -1,4 +0,0 @@
|
||||
// Barrel re-export — consumers can migrate to specific files over time
|
||||
export * from "./providers.js";
|
||||
export * from "./appConstants.js";
|
||||
export * from "./runtimeConfig.js";
|
||||
@@ -1,7 +0,0 @@
|
||||
// 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 = "...";
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
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" }
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -1,390 +0,0 @@
|
||||
import { PROVIDERS } from "./providers.js";
|
||||
|
||||
// Provider models - Single source of truth
|
||||
// Key = alias (cc, cx, gc, qw, if, ag, gh for OAuth; id for API Key)
|
||||
// Field "provider" for special cases (e.g. AntiGravity models that call different backends)
|
||||
|
||||
export const PROVIDER_MODELS = {
|
||||
// OAuth Providers (using alias)
|
||||
cc: [ // Claude Code
|
||||
{ id: "claude-opus-4-6", name: "Claude Opus 4.6" },
|
||||
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
|
||||
{ id: "claude-opus-4-5-20251101", name: "Claude 4.5 Opus" },
|
||||
{ id: "claude-sonnet-4-5-20250929", name: "Claude 4.5 Sonnet" },
|
||||
{ id: "claude-haiku-4-5-20251001", name: "Claude 4.5 Haiku" },
|
||||
],
|
||||
cx: [ // OpenAI Codex
|
||||
{ id: "gpt-5.4", name: "GPT 5.4" },
|
||||
// GPT 5.3 Codex - all thinking levels
|
||||
{ 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.3-codex-spark", name: "GPT 5.3 Codex Spark" },
|
||||
// Mini - medium and high only
|
||||
{ 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)" },
|
||||
// Other models
|
||||
{ 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" },
|
||||
],
|
||||
gc: [ // Gemini CLI
|
||||
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" },
|
||||
{ id: "gemini-3-pro-preview", name: "Gemini 3 Pro Preview" },
|
||||
],
|
||||
qw: [ // Qwen Code
|
||||
// { id: "qwen3-coder-next", name: "Qwen3 Coder Next" },
|
||||
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
|
||||
{ id: "qwen3-coder-flash", name: "Qwen3 Coder Flash" },
|
||||
{ id: "vision-model", name: "Qwen3 Vision Model" },
|
||||
{ id: "coder-model", name: "Qwen3.5 Coder Model" },
|
||||
],
|
||||
if: [ // iFlow AI
|
||||
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
|
||||
{ id: "qwen3-max", name: "Qwen3 Max" },
|
||||
{ id: "qwen3-vl-plus", name: "Qwen3 VL Plus" },
|
||||
{ id: "qwen3-max-preview", name: "Qwen3 Max Preview" },
|
||||
{ id: "qwen3-235b", name: "Qwen3 235B A22B" },
|
||||
{ id: "qwen3-235b-a22b-instruct", name: "Qwen3 235B A22B Instruct" },
|
||||
{ id: "qwen3-235b-a22b-thinking-2507", name: "Qwen3 235B A22B Thinking" },
|
||||
{ id: "qwen3-32b", name: "Qwen3 32B" },
|
||||
{ id: "kimi-k2", name: "Kimi K2" },
|
||||
{ id: "deepseek-v3.2", name: "DeepSeek V3.2 Exp" },
|
||||
{ id: "deepseek-v3.1", name: "DeepSeek V3.1 Terminus" },
|
||||
{ id: "deepseek-v3", name: "DeepSeek V3 671B" },
|
||||
{ id: "deepseek-r1", name: "DeepSeek R1" },
|
||||
{ id: "glm-4.7", name: "GLM 4.7" },
|
||||
{ id: "iflow-rome-30ba3b", name: "iFlow ROME" },
|
||||
],
|
||||
ag: [ // Antigravity - special case: models call different backends
|
||||
{ id: "gemini-3.1-pro-high", name: "Gemini 3 Pro High" },
|
||||
{ id: "gemini-3.1-pro-low", name: "Gemini 3 Pro Low" },
|
||||
{ id: "gemini-3-flash", name: "Gemini 3 Flash" },
|
||||
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
|
||||
{ id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 Thinking" },
|
||||
{ id: "gpt-oss-120b-medium", name: "GPT OSS 120B Medium" },
|
||||
],
|
||||
gh: [ // GitHub Copilot - OpenAI models
|
||||
{ id: "gpt-3.5-turbo", name: "GPT-3.5 Turbo" },
|
||||
{ id: "gpt-4", name: "GPT-4" },
|
||||
{ id: "gpt-4o", name: "GPT-4o" },
|
||||
{ id: "gpt-4o-mini", name: "GPT-4o mini" },
|
||||
{ id: "gpt-4.1", name: "GPT-4.1" },
|
||||
{ id: "gpt-5", name: "GPT-5" },
|
||||
{ id: "gpt-5-mini", name: "GPT-5 Mini" },
|
||||
{ id: "gpt-5-codex", name: "GPT-5 Codex" },
|
||||
{ id: "gpt-5.1", name: "GPT-5.1" },
|
||||
{ id: "gpt-5.1-codex", name: "GPT-5.1 Codex" },
|
||||
{ id: "gpt-5.1-codex-mini", name: "GPT-5.1 Codex Mini" },
|
||||
{ id: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max" },
|
||||
{ id: "gpt-5.2", name: "GPT-5.2" },
|
||||
{ id: "gpt-5.2-codex", name: "GPT-5.2 Codex" },
|
||||
{ id: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
|
||||
{ id: "gpt-5.4", name: "GPT-5.4" },
|
||||
// GitHub Copilot - Anthropic models
|
||||
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5" },
|
||||
{ id: "claude-opus-4.1", name: "Claude Opus 4.1" },
|
||||
{ id: "claude-opus-4.5", name: "Claude Opus 4.5" },
|
||||
{ id: "claude-sonnet-4", name: "Claude Sonnet 4" },
|
||||
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" },
|
||||
{ id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6" },
|
||||
{ id: "claude-opus-4.6", name: "Claude Opus 4.6" },
|
||||
// GitHub Copilot - Google models
|
||||
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
|
||||
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash" },
|
||||
{ id: "gemini-3-pro-preview", name: "Gemini 3 Pro" },
|
||||
// GitHub Copilot - Other models
|
||||
{ id: "grok-code-fast-1", name: "Grok Code Fast 1" },
|
||||
{ id: "oswe-vscode-prime", name: "Raptor Mini" },
|
||||
],
|
||||
kr: [ // Kiro AI
|
||||
// { id: "claude-opus-4.5", name: "Claude Opus 4.5" },
|
||||
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" },
|
||||
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5" },
|
||||
{ id: "deepseek-3.2", name: "DeepSeek 3.2" },
|
||||
{ id: "deepseek-3.1", name: "DeepSeek 3.1" },
|
||||
{ id: "qwen3-coder-next", name: "Qwen3 Coder Next" },
|
||||
],
|
||||
cu: [ // Cursor IDE
|
||||
{ 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" },
|
||||
{ id: "claude-4.6-opus-max", name: "Claude 4.6 Opus Max" },
|
||||
{ id: "claude-4.6-sonnet-medium-thinking", name: "Claude 4.6 Sonnet Medium Thinking" },
|
||||
{ id: "kimi-k2.5", name: "Kimi K2.5" },
|
||||
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" },
|
||||
{ id: "gpt-5.2", name: "GPT 5.2" },
|
||||
{ id: "gpt-5.3-codex", name: "GPT 5.3 Codex" },
|
||||
],
|
||||
kmc: [ // Kimi Coding
|
||||
{ id: "kimi-k2.5", name: "Kimi K2.5" },
|
||||
{ id: "kimi-k2.5-thinking", name: "Kimi K2.5 Thinking" },
|
||||
{ id: "kimi-latest", name: "Kimi Latest" },
|
||||
],
|
||||
kc: [ // KiloCode
|
||||
{ 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" },
|
||||
],
|
||||
cl: [ // Cline
|
||||
{ id: "anthropic/claude-sonnet-4.6", name: "Claude Sonnet 4.6" },
|
||||
{ id: "anthropic/claude-opus-4.6", name: "Claude Opus 4.6" },
|
||||
{ id: "openai/gpt-5.3-codex", name: "GPT-5.3 Codex" },
|
||||
{ id: "openai/gpt-5.4", name: "GPT-5.4" },
|
||||
{ id: "google/gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" },
|
||||
{ id: "google/gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview" },
|
||||
{ id: "kwaipilot/kat-coder-pro", name: "KAT Coder Pro" },
|
||||
],
|
||||
|
||||
// API Key Providers (alias = id)
|
||||
openai: [
|
||||
{ id: "gpt-4o", name: "GPT-4o" },
|
||||
{ id: "gpt-5-mini", name: "GPT-5 Mini" },
|
||||
{ id: "gpt-4-turbo", name: "GPT-4 Turbo" },
|
||||
{ id: "o1", name: "O1" },
|
||||
{ id: "o1-mini", name: "O1 Mini" },
|
||||
],
|
||||
anthropic: [
|
||||
{ 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" },
|
||||
],
|
||||
gemini: [
|
||||
{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" },
|
||||
{ id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview" },
|
||||
{ id: "gemini-3-pro-preview", name: "Gemini 3 Pro Preview" },
|
||||
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash 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" },
|
||||
// Embedding models
|
||||
{ id: "gemini-embedding-001", name: "Gemini Embedding 001", type: "embedding" },
|
||||
{ id: "text-embedding-005", name: "Text Embedding 005", type: "embedding" },
|
||||
{ id: "text-embedding-004", name: "Text Embedding 004 (Legacy)", type: "embedding" },
|
||||
],
|
||||
openrouter: [
|
||||
{ id: "auto", name: "Auto (Best Available)" },
|
||||
],
|
||||
glm: [
|
||||
{ id: "glm-5", name: "GLM 5" },
|
||||
{ id: "glm-4.7", name: "GLM 4.7" },
|
||||
{ id: "glm-4.6v", name: "GLM 4.6V (Vision)" },
|
||||
],
|
||||
"glm-cn": [
|
||||
{ id: "glm-5", name: "GLM 5" },
|
||||
{ id: "glm-4.7", name: "GLM-4.7" },
|
||||
{ id: "glm-4.6", name: "GLM-4.6" },
|
||||
{ id: "glm-4.5-air", name: "GLM-4.5-Air" },
|
||||
],
|
||||
kimi: [
|
||||
{ id: "kimi-k2.5", name: "Kimi K2.5" },
|
||||
{ id: "kimi-k2.5-thinking", name: "Kimi K2.5 Thinking" },
|
||||
{ id: "kimi-latest", name: "Kimi Latest" },
|
||||
],
|
||||
minimax: [
|
||||
{ id: "MiniMax-M2.7", name: "MiniMax M2.7" },
|
||||
{ id: "MiniMax-M2.5", name: "MiniMax M2.5" },
|
||||
{ id: "MiniMax-M2.1", name: "MiniMax M2.1" },
|
||||
],
|
||||
"minimax-cn": [
|
||||
{ id: "MiniMax-M2.7", name: "MiniMax M2.7" },
|
||||
{ id: "MiniMax-M2.5", name: "MiniMax M2.5" },
|
||||
{ id: "MiniMax-M2.1", name: "MiniMax M2.1" },
|
||||
],
|
||||
alicode: [
|
||||
{ id: "qwen3.5-plus", name: "Qwen3.5 Plus" },
|
||||
{ id: "kimi-k2.5", name: "Kimi K2.5" },
|
||||
{ id: "glm-5", name: "GLM 5" },
|
||||
{ id: "MiniMax-M2.5", name: "MiniMax M2.5" },
|
||||
{ id: "qwen3-max-2026-01-23", name: "Qwen3 Max" },
|
||||
{ id: "qwen3-coder-next", name: "Qwen3 Coder Next" },
|
||||
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
|
||||
{ id: "glm-4.7", name: "GLM 4.7" },
|
||||
],
|
||||
"alicode-intl": [
|
||||
{ id: "qwen3.5-plus", name: "Qwen3.5 Plus" },
|
||||
{ id: "kimi-k2.5", name: "Kimi K2.5" },
|
||||
{ id: "glm-5", name: "GLM 5" },
|
||||
{ id: "MiniMax-M2.5", name: "MiniMax M2.5" },
|
||||
{ id: "qwen3-coder-next", name: "Qwen3 Coder Next" },
|
||||
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
|
||||
{ id: "glm-4.7", name: "GLM 4.7" },
|
||||
],
|
||||
deepseek: [
|
||||
{ id: "deepseek-chat", name: "DeepSeek V3.2 Chat" },
|
||||
{ id: "deepseek-reasoner", name: "DeepSeek V3.2 Reasoner" },
|
||||
],
|
||||
groq: [
|
||||
{ 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: "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-large-latest", name: "Mistral Large 3" },
|
||||
{ id: "codestral-latest", name: "Codestral" },
|
||||
{ id: "mistral-medium-latest", name: "Mistral Medium 3" },
|
||||
],
|
||||
perplexity: [
|
||||
{ id: "sonar-pro", name: "Sonar Pro" },
|
||||
{ id: "sonar", name: "Sonar" },
|
||||
],
|
||||
together: [
|
||||
{ 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: "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: "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: "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: "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: "meta-llama/Llama-3.3-70B-Instruct", name: "Llama 3.3 70B Instruct" },
|
||||
],
|
||||
siliconflow: [
|
||||
{ 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" },
|
||||
],
|
||||
hyperbolic: [
|
||||
{ id: "Qwen/QwQ-32B", name: "QwQ 32B" },
|
||||
{ id: "deepseek-ai/DeepSeek-R1", name: "DeepSeek R1" },
|
||||
{ id: "deepseek-ai/DeepSeek-V3", name: "DeepSeek V3" },
|
||||
{ id: "meta-llama/Llama-3.3-70B-Instruct", name: "Llama 3.3 70B" },
|
||||
{ id: "meta-llama/Llama-3.2-3B-Instruct", name: "Llama 3.2 3B" },
|
||||
{ id: "Qwen/Qwen2.5-72B-Instruct", name: "Qwen 2.5 72B" },
|
||||
{ id: "Qwen/Qwen2.5-Coder-32B-Instruct", name: "Qwen 2.5 Coder 32B" },
|
||||
{ id: "NousResearch/Hermes-3-Llama-3.1-70B", name: "Hermes 3 70B" },
|
||||
],
|
||||
ollama: [
|
||||
{ id: "gpt-oss:120b", name: "GPT OSS 120B" },
|
||||
{ id: "kimi-k2.5", name: "Kimi K2.5" },
|
||||
{ id: "glm-5", name: "GLM 5" },
|
||||
{ id: "minimax-m2.5", name: "MiniMax M2.5" },
|
||||
{ id: "glm-4.7-flash", name: "GLM 4.7 Flash" },
|
||||
{ id: "qwen3.5", name: "Qwen3.5" },
|
||||
],
|
||||
vertex: [
|
||||
{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" },
|
||||
{ id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview" },
|
||||
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" },
|
||||
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
|
||||
],
|
||||
"vertex-partner": [
|
||||
{ id: "deepseek-ai/deepseek-v3.2-maas", name: "DeepSeek V3.2 (Vertex)" },
|
||||
{ id: "qwen/qwen3-next-80b-a3b-thinking-maas", name: "Qwen3 Next 80B Thinking (Vertex)" },
|
||||
{ id: "qwen/qwen3-next-80b-a3b-instruct-maas", name: "Qwen3 Next 80B Instruct (Vertex)" },
|
||||
{ id: "zai-org/glm-5-maas", name: "GLM-5 (Vertex)" },
|
||||
],
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// OAuth providers that use short aliases (everything else: alias = id)
|
||||
const OAUTH_ALIASES = {
|
||||
claude: "cc",
|
||||
codex: "cx",
|
||||
"gemini-cli": "gc",
|
||||
qwen: "qw",
|
||||
iflow: "if",
|
||||
antigravity: "ag",
|
||||
github: "gh",
|
||||
kiro: "kr",
|
||||
cursor: "cu",
|
||||
"kimi-coding": "kmc",
|
||||
kilocode: "kc",
|
||||
cline: "cl",
|
||||
vertex: "vertex",
|
||||
"vertex-partner": "vertex-partner",
|
||||
};
|
||||
|
||||
// Derived from PROVIDERS — no need to maintain manually
|
||||
export const PROVIDER_ID_TO_ALIAS = Object.fromEntries(
|
||||
Object.keys(PROVIDERS).map(id => [id, OAUTH_ALIASES[id] || id])
|
||||
);
|
||||
|
||||
export function getModelsByProviderId(providerId) {
|
||||
const alias = PROVIDER_ID_TO_ALIAS[providerId] || providerId;
|
||||
return PROVIDER_MODELS[alias] || [];
|
||||
}
|
||||
@@ -1,313 +0,0 @@
|
||||
import { platform, arch } from "os";
|
||||
|
||||
// === OS/Arch helpers ===
|
||||
function mapStainlessOs() {
|
||||
switch (platform()) {
|
||||
case "darwin": return "MacOS";
|
||||
case "win32": return "Windows";
|
||||
case "linux": return "Linux";
|
||||
case "freebsd": return "FreeBSD";
|
||||
default: return `Other::${platform()}`;
|
||||
}
|
||||
}
|
||||
|
||||
function mapStainlessArch() {
|
||||
switch (arch()) {
|
||||
case "x64": return "x64";
|
||||
case "arm64": return "arm64";
|
||||
case "ia32": return "x86";
|
||||
default: return `other::${arch()}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Shared Claude-compatible API headers (reused across claude-format providers)
|
||||
const CLAUDE_API_HEADERS = {
|
||||
"Anthropic-Version": "2023-06-01",
|
||||
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14"
|
||||
};
|
||||
|
||||
// Shared baseUrls
|
||||
const KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/v1/messages";
|
||||
|
||||
export const PROVIDERS = {
|
||||
claude: {
|
||||
baseUrl: "https://api.anthropic.com/v1/messages",
|
||||
format: "claude",
|
||||
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,prompt-caching-scope-2026-01-05",
|
||||
"Anthropic-Dangerous-Direct-Browser-Access": "true",
|
||||
"User-Agent": "claude-cli/2.1.63 (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.74.0",
|
||||
"X-Stainless-Runtime": "node",
|
||||
"X-Stainless-Lang": "js",
|
||||
"X-Stainless-Arch": mapStainlessArch(),
|
||||
"X-Stainless-Os": mapStainlessOs(),
|
||||
"X-Stainless-Timeout": "600"
|
||||
},
|
||||
clientId: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
|
||||
tokenUrl: "https://api.anthropic.com/v1/oauth/token"
|
||||
},
|
||||
gemini: {
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/models",
|
||||
format: "gemini",
|
||||
clientId: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com",
|
||||
clientSecret: "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"
|
||||
},
|
||||
"gemini-cli": {
|
||||
baseUrl: "https://cloudcode-pa.googleapis.com/v1internal",
|
||||
format: "gemini-cli",
|
||||
clientId: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com",
|
||||
clientSecret: "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"
|
||||
},
|
||||
codex: {
|
||||
baseUrl: "https://chatgpt.com/backend-api/codex/responses",
|
||||
format: "openai-responses",
|
||||
headers: {
|
||||
"originator": "codex-cli",
|
||||
"User-Agent": "codex-cli/1.0.18 (macOS; arm64)"
|
||||
},
|
||||
clientId: "app_EMoamEEZ73f0CkXaXp7hrann",
|
||||
clientSecret: "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",
|
||||
tokenUrl: "https://auth.openai.com/oauth/token"
|
||||
},
|
||||
qwen: {
|
||||
baseUrl: "https://portal.qwen.ai/v1/chat/completions",
|
||||
format: "openai",
|
||||
headers: {
|
||||
"User-Agent": "google-api-nodejs-client/9.15.1",
|
||||
"X-Goog-Api-Client": "gl-node/22.17.0"
|
||||
},
|
||||
clientId: "f0304373b74a44d2b584a3fb70ca9e56",
|
||||
tokenUrl: "https://chat.qwen.ai/api/v1/oauth2/token",
|
||||
authUrl: "https://chat.qwen.ai/api/v1/oauth2/device/code"
|
||||
},
|
||||
iflow: {
|
||||
baseUrl: "https://apis.iflow.cn/v1/chat/completions",
|
||||
format: "openai",
|
||||
headers: { "User-Agent": "iFlow-Cli" },
|
||||
clientId: "10009311001",
|
||||
clientSecret: "4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW",
|
||||
tokenUrl: "https://iflow.cn/oauth/token",
|
||||
authUrl: "https://iflow.cn/oauth"
|
||||
},
|
||||
antigravity: {
|
||||
baseUrls: [
|
||||
"https://daily-cloudcode-pa.googleapis.com",
|
||||
"https://daily-cloudcode-pa.sandbox.googleapis.com",
|
||||
],
|
||||
format: "antigravity",
|
||||
headers: { "User-Agent": `antigravity/1.107.0 ${platform()}/${arch()}` },
|
||||
clientId: "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com",
|
||||
clientSecret: "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"
|
||||
},
|
||||
openrouter: {
|
||||
baseUrl: "https://openrouter.ai/api/v1/chat/completions",
|
||||
format: "openai",
|
||||
headers: {
|
||||
"HTTP-Referer": "https://endpoint-proxy.local",
|
||||
"X-Title": "Endpoint Proxy"
|
||||
}
|
||||
},
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1/chat/completions",
|
||||
format: "openai"
|
||||
},
|
||||
glm: {
|
||||
baseUrl: "https://api.z.ai/api/anthropic/v1/messages",
|
||||
format: "claude",
|
||||
headers: { ...CLAUDE_API_HEADERS }
|
||||
},
|
||||
"glm-cn": {
|
||||
baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions",
|
||||
format: "openai",
|
||||
headers: {}
|
||||
},
|
||||
kimi: {
|
||||
baseUrl: KIMI_CODING_BASE_URL,
|
||||
format: "claude",
|
||||
headers: { ...CLAUDE_API_HEADERS }
|
||||
},
|
||||
minimax: {
|
||||
baseUrl: "https://api.minimax.io/anthropic/v1/messages",
|
||||
format: "claude",
|
||||
headers: { ...CLAUDE_API_HEADERS }
|
||||
},
|
||||
"minimax-cn": {
|
||||
baseUrl: "https://api.minimaxi.com/anthropic/v1/messages",
|
||||
format: "claude",
|
||||
headers: { ...CLAUDE_API_HEADERS }
|
||||
},
|
||||
alicode: {
|
||||
baseUrl: "https://coding.dashscope.aliyuncs.com/v1/chat/completions",
|
||||
format: "openai",
|
||||
headers: {}
|
||||
},
|
||||
"alicode-intl": {
|
||||
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1/chat/completions",
|
||||
format: "openai",
|
||||
headers: {}
|
||||
},
|
||||
github: {
|
||||
baseUrl: "https://api.githubcopilot.com/chat/completions",
|
||||
responsesUrl: "https://api.githubcopilot.com/responses",
|
||||
format: "openai",
|
||||
headers: {
|
||||
"copilot-integration-id": "vscode-chat",
|
||||
"editor-version": "vscode/1.110.0",
|
||||
"editor-plugin-version": "copilot-chat/0.38.0",
|
||||
"user-agent": "GitHubCopilotChat/0.38.0",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
kiro: {
|
||||
baseUrl: "https://codewhisperer.us-east-1.amazonaws.com/generateAssistantResponse",
|
||||
format: "kiro",
|
||||
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"
|
||||
},
|
||||
tokenUrl: "https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken",
|
||||
authUrl: "https://prod.us-east-1.auth.desktop.kiro.dev"
|
||||
},
|
||||
cursor: {
|
||||
baseUrl: "https://api2.cursor.sh",
|
||||
chatPath: "/aiserver.v1.ChatService/StreamUnifiedChatWithTools",
|
||||
format: "cursor",
|
||||
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"
|
||||
},
|
||||
"kimi-coding": {
|
||||
baseUrl: KIMI_CODING_BASE_URL,
|
||||
format: "claude",
|
||||
headers: { ...CLAUDE_API_HEADERS },
|
||||
clientId: "17e5f671-d194-4dfb-9706-5516cb48c098",
|
||||
tokenUrl: "https://auth.kimi.com/api/oauth/token",
|
||||
refreshUrl: "https://auth.kimi.com/api/oauth/token"
|
||||
},
|
||||
kilocode: {
|
||||
baseUrl: "https://api.kilo.ai/api/openrouter/chat/completions",
|
||||
format: "openai",
|
||||
headers: {}
|
||||
},
|
||||
cline: {
|
||||
baseUrl: "https://api.cline.bot/api/v1/chat/completions",
|
||||
format: "openai",
|
||||
headers: {
|
||||
"HTTP-Referer": "https://cline.bot",
|
||||
"X-Title": "Cline"
|
||||
},
|
||||
tokenUrl: "https://api.cline.bot/api/v1/auth/token",
|
||||
refreshUrl: "https://api.cline.bot/api/v1/auth/refresh"
|
||||
},
|
||||
nvidia: {
|
||||
baseUrl: "https://integrate.api.nvidia.com/v1/chat/completions",
|
||||
format: "openai"
|
||||
},
|
||||
anthropic: {
|
||||
baseUrl: "https://api.anthropic.com/v1/messages",
|
||||
format: "claude",
|
||||
headers: { ...CLAUDE_API_HEADERS }
|
||||
},
|
||||
deepseek: {
|
||||
baseUrl: "https://api.deepseek.com/chat/completions",
|
||||
format: "openai"
|
||||
},
|
||||
groq: {
|
||||
baseUrl: "https://api.groq.com/openai/v1/chat/completions",
|
||||
format: "openai"
|
||||
},
|
||||
xai: {
|
||||
baseUrl: "https://api.x.ai/v1/chat/completions",
|
||||
format: "openai"
|
||||
},
|
||||
mistral: {
|
||||
baseUrl: "https://api.mistral.ai/v1/chat/completions",
|
||||
format: "openai"
|
||||
},
|
||||
perplexity: {
|
||||
baseUrl: "https://api.perplexity.ai/chat/completions",
|
||||
format: "openai"
|
||||
},
|
||||
together: {
|
||||
baseUrl: "https://api.together.xyz/v1/chat/completions",
|
||||
format: "openai"
|
||||
},
|
||||
fireworks: {
|
||||
baseUrl: "https://api.fireworks.ai/inference/v1/chat/completions",
|
||||
format: "openai"
|
||||
},
|
||||
cerebras: {
|
||||
baseUrl: "https://api.cerebras.ai/v1/chat/completions",
|
||||
format: "openai"
|
||||
},
|
||||
cohere: {
|
||||
baseUrl: "https://api.cohere.ai/v1/chat/completions",
|
||||
format: "openai"
|
||||
},
|
||||
nebius: {
|
||||
baseUrl: "https://api.studio.nebius.ai/v1/chat/completions",
|
||||
format: "openai"
|
||||
},
|
||||
siliconflow: {
|
||||
baseUrl: "https://api.siliconflow.cn/v1/chat/completions",
|
||||
format: "openai"
|
||||
},
|
||||
hyperbolic: {
|
||||
baseUrl: "https://api.hyperbolic.xyz/v1/chat/completions",
|
||||
format: "openai"
|
||||
},
|
||||
deepgram: {
|
||||
baseUrl: "https://api.deepgram.com/v1/listen",
|
||||
format: "openai"
|
||||
},
|
||||
assemblyai: {
|
||||
baseUrl: "https://api.assemblyai.com/v1/audio/transcriptions",
|
||||
format: "openai"
|
||||
},
|
||||
nanobanana: {
|
||||
baseUrl: "https://api.nanobananaapi.ai/v1/chat/completions",
|
||||
format: "openai"
|
||||
},
|
||||
chutes: {
|
||||
baseUrl: "https://llm.chutes.ai/v1/chat/completions",
|
||||
format: "openai"
|
||||
},
|
||||
ollama: {
|
||||
baseUrl: "https://ollama.com/api/chat",
|
||||
format: "ollama"
|
||||
},
|
||||
"ollama-local": {
|
||||
baseUrl: "http://localhost:11434/api/chat",
|
||||
format: "ollama"
|
||||
},
|
||||
// Vertex AI - Gemini models via Service Account JSON
|
||||
// baseUrl is not used; VertexExecutor.buildUrl() constructs it dynamically
|
||||
vertex: {
|
||||
baseUrl: "https://aiplatform.googleapis.com",
|
||||
format: "gemini"
|
||||
},
|
||||
// Vertex AI - Partner models (Claude, Llama, Mistral, GLM) via SA JSON
|
||||
// Uses OpenAI-compatible global endpoint (or rawPredict for Anthropic)
|
||||
"vertex-partner": {
|
||||
baseUrl: "https://aiplatform.googleapis.com",
|
||||
format: "openai"
|
||||
},
|
||||
};
|
||||
@@ -1,92 +0,0 @@
|
||||
// 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.NOT_ACCEPTABLE]: { type: "invalid_request_error", code: "model_not_supported" },
|
||||
[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.NOT_ACCEPTABLE]: "Model not supported",
|
||||
[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"
|
||||
};
|
||||
|
||||
// Cache TTLs (seconds)
|
||||
export const CACHE_TTL = {
|
||||
userInfo: 300, // 5 minutes
|
||||
modelAlias: 3600 // 1 hour
|
||||
};
|
||||
|
||||
// Memory management config
|
||||
export const MEMORY_CONFIG = {
|
||||
sessionTtlMs: 2 * 60 * 60 * 1000,
|
||||
sessionCleanupIntervalMs: 30 * 60 * 1000,
|
||||
dnsCacheTtlMs: 5 * 60 * 1000,
|
||||
proxyDispatchersMaxSize: 20,
|
||||
};
|
||||
|
||||
// Default token limits
|
||||
export const DEFAULT_MAX_TOKENS = 64000;
|
||||
export const DEFAULT_MIN_TOKENS = 32000;
|
||||
|
||||
// Retry config for 429 responses
|
||||
export const RETRY_CONFIG = {
|
||||
maxAttempts: 2,
|
||||
delayMs: 2000
|
||||
};
|
||||
|
||||
// Exponential backoff config for rate limits
|
||||
export const BACKOFF_CONFIG = {
|
||||
base: 1000,
|
||||
max: 2 * 60 * 1000,
|
||||
maxLevel: 15
|
||||
};
|
||||
|
||||
// Error-based cooldown times
|
||||
export const COOLDOWN_MS = {
|
||||
unauthorized: 2 * 60 * 1000,
|
||||
paymentRequired: 2 * 60 * 1000,
|
||||
notFound: 2 * 60 * 1000,
|
||||
transient: 30 * 1000,
|
||||
requestNotAllowed: 5 * 1000,
|
||||
// Legacy aliases
|
||||
rateLimit: 2 * 60 * 1000,
|
||||
serviceUnavailable: 2 * 1000,
|
||||
authExpired: 2 * 60 * 1000
|
||||
};
|
||||
|
||||
// Requests containing these texts will bypass provider
|
||||
export const SKIP_PATTERNS = [
|
||||
"Please write a 5-10 word title for the following conversation:"
|
||||
];
|
||||
@@ -1,261 +0,0 @@
|
||||
import crypto from "crypto";
|
||||
import { BaseExecutor } from "./base.js";
|
||||
import { PROVIDERS } from "../config/providers.js";
|
||||
import { OAUTH_ENDPOINTS, ANTIGRAVITY_HEADERS, INTERNAL_REQUEST_HEADER } from "../config/appConstants.js";
|
||||
import { HTTP_STATUS } from "../config/runtimeConfig.js";
|
||||
import { deriveSessionId } from "../utils/sessionManager.js";
|
||||
import { proxyAwareFetch } from "../utils/proxyFetch.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, sessionId = null) {
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${credentials.accessToken}`,
|
||||
"User-Agent": this.config.headers?.["User-Agent"] || ANTIGRAVITY_HEADERS["User-Agent"],
|
||||
[INTERNAL_REQUEST_HEADER.name]: INTERNAL_REQUEST_HEADER.value,
|
||||
...(sessionId && { "X-Machine-Session-Id": sessionId }),
|
||||
"Accept": stream ? "text/event-stream" : "application/json"
|
||||
};
|
||||
}
|
||||
|
||||
transformRequest(model, body, stream, credentials) {
|
||||
const projectId = credentials?.projectId || this.generateProjectId();
|
||||
|
||||
// Fix contents for Claude models via Antigravity
|
||||
const contents = 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-only parts, keep thoughtSignature on functionCall parts (Gemini 3+ requires it)
|
||||
const parts = c.parts?.filter(p => {
|
||||
if (p.thought && !p.functionCall) return false;
|
||||
if (p.thoughtSignature && !p.functionCall && !p.text) return false;
|
||||
return true;
|
||||
});
|
||||
if (role !== c.role || parts?.length !== c.parts?.length) {
|
||||
return { ...c, role, parts };
|
||||
}
|
||||
return c;
|
||||
});
|
||||
|
||||
const transformedRequest = {
|
||||
...body.request,
|
||||
...(contents && { contents }),
|
||||
sessionId: body.request?.sessionId || deriveSessionId(credentials?.email || credentials?.connectionId),
|
||||
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 crypto.randomUUID() + Date.now().toString();
|
||||
}
|
||||
|
||||
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, proxyOptions = null }) {
|
||||
const fallbackCount = this.getFallbackCount();
|
||||
let lastError = null;
|
||||
let lastStatus = 0;
|
||||
const MAX_AUTO_RETRIES = 3;
|
||||
const MAX_RETRY_AFTER_RETRIES = 3;
|
||||
const retryAttemptsByUrl = {}; // Track retry attempts per URL
|
||||
const retryAfterAttemptsByUrl = {}; // Track Retry-After retries per URL
|
||||
|
||||
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
|
||||
const url = this.buildUrl(model, stream, urlIndex);
|
||||
const transformedBody = this.transformRequest(model, body, stream, credentials);
|
||||
const sessionId = transformedBody.request?.sessionId;
|
||||
const headers = this.buildHeaders(credentials, stream, sessionId);
|
||||
|
||||
// Initialize retry counters for this URL
|
||||
if (!retryAttemptsByUrl[urlIndex]) {
|
||||
retryAttemptsByUrl[urlIndex] = 0;
|
||||
}
|
||||
if (!retryAfterAttemptsByUrl[urlIndex]) {
|
||||
retryAfterAttemptsByUrl[urlIndex] = 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await proxyAwareFetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(transformedBody),
|
||||
signal
|
||||
}, proxyOptions);
|
||||
|
||||
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 && retryAfterAttemptsByUrl[urlIndex] < MAX_RETRY_AFTER_RETRIES) {
|
||||
retryAfterAttemptsByUrl[urlIndex]++;
|
||||
log?.debug?.("RETRY", `${response.status} with Retry-After: ${Math.ceil(retryMs / 1000)}s, waiting... (${retryAfterAttemptsByUrl[urlIndex]}/${MAX_RETRY_AFTER_RETRIES})`);
|
||||
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;
|
||||
@@ -1,130 +0,0 @@
|
||||
import { HTTP_STATUS, RETRY_CONFIG } from "../config/runtimeConfig.js";
|
||||
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
||||
|
||||
/**
|
||||
* BaseExecutor - Base class for provider executors
|
||||
*/
|
||||
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, proxyOptions = null }) {
|
||||
const fallbackCount = this.getFallbackCount();
|
||||
let lastError = null;
|
||||
let lastStatus = 0;
|
||||
const retryAttemptsByUrl = {};
|
||||
|
||||
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
|
||||
const url = this.buildUrl(model, stream, urlIndex, credentials);
|
||||
const transformedBody = this.transformRequest(model, body, stream, credentials);
|
||||
const headers = this.buildHeaders(credentials, stream);
|
||||
|
||||
if (!retryAttemptsByUrl[urlIndex]) retryAttemptsByUrl[urlIndex] = 0;
|
||||
|
||||
try {
|
||||
const response = await proxyAwareFetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(transformedBody),
|
||||
signal
|
||||
}, proxyOptions);
|
||||
|
||||
// Retry 429 with fixed delay before falling back to next URL
|
||||
if (response.status === HTTP_STATUS.RATE_LIMITED && retryAttemptsByUrl[urlIndex] < RETRY_CONFIG.maxAttempts) {
|
||||
retryAttemptsByUrl[urlIndex]++;
|
||||
log?.debug?.("RETRY", `429 retry ${retryAttemptsByUrl[urlIndex]}/${RETRY_CONFIG.maxAttempts} after ${RETRY_CONFIG.delayMs / 1000}s`);
|
||||
await new Promise(resolve => setTimeout(resolve, RETRY_CONFIG.delayMs));
|
||||
urlIndex--;
|
||||
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 BaseExecutor;
|
||||
@@ -1,108 +0,0 @@
|
||||
import { BaseExecutor } from "./base.js";
|
||||
import { CODEX_DEFAULT_INSTRUCTIONS } from "../config/codexInstructions.js";
|
||||
import { PROVIDERS } from "../config/providers.js";
|
||||
import { normalizeResponsesInput } from "../translator/helpers/responsesApiHelper.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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Override headers to add session_id per request
|
||||
*/
|
||||
buildHeaders(credentials, stream = true) {
|
||||
const headers = super.buildHeaders(credentials, stream);
|
||||
headers["session_id"] = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform request before sending - inject default instructions if missing
|
||||
*/
|
||||
transformRequest(model, body, stream, credentials) {
|
||||
// Convert string input to array format (Codex API requires input as array)
|
||||
const normalized = normalizeResponsesInput(body.input);
|
||||
if (normalized) body.input = normalized;
|
||||
|
||||
// Ensure input is present and non-empty (Codex API rejects empty input)
|
||||
if (!body.input || (Array.isArray(body.input) && body.input.length === 0)) {
|
||||
body.input = [{ type: "message", role: "user", content: [{ type: "input_text", text: "..." }] }];
|
||||
}
|
||||
|
||||
// Normalize image content: image_url → input_image (Responses API format)
|
||||
if (Array.isArray(body.input)) {
|
||||
for (const item of body.input) {
|
||||
if (Array.isArray(item.content)) {
|
||||
item.content = item.content.map(c => {
|
||||
if (c.type === "image_url") {
|
||||
const url = typeof c.image_url === "string" ? c.image_url : c.image_url?.url;
|
||||
return { type: "input_image", image_url: url, detail: c.image_url?.detail || "auto" };
|
||||
}
|
||||
return c;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure streaming is enabled (Codex API requires it)
|
||||
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, summary: "auto" };
|
||||
} else if (!body.reasoning.summary) {
|
||||
body.reasoning.summary = "auto";
|
||||
}
|
||||
delete body.reasoning_effort;
|
||||
|
||||
// Include reasoning encrypted content (required by Codex backend for reasoning models)
|
||||
if (body.reasoning && body.reasoning.effort && body.reasoning.effort !== 'none') {
|
||||
body.include = ["reasoning.encrypted_content"];
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -1,742 +0,0 @@
|
||||
import { BaseExecutor } from "./base.js";
|
||||
import { PROVIDERS } from "../config/providers.js";
|
||||
import { HTTP_STATUS } from "../config/runtimeConfig.js";
|
||||
import {
|
||||
generateCursorBody,
|
||||
parseConnectRPCFrame,
|
||||
extractTextFromResponse
|
||||
} from "../utils/cursorProtobuf.js";
|
||||
import { buildCursorHeaders } from "../utils/cursorChecksum.js";
|
||||
import { estimateUsage } from "../utils/usageTracking.js";
|
||||
import { FORMATS } from "../translator/formats.js";
|
||||
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
const COMPRESS_FLAG = {
|
||||
NONE: 0x00,
|
||||
GZIP: 0x01,
|
||||
TRAILER: 0x02,
|
||||
GZIP_TRAILER: 0x03
|
||||
};
|
||||
|
||||
const CURSOR_STREAM_DEBUG = process.env.CURSOR_STREAM_DEBUG === "1";
|
||||
const debugLog = (...args) => {
|
||||
if (CURSOR_STREAM_DEBUG) console.log(...args);
|
||||
};
|
||||
|
||||
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"')) {
|
||||
debugLog(`[DECOMPRESS] Detected JSON error, skipping decompression`);
|
||||
return payload;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (
|
||||
flags === COMPRESS_FLAG.GZIP ||
|
||||
flags === COMPRESS_FLAG.TRAILER ||
|
||||
flags === COMPRESS_FLAG.GZIP_TRAILER
|
||||
) {
|
||||
// Primary: try gzip decompression (standard gzip header 0x1f 0x8b)
|
||||
try {
|
||||
return zlib.gunzipSync(payload);
|
||||
} catch (gzipErr) {
|
||||
// Fallback: TRAILER and GZIP_TRAILER frames sometimes use raw zlib deflate format
|
||||
try {
|
||||
return zlib.inflateSync(payload);
|
||||
} catch (deflateErr) {
|
||||
// Last resort: try raw deflate (no zlib header)
|
||||
try {
|
||||
return zlib.inflateRawSync(payload);
|
||||
} catch (rawErr) {
|
||||
debugLog(
|
||||
`[DECOMPRESS ERROR] flags=${flags}, payloadSize=${payload.length}, gzip=${gzipErr.message}, deflate=${deflateErr.message}, raw=${rawErr.message}`
|
||||
);
|
||||
debugLog(
|
||||
`[DECOMPRESS ERROR] First 50 bytes (hex):`,
|
||||
payload.slice(0, 50).toString("hex")
|
||||
);
|
||||
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}`;
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
return buildCursorHeaders(accessToken, machineId, ghostMode);
|
||||
}
|
||||
|
||||
transformRequest(model, body, stream, credentials) {
|
||||
// Messages are already translated by chatCore (claude→openai→cursor)
|
||||
// Do NOT call buildCursorRequest again — double-translation drops tool_results
|
||||
const messages = body.messages || [];
|
||||
const tools = body.tools || [];
|
||||
const reasoningEffort = body.reasoning_effort || null;
|
||||
return generateCursorBody(messages, model, tools, reasoningEffort);
|
||||
}
|
||||
|
||||
async makeFetchRequest(url, headers, body, signal, proxyOptions = null) {
|
||||
const response = await proxyAwareFetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
signal
|
||||
}, proxyOptions);
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
const HTTP2_TIMEOUT_MS = 60000; // 60s max — prevent hung sessions
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const urlObj = new URL(url);
|
||||
const client = http2.connect(`https://${urlObj.host}`);
|
||||
const chunks = [];
|
||||
let responseHeaders = {};
|
||||
let settled = false;
|
||||
|
||||
// Ensure client is always closed on settle
|
||||
const finish = (fn) => (...args) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(hangTimeout);
|
||||
client.close();
|
||||
fn(...args);
|
||||
};
|
||||
|
||||
// Hard timeout: close session if server never responds
|
||||
const hangTimeout = setTimeout(finish(() => {
|
||||
reject(new Error("HTTP/2 request timed out"));
|
||||
}), HTTP2_TIMEOUT_MS);
|
||||
|
||||
client.on("error", finish(reject));
|
||||
|
||||
const req = client.request({
|
||||
":method": "POST",
|
||||
":path": urlObj.pathname,
|
||||
":authority": urlObj.host,
|
||||
":scheme": "https",
|
||||
...headers
|
||||
});
|
||||
|
||||
req.on("response", (hdrs) => { responseHeaders = hdrs; });
|
||||
req.on("data", (chunk) => { chunks.push(chunk); });
|
||||
req.on("end", finish(() => {
|
||||
resolve({
|
||||
status: responseHeaders[":status"],
|
||||
headers: responseHeaders,
|
||||
body: Buffer.concat(chunks)
|
||||
});
|
||||
}));
|
||||
req.on("error", finish(reject));
|
||||
|
||||
if (signal) {
|
||||
const onAbort = finish(() => reject(new Error("Request aborted")));
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) {
|
||||
const url = this.buildUrl();
|
||||
const headers = this.buildHeaders(credentials);
|
||||
const transformedBody = this.transformRequest(model, body, stream, credentials);
|
||||
|
||||
try {
|
||||
const shouldForceFetch = proxyOptions?.enabled === true || proxyOptions?.connectionProxyEnabled === true;
|
||||
const response = (http2 && !shouldForceFetch)
|
||||
? await this.makeHttp2Request(url, headers, transformedBody, signal)
|
||||
: await this.makeFetchRequest(url, headers, transformedBody, signal, proxyOptions);
|
||||
|
||||
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
|
||||
const finalizedIds = new Set();
|
||||
let frameCount = 0;
|
||||
|
||||
debugLog(`[CURSOR BUFFER] Total length: ${buffer.length} bytes`);
|
||||
|
||||
while (offset < buffer.length) {
|
||||
if (offset + 5 > buffer.length) {
|
||||
debugLog(
|
||||
`[CURSOR BUFFER] Reached end, offset=${offset}, remaining=${buffer.length - offset}`
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
const flags = buffer[offset];
|
||||
const length = buffer.readUInt32BE(offset + 1);
|
||||
|
||||
debugLog(
|
||||
`[CURSOR BUFFER] Frame ${frameCount + 1}: flags=0x${flags.toString(16).padStart(2, "0")}, length=${length}`
|
||||
);
|
||||
|
||||
if (offset + 5 + length > buffer.length) {
|
||||
debugLog(
|
||||
`[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) {
|
||||
debugLog(`[CURSOR BUFFER] Frame ${frameCount}: decompression failed, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for JSON error frames (byte guard: skip toString on non-JSON frames)
|
||||
if (payload.length > 0 && payload[0] === 0x7b) {
|
||||
try {
|
||||
const text = payload.toString("utf-8");
|
||||
if (text.includes('"error"')) {
|
||||
const hasContent = totalContent || toolCallsMap.size > 0;
|
||||
debugLog(
|
||||
`[CURSOR BUFFER] Error frame (hasContent=${hasContent}): ${text.slice(0, 500)}`
|
||||
);
|
||||
if (hasContent) {
|
||||
break;
|
||||
}
|
||||
return createErrorResponse(JSON.parse(text));
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const result = extractTextFromResponse(new Uint8Array(payload));
|
||||
debugLog(`[CURSOR DECODED] Frame ${frameCount}:`, result);
|
||||
|
||||
if (result.error) {
|
||||
const hasContent = totalContent || toolCallsMap.size > 0;
|
||||
debugLog(`[CURSOR BUFFER] Decoded error (hasContent=${hasContent}): ${result.error}`);
|
||||
if (hasContent) {
|
||||
break;
|
||||
}
|
||||
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);
|
||||
finalizedIds.add(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;
|
||||
}
|
||||
|
||||
debugLog(
|
||||
`[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 (!finalizedIds.has(id)) {
|
||||
debugLog(`[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
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
debugLog(`[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
|
||||
const finalizedIds = new Set();
|
||||
const emittedToolCallIds = new Set();
|
||||
let frameCount = 0;
|
||||
|
||||
debugLog(`[CURSOR BUFFER SSE] Total length: ${buffer.length} bytes`);
|
||||
|
||||
while (offset < buffer.length) {
|
||||
if (offset + 5 > buffer.length) {
|
||||
debugLog(
|
||||
`[CURSOR BUFFER SSE] Reached end, offset=${offset}, remaining=${buffer.length - offset}`
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
const flags = buffer[offset];
|
||||
const length = buffer.readUInt32BE(offset + 1);
|
||||
|
||||
debugLog(
|
||||
`[CURSOR BUFFER SSE] Frame ${frameCount + 1}: flags=0x${flags.toString(16).padStart(2, "0")}, length=${length}`
|
||||
);
|
||||
|
||||
if (offset + 5 + length > buffer.length) {
|
||||
debugLog(
|
||||
`[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) {
|
||||
debugLog(`[CURSOR BUFFER SSE] Frame ${frameCount}: decompression failed, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for JSON error frames (byte-guard: only decode if starts with '{')
|
||||
if (payload[0] === 0x7b) {
|
||||
try {
|
||||
const text = payload.toString("utf-8");
|
||||
if (text.includes('"error"')) {
|
||||
const hasContent = chunks.length > 0 || totalContent || toolCallsMap.size > 0;
|
||||
debugLog(
|
||||
`[CURSOR BUFFER SSE] Error frame (hasContent=${hasContent}): ${text.slice(0, 500)}`
|
||||
);
|
||||
if (hasContent) {
|
||||
break;
|
||||
}
|
||||
return createErrorResponse(JSON.parse(text));
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const result = extractTextFromResponse(new Uint8Array(payload));
|
||||
debugLog(`[CURSOR DECODED SSE] Frame ${frameCount}:`, result);
|
||||
|
||||
if (result.error) {
|
||||
const hasContent = chunks.length > 0 || totalContent || toolCallsMap.size > 0;
|
||||
debugLog(`[CURSOR BUFFER SSE] Decoded error (hasContent=${hasContent}): ${result.error}`);
|
||||
if (hasContent) {
|
||||
break;
|
||||
}
|
||||
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) {
|
||||
emittedToolCallIds.add(tc.id);
|
||||
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;
|
||||
finalizedIds.add(tc.id);
|
||||
toolCalls.push({ ...tc, index: toolCallIndex });
|
||||
toolCallsMap.set(tc.id, { ...tc, index: toolCallIndex });
|
||||
|
||||
// Stream initial tool call with name
|
||||
emittedToolCallIds.add(tc.id);
|
||||
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`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
debugLog(
|
||||
`[CURSOR BUFFER SSE] Parsed ${frameCount} frames, toolCallsMap size: ${toolCallsMap.size}, toolCalls array: ${toolCalls.length}`
|
||||
);
|
||||
|
||||
// Finalize all remaining tool calls in map (stream may have ended without isLast=true)
|
||||
for (const [id, tc] of toolCallsMap.entries()) {
|
||||
if (!finalizedIds.has(id)) {
|
||||
debugLog(`[CURSOR BUFFER SSE] Finalizing incomplete tool call: ${id}, isLast=${tc.isLast}`);
|
||||
const toolCallIndex = toolCalls.length;
|
||||
toolCalls.push({
|
||||
id: tc.id,
|
||||
type: tc.type,
|
||||
index: toolCallIndex,
|
||||
function: {
|
||||
name: tc.function.name,
|
||||
arguments: tc.function.arguments
|
||||
}
|
||||
});
|
||||
|
||||
// Emit SSE chunk for the finalized tool call if not already emitted
|
||||
if (!emittedToolCallIds.has(tc.id)) {
|
||||
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 (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;
|
||||
@@ -1,213 +0,0 @@
|
||||
import { BaseExecutor } from "./base.js";
|
||||
import { PROVIDERS } from "../config/providers.js";
|
||||
import { OAUTH_ENDPOINTS, buildKimiHeaders } from "../config/appConstants.js";
|
||||
import { buildClineHeaders } from "../../src/shared/utils/clineAuth.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":
|
||||
case "minimax":
|
||||
case "minimax-cn":
|
||||
return `${this.config.baseUrl}?beta=true`;
|
||||
case "kimi-coding":
|
||||
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":
|
||||
case "minimax":
|
||||
case "minimax-cn":
|
||||
headers["x-api-key"] = credentials.apiKey || credentials.accessToken;
|
||||
break;
|
||||
case "kimi-coding":
|
||||
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
|
||||
Object.assign(headers, buildKimiHeaders());
|
||||
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 if (this.provider === "kilocode") {
|
||||
headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`;
|
||||
if (credentials.providerSpecificData?.orgId) {
|
||||
headers["X-Kilocode-OrganizationID"] = credentials.providerSpecificData.orgId;
|
||||
}
|
||||
} else if (this.provider === "cline") {
|
||||
Object.assign(headers, buildClineHeaders(credentials.apiKey || credentials.accessToken));
|
||||
} else {
|
||||
headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (stream) headers["Accept"] = "text/event-stream";
|
||||
return headers;
|
||||
}
|
||||
|
||||
async refreshCredentials(credentials, log) {
|
||||
if (!credentials.refreshToken) return null;
|
||||
|
||||
const refreshers = {
|
||||
claude: () => this.refreshWithJSON(OAUTH_ENDPOINTS.anthropic.token, { grant_type: "refresh_token", refresh_token: credentials.refreshToken, client_id: PROVIDERS.claude.clientId }),
|
||||
codex: () => this.refreshWithForm(OAUTH_ENDPOINTS.openai.token, { grant_type: "refresh_token", refresh_token: credentials.refreshToken, client_id: PROVIDERS.codex.clientId, scope: "openid profile email offline_access" }),
|
||||
qwen: () => this.refreshWithForm(OAUTH_ENDPOINTS.qwen.token, { grant_type: "refresh_token", refresh_token: credentials.refreshToken, client_id: PROVIDERS.qwen.clientId }),
|
||||
iflow: () => this.refreshIflow(credentials.refreshToken),
|
||||
gemini: () => this.refreshGoogle(credentials.refreshToken),
|
||||
kiro: () => this.refreshKiro(credentials.refreshToken),
|
||||
cline: () => this.refreshCline(credentials.refreshToken),
|
||||
"kimi-coding": () => this.refreshKimiCoding(credentials.refreshToken),
|
||||
kilocode: () => this.refreshKilocode(credentials.refreshToken)
|
||||
};
|
||||
|
||||
const refresher = refreshers[this.provider];
|
||||
if (!refresher) return null;
|
||||
|
||||
try {
|
||||
const result = await refresher();
|
||||
if (result) log?.info?.("TOKEN", `${this.provider} refreshed`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
log?.error?.("TOKEN", `${this.provider} refresh error: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async refreshWithJSON(url, body) {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "Accept": "application/json" },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const tokens = await response.json();
|
||||
return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || body.refresh_token, expiresIn: tokens.expires_in };
|
||||
}
|
||||
|
||||
async refreshWithForm(url, params) {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json" },
|
||||
body: new URLSearchParams(params)
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const tokens = await response.json();
|
||||
return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || params.refresh_token, expiresIn: tokens.expires_in };
|
||||
}
|
||||
|
||||
async refreshIflow(refreshToken) {
|
||||
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) return null;
|
||||
const tokens = await response.json();
|
||||
return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in };
|
||||
}
|
||||
|
||||
async refreshGoogle(refreshToken) {
|
||||
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: this.config.clientId, client_secret: this.config.clientSecret })
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const tokens = await response.json();
|
||||
return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in };
|
||||
}
|
||||
|
||||
async refreshKiro(refreshToken) {
|
||||
const response = await fetch(PROVIDERS.kiro.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "Accept": "application/json", "User-Agent": "kiro-cli/1.0.0" },
|
||||
body: JSON.stringify({ refreshToken })
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const tokens = await response.json();
|
||||
return { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken || refreshToken, expiresIn: tokens.expiresIn };
|
||||
}
|
||||
|
||||
async refreshCline(refreshToken) {
|
||||
console.log('[DEBUG] Refreshing Cline token, refreshToken length:', refreshToken?.length);
|
||||
const response = await fetch("https://api.cline.bot/api/v1/auth/refresh", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "Accept": "application/json" },
|
||||
body: JSON.stringify({ refreshToken, grantType: "refresh_token", clientType: "extension" })
|
||||
});
|
||||
console.log('[DEBUG] Cline refresh response status:', response.status);
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.log('[DEBUG] Cline refresh error:', errorText);
|
||||
return null;
|
||||
}
|
||||
const payload = await response.json();
|
||||
console.log('[DEBUG] Cline refresh payload:', JSON.stringify(payload).substring(0, 200));
|
||||
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;
|
||||
console.log('[DEBUG] Cline refresh success, expiresIn:', expiresIn);
|
||||
return { accessToken: data?.accessToken, refreshToken: data?.refreshToken || refreshToken, expiresIn };
|
||||
}
|
||||
|
||||
async refreshKimiCoding(refreshToken) {
|
||||
const kimiHeaders = buildKimiHeaders();
|
||||
const response = await fetch("https://auth.kimi.com/api/oauth/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
...kimiHeaders
|
||||
},
|
||||
body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: "17e5f671-d194-4dfb-9706-5516cb48c098" })
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const tokens = await response.json();
|
||||
return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in };
|
||||
}
|
||||
|
||||
async refreshKilocode(refreshToken) {
|
||||
// Kilocode uses device code flow, no refresh token support
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default DefaultExecutor;
|
||||
@@ -1,67 +0,0 @@
|
||||
import { BaseExecutor } from "./base.js";
|
||||
import { PROVIDERS } from "../config/providers.js";
|
||||
import { OAUTH_ENDPOINTS, GEMINI_CLI_API_CLIENT, geminiCLIUserAgent } from "../config/appConstants.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}`,
|
||||
"User-Agent": geminiCLIUserAgent(this._currentModel),
|
||||
"X-Goog-Api-Client": GEMINI_CLI_API_CLIENT,
|
||||
"Accept": stream ? "text/event-stream" : "application/json"
|
||||
};
|
||||
}
|
||||
|
||||
transformRequest(model, body, stream, credentials) {
|
||||
// Store model for use in buildHeaders (called by base.execute after transformRequest)
|
||||
this._currentModel = model;
|
||||
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;
|
||||
@@ -1,305 +0,0 @@
|
||||
import { BaseExecutor } from "./base.js";
|
||||
import { PROVIDERS } from "../config/providers.js";
|
||||
import { OAUTH_ENDPOINTS, GITHUB_COPILOT } from "../config/appConstants.js";
|
||||
import { HTTP_STATUS } from "../config/runtimeConfig.js";
|
||||
import { openaiToOpenAIResponsesRequest } from "../translator/request/openai-responses.js";
|
||||
import { openaiResponsesToOpenAIResponse } from "../translator/response/openai-responses.js";
|
||||
import { initState } from "../translator/index.js";
|
||||
import { parseSSELine, formatSSE } from "../utils/streamHelpers.js";
|
||||
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
||||
import crypto from "crypto";
|
||||
|
||||
export class GithubExecutor extends BaseExecutor {
|
||||
constructor() {
|
||||
super("github", PROVIDERS.github);
|
||||
this.knownCodexModels = new Set();
|
||||
}
|
||||
|
||||
buildUrl(model, stream, urlIndex = 0) {
|
||||
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/${GITHUB_COPILOT.VSCODE_VERSION}`,
|
||||
"editor-plugin-version": `copilot-chat/${GITHUB_COPILOT.COPILOT_CHAT_VERSION}`,
|
||||
"user-agent": GITHUB_COPILOT.USER_AGENT,
|
||||
"openai-intent": "conversation-panel",
|
||||
"x-github-api-version": GITHUB_COPILOT.API_VERSION,
|
||||
"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"
|
||||
};
|
||||
}
|
||||
|
||||
// Sanitize messages for GitHub Copilot /chat/completions endpoint.
|
||||
// The endpoint only accepts 'text' and 'image_url' content part types.
|
||||
// Tool-related content (tool_use, tool_result, thinking) must be serialized as text.
|
||||
sanitizeMessagesForChatCompletions(body) {
|
||||
if (!body?.messages) return body;
|
||||
|
||||
const sanitized = { ...body };
|
||||
|
||||
// Handle response_format for Claude models via GitHub
|
||||
// GitHub's internal translation doesn't respect response_format, so we inject it as a system prompt
|
||||
// AND prepend a reminder to the last user message for maximum effectiveness
|
||||
if (body.response_format && body.model?.includes('claude')) {
|
||||
const responseFormat = body.response_format;
|
||||
let systemInstruction = '';
|
||||
if (responseFormat.type === 'json_schema' && responseFormat.json_schema?.schema) {
|
||||
systemInstruction = 'CRITICAL: You must ONLY output raw JSON. Never use markdown code blocks. Never use backticks. Never wrap JSON in triple backticks. Output ONLY the raw JSON object.';
|
||||
} else if (responseFormat.type === 'json_object') {
|
||||
systemInstruction = 'CRITICAL: You must ONLY output raw JSON. Never use markdown code blocks. Never use backticks.';
|
||||
}
|
||||
if (systemInstruction) {
|
||||
// Add to system message
|
||||
const systemIdx = body.messages.findIndex(m => m.role === 'system');
|
||||
if (systemIdx >= 0) {
|
||||
body.messages[systemIdx].content = systemInstruction + '\n\n' + body.messages[systemIdx].content;
|
||||
} else {
|
||||
body.messages.unshift({ role: 'system', content: systemInstruction });
|
||||
}
|
||||
|
||||
// Also prepend to the last user message as a reminder
|
||||
const lastUserIdx = body.messages.map((m, i) => m.role === 'user' ? i : -1).filter(i => i >= 0).pop();
|
||||
if (lastUserIdx >= 0) {
|
||||
const userMsg = body.messages[lastUserIdx];
|
||||
const userContent = typeof userMsg.content === 'string' ? userMsg.content : JSON.stringify(userMsg.content);
|
||||
userMsg.content = 'Respond with ONLY raw JSON (no markdown, no backticks, no code blocks): ' + userContent;
|
||||
}
|
||||
}
|
||||
}
|
||||
sanitized.messages = body.messages.map(msg => {
|
||||
// assistant messages with only tool_calls have content: null — leave as-is
|
||||
if (!msg.content) return msg;
|
||||
|
||||
// String content is always fine
|
||||
if (typeof msg.content === "string") return msg;
|
||||
|
||||
// Array content: filter/convert unsupported part types
|
||||
if (Array.isArray(msg.content)) {
|
||||
const cleanContent = msg.content
|
||||
.map(part => {
|
||||
if (part.type === "text") return part;
|
||||
if (part.type === "image_url") return part;
|
||||
// Serialize tool_use, tool_result, thinking, etc. as text
|
||||
const text = part.text || part.content || JSON.stringify(part);
|
||||
return { type: "text", text: typeof text === "string" ? text : JSON.stringify(text) };
|
||||
})
|
||||
.filter(part => part.text !== ""); // remove empty text parts
|
||||
|
||||
// If all content was stripped (e.g. only tool_result with no text), drop content
|
||||
return { ...msg, content: cleanContent.length > 0 ? cleanContent : null };
|
||||
}
|
||||
|
||||
return msg;
|
||||
});
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
async execute(options) {
|
||||
const { model, log } = options;
|
||||
|
||||
// Only use /responses for models that are explicitly known to need it (e.g. gpt codex models)
|
||||
if (this.knownCodexModels.has(model)) {
|
||||
log?.debug("GITHUB", `Using cached /responses route for ${model}`);
|
||||
return this.executeWithResponsesEndpoint(options);
|
||||
}
|
||||
|
||||
// Sanitize messages before sending to /chat/completions
|
||||
// This handles Claude models on GitHub Copilot which reject non-text/image_url content types
|
||||
const sanitizedOptions = {
|
||||
...options,
|
||||
body: this.sanitizeMessagesForChatCompletions(options.body)
|
||||
};
|
||||
|
||||
const result = await super.execute({ ...sanitizedOptions, proxyOptions: options.proxyOptions || null });
|
||||
|
||||
if (result.response.status === HTTP_STATUS.BAD_REQUEST) {
|
||||
const errorBody = await result.response.clone().text();
|
||||
|
||||
if (errorBody.includes("not accessible via the /chat/completions endpoint")) {
|
||||
log?.warn("GITHUB", `Model ${model} requires /responses. Switching...`);
|
||||
this.knownCodexModels.add(model);
|
||||
return this.executeWithResponsesEndpoint(options);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async executeWithResponsesEndpoint({ model, body, stream, credentials, signal, log, proxyOptions = null }) {
|
||||
const url = this.config.responsesUrl;
|
||||
const headers = this.buildHeaders(credentials, stream);
|
||||
|
||||
const transformedBody = openaiToOpenAIResponsesRequest(model, body, stream, credentials);
|
||||
|
||||
log?.debug("GITHUB", "Sending translated request to /responses");
|
||||
|
||||
const response = await proxyAwareFetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(transformedBody),
|
||||
signal
|
||||
}, proxyOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
return { response, url, headers, transformedBody };
|
||||
}
|
||||
|
||||
const state = initState("openai-responses");
|
||||
state.model = model;
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
const transformStream = new TransformStream({
|
||||
async transform(chunk, controller) {
|
||||
buffer += decoder.decode(chunk, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
const parsed = parseSSELine(trimmed);
|
||||
if (!parsed) continue;
|
||||
|
||||
if (parsed.done && stream === true) {
|
||||
controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n"));
|
||||
continue;
|
||||
}
|
||||
|
||||
const converted = openaiResponsesToOpenAIResponse(parsed, state);
|
||||
if (converted) {
|
||||
const sseString = formatSSE(converted, "openai");
|
||||
controller.enqueue(new TextEncoder().encode(sseString));
|
||||
}
|
||||
}
|
||||
},
|
||||
flush(controller) {
|
||||
if (buffer.trim()) {
|
||||
const parsed = parseSSELine(buffer.trim());
|
||||
if (parsed && !parsed.done) {
|
||||
const converted = openaiResponsesToOpenAIResponse(parsed, state);
|
||||
if (converted) {
|
||||
controller.enqueue(new TextEncoder().encode(formatSSE(converted, "openai")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.body) {
|
||||
return { response: new Response("", { status: response.status, headers: response.headers }), url, headers, transformedBody };
|
||||
}
|
||||
const convertedStream = response.body.pipeThrough(transformStream);
|
||||
|
||||
return {
|
||||
response: new Response(convertedStream, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers
|
||||
}),
|
||||
url,
|
||||
headers,
|
||||
transformedBody
|
||||
};
|
||||
}
|
||||
|
||||
async refreshCopilotToken(githubAccessToken, log) {
|
||||
try {
|
||||
const response = await fetch("https://api.github.com/copilot_internal/v2/token", {
|
||||
headers: {
|
||||
"Authorization": `token ${githubAccessToken}`,
|
||||
"User-Agent": GITHUB_COPILOT.USER_AGENT,
|
||||
"Editor-Version": `vscode/${GITHUB_COPILOT.VSCODE_VERSION}`,
|
||||
"Editor-Plugin-Version": `copilot-chat/${GITHUB_COPILOT.COPILOT_CHAT_VERSION}`,
|
||||
"Accept": "application/json",
|
||||
"x-github-api-version": GITHUB_COPILOT.API_VERSION
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
log?.error?.("TOKEN", `Copilot token refresh failed: ${response.status} ${errorText}`);
|
||||
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;
|
||||
@@ -1,104 +0,0 @@
|
||||
import crypto from "crypto";
|
||||
import { BaseExecutor } from "./base.js";
|
||||
import { PROVIDERS } from "../config/providers.js";
|
||||
|
||||
/**
|
||||
* IFlowExecutor - Executor for iFlow API with HMAC-SHA256 signature
|
||||
*/
|
||||
export class IFlowExecutor extends BaseExecutor {
|
||||
constructor() {
|
||||
super("iflow", PROVIDERS.iflow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate UUID v4
|
||||
* @returns {string} UUID v4 string
|
||||
*/
|
||||
generateUUID() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create iFlow signature using HMAC-SHA256
|
||||
* @param {string} userAgent - User agent string
|
||||
* @param {string} sessionID - Session ID
|
||||
* @param {number} timestamp - Unix timestamp in milliseconds
|
||||
* @param {string} apiKey - API key for signing
|
||||
* @returns {string} Hex-encoded signature
|
||||
*/
|
||||
createIFlowSignature(userAgent, sessionID, timestamp, apiKey) {
|
||||
if (!apiKey) return "";
|
||||
const payload = `${userAgent}:${sessionID}:${timestamp}`;
|
||||
const hmac = crypto.createHmac("sha256", apiKey);
|
||||
hmac.update(payload);
|
||||
return hmac.digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build headers with iFlow-specific signature
|
||||
* @param {object} credentials - Provider credentials
|
||||
* @param {boolean} stream - Whether streaming is enabled
|
||||
* @returns {object} Headers object
|
||||
*/
|
||||
buildHeaders(credentials, stream = true) {
|
||||
// Generate session ID and timestamp
|
||||
const sessionID = `session-${this.generateUUID()}`;
|
||||
const timestamp = Date.now();
|
||||
|
||||
// Get user agent from config
|
||||
const userAgent = this.config.headers["User-Agent"] || "iFlow-Cli";
|
||||
|
||||
// Get API key (prefer apiKey, fallback to accessToken)
|
||||
const apiKey = credentials.apiKey || credentials.accessToken || "";
|
||||
|
||||
// Create signature
|
||||
const signature = this.createIFlowSignature(userAgent, sessionID, timestamp, apiKey);
|
||||
|
||||
// Build headers
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
...this.config.headers,
|
||||
"session-id": sessionID,
|
||||
"x-iflow-timestamp": timestamp.toString(),
|
||||
"x-iflow-signature": signature
|
||||
};
|
||||
|
||||
// Add authorization
|
||||
if (credentials.apiKey) {
|
||||
headers["Authorization"] = `Bearer ${credentials.apiKey}`;
|
||||
}
|
||||
|
||||
// Add streaming header
|
||||
if (stream) {
|
||||
headers["Accept"] = "text/event-stream";
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build URL for iFlow API
|
||||
* @param {string} model - Model name
|
||||
* @param {boolean} stream - Whether streaming is enabled
|
||||
* @param {number} urlIndex - URL index for fallback
|
||||
* @param {object} credentials - Provider credentials
|
||||
* @returns {string} API URL
|
||||
*/
|
||||
buildUrl(model, stream, urlIndex = 0, credentials = null) {
|
||||
return this.config.baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform request body (passthrough for iFlow)
|
||||
* @param {string} model - Model name
|
||||
* @param {object} body - Request body
|
||||
* @param {boolean} stream - Whether streaming is enabled
|
||||
* @param {object} credentials - Provider credentials
|
||||
* @returns {object} Transformed body
|
||||
*/
|
||||
transformRequest(model, body, stream, credentials) {
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
export default IFlowExecutor;
|
||||
@@ -1,45 +0,0 @@
|
||||
import { AntigravityExecutor } from "./antigravity.js";
|
||||
import { GeminiCLIExecutor } from "./gemini-cli.js";
|
||||
import { GithubExecutor } from "./github.js";
|
||||
import { IFlowExecutor } from "./iflow.js";
|
||||
import { KiroExecutor } from "./kiro.js";
|
||||
import { CodexExecutor } from "./codex.js";
|
||||
import { CursorExecutor } from "./cursor.js";
|
||||
import { VertexExecutor } from "./vertex.js";
|
||||
import { DefaultExecutor } from "./default.js";
|
||||
|
||||
const executors = {
|
||||
antigravity: new AntigravityExecutor(),
|
||||
"gemini-cli": new GeminiCLIExecutor(),
|
||||
github: new GithubExecutor(),
|
||||
iflow: new IFlowExecutor(),
|
||||
kiro: new KiroExecutor(),
|
||||
codex: new CodexExecutor(),
|
||||
cursor: new CursorExecutor(),
|
||||
cu: new CursorExecutor(), // Alias for cursor
|
||||
vertex: new VertexExecutor("vertex"),
|
||||
"vertex-partner": new VertexExecutor("vertex-partner"),
|
||||
};
|
||||
|
||||
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 { IFlowExecutor } from "./iflow.js";
|
||||
export { KiroExecutor } from "./kiro.js";
|
||||
export { CodexExecutor } from "./codex.js";
|
||||
export { CursorExecutor } from "./cursor.js";
|
||||
export { VertexExecutor } from "./vertex.js";
|
||||
export { DefaultExecutor } from "./default.js";
|
||||
@@ -1,448 +0,0 @@
|
||||
import { BaseExecutor } from "./base.js";
|
||||
import { PROVIDERS } from "../config/providers.js";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { refreshKiroToken } from "../services/tokenRefresh.js";
|
||||
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
||||
|
||||
/**
|
||||
* 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, proxyOptions = null }) {
|
||||
const url = this.buildUrl(model, stream, 0);
|
||||
const headers = this.buildHeaders(credentials, stream);
|
||||
const transformedBody = this.transformRequest(model, body, stream, credentials);
|
||||
|
||||
const response = await proxyAwareFetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(transformedBody),
|
||||
signal
|
||||
}, proxyOptions);
|
||||
|
||||
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
|
||||
if (!response.body) {
|
||||
return new Response("data: [DONE]\n\n", { status: response.status, headers: { "Content-Type": "text/event-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 headersLength = view.getUint32(4, false);
|
||||
|
||||
// Parse headers
|
||||
const headers = {};
|
||||
let offset = 12; // After prelude
|
||||
const headerEnd = 12 + headersLength;
|
||||
|
||||
while (offset < headerEnd && offset < data.length) {
|
||||
const nameLen = data[offset];
|
||||
offset++;
|
||||
if (offset + nameLen > data.length) break;
|
||||
|
||||
const name = new TextDecoder().decode(data.slice(offset, offset + nameLen));
|
||||
offset += nameLen;
|
||||
|
||||
const headerType = data[offset];
|
||||
offset++;
|
||||
|
||||
if (headerType === 7) { // String type
|
||||
const valueLen = (data[offset] << 8) | data[offset + 1];
|
||||
offset += 2;
|
||||
if (offset + valueLen > data.length) break;
|
||||
|
||||
const value = new TextDecoder().decode(data.slice(offset, offset + valueLen));
|
||||
offset += valueLen;
|
||||
headers[name] = value;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse payload
|
||||
const payloadStart = 12 + headersLength;
|
||||
const payloadEnd = data.length - 4; // Exclude message CRC
|
||||
|
||||
let payload = null;
|
||||
if (payloadEnd > payloadStart) {
|
||||
const payloadStr = new TextDecoder().decode(data.slice(payloadStart, payloadEnd));
|
||||
|
||||
// Skip empty or whitespace-only payloads
|
||||
if (!payloadStr || !payloadStr.trim()) {
|
||||
return { headers, payload: null };
|
||||
}
|
||||
|
||||
try {
|
||||
payload = JSON.parse(payloadStr);
|
||||
} catch (parseError) {
|
||||
// Log parse error for debugging
|
||||
console.warn(`[Kiro] Failed to parse payload: ${parseError.message} | payload: ${payloadStr.substring(0, 100)}`);
|
||||
payload = { raw: payloadStr };
|
||||
}
|
||||
}
|
||||
|
||||
return { headers, payload };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default KiroExecutor;
|
||||
@@ -1,120 +0,0 @@
|
||||
import { BaseExecutor } from "./base.js";
|
||||
import { PROVIDERS } from "../config/providers.js";
|
||||
import { parseVertexSaJson, refreshVertexToken } from "../services/tokenRefresh.js";
|
||||
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
||||
|
||||
// Cache project IDs resolved from raw API keys { apiKey → projectId }
|
||||
const projectIdCache = new Map();
|
||||
|
||||
/**
|
||||
* Resolve GCP project ID from a raw Vertex API key.
|
||||
* Sends a dummy 404 request and parses "projects/{id}" from the error message.
|
||||
*/
|
||||
async function resolveProjectId(apiKey) {
|
||||
if (projectIdCache.has(apiKey)) return projectIdCache.get(apiKey);
|
||||
|
||||
const res = await fetch(
|
||||
`https://aiplatform.googleapis.com/v1/publishers/google/models/__probe__:generateContent?key=${apiKey}`,
|
||||
{ method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }
|
||||
);
|
||||
const json = await res.json().catch(() => null);
|
||||
const msg = json?.[0]?.error?.message || json?.error?.message || "";
|
||||
const match = msg.match(/projects\/([^/]+)\//);
|
||||
const projectId = match?.[1] || null;
|
||||
|
||||
if (projectId) projectIdCache.set(apiKey, projectId);
|
||||
return projectId;
|
||||
}
|
||||
|
||||
/**
|
||||
* VertexExecutor - Google Cloud Vertex AI
|
||||
*
|
||||
* "vertex" → Gemini models via regional/global Vertex endpoint
|
||||
* "vertex-partner" → Partner models (Llama, Mistral, GLM, DeepSeek, Qwen)
|
||||
* via global OpenAI-compatible endpoint
|
||||
*
|
||||
* Auth: SA JSON (stored as apiKey) → JWT assertion → Bearer token (via jose)
|
||||
* Token is minted/cached in tokenRefresh.js, not here.
|
||||
*/
|
||||
export class VertexExecutor extends BaseExecutor {
|
||||
constructor(providerId = "vertex") {
|
||||
super(providerId, PROVIDERS[providerId] || {});
|
||||
}
|
||||
|
||||
buildUrl(model, stream, urlIndex = 0, credentials = null) {
|
||||
const saJson = parseVertexSaJson(credentials?.apiKey);
|
||||
const rawKey = !saJson ? credentials?.apiKey : null;
|
||||
const projectId = saJson?.project_id || credentials?.providerSpecificData?.projectId;
|
||||
|
||||
if (this.provider === "vertex-partner") {
|
||||
// Partner models require project_id in path regardless of auth method
|
||||
if (!projectId) throw new Error("Vertex partner models require a project_id. Add it in providerSpecificData or use Service Account JSON.");
|
||||
const url = `https://aiplatform.googleapis.com/v1/projects/${projectId}/locations/global/endpoints/openapi/chat/completions`;
|
||||
return rawKey ? `${url}?key=${rawKey}` : url;
|
||||
}
|
||||
|
||||
// Gemini on Vertex: always use global publishers endpoint
|
||||
const action = stream ? "streamGenerateContent" : "generateContent";
|
||||
let url = `https://aiplatform.googleapis.com/v1/publishers/google/models/${model}:${action}`;
|
||||
|
||||
if (rawKey) url += `?key=${rawKey}`;
|
||||
return url;
|
||||
}
|
||||
|
||||
buildHeaders(credentials, stream = true) {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
|
||||
// Only set Bearer token if using SA JSON flow (raw key goes in URL ?key=)
|
||||
if (credentials.accessToken) {
|
||||
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
|
||||
}
|
||||
|
||||
if (stream) headers["Accept"] = "text/event-stream";
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
async refreshCredentials(credentials, log) {
|
||||
const saJson = parseVertexSaJson(credentials?.apiKey);
|
||||
if (!saJson) return null;
|
||||
|
||||
const result = await refreshVertexToken(saJson, log);
|
||||
if (!result) return null;
|
||||
|
||||
return { accessToken: result.accessToken, expiresAt: result.expiresAt };
|
||||
}
|
||||
|
||||
async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) {
|
||||
const saJson = parseVertexSaJson(credentials?.apiKey);
|
||||
|
||||
// SA JSON flow: mint Bearer token (cached)
|
||||
if (saJson) {
|
||||
const result = await refreshVertexToken(saJson, log);
|
||||
if (!result?.accessToken) throw new Error("Vertex: failed to mint access token from Service Account JSON");
|
||||
credentials.accessToken = result.accessToken;
|
||||
}
|
||||
|
||||
// vertex-partner with raw key: auto-resolve project_id if not provided
|
||||
if (this.provider === "vertex-partner" && !saJson && !credentials?.providerSpecificData?.projectId) {
|
||||
const projectId = await resolveProjectId(credentials.apiKey);
|
||||
if (!projectId) throw new Error("Vertex: could not resolve project_id from API key. Please add it manually in provider settings.");
|
||||
log?.debug?.("VERTEX", `Resolved project_id: ${projectId}`);
|
||||
credentials.providerSpecificData = { ...credentials.providerSpecificData, projectId };
|
||||
}
|
||||
|
||||
const url = this.buildUrl(model, stream, 0, credentials);
|
||||
const headers = this.buildHeaders(credentials, stream);
|
||||
const transformedBody = this.transformRequest(model, body, stream, credentials);
|
||||
|
||||
const response = await proxyAwareFetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(transformedBody),
|
||||
signal,
|
||||
}, proxyOptions);
|
||||
|
||||
return { response, url, headers, transformedBody };
|
||||
}
|
||||
}
|
||||
|
||||
export default VertexExecutor;
|
||||
@@ -1,213 +0,0 @@
|
||||
import { detectFormat, getTargetFormat } from "../services/provider.js";
|
||||
import { translateRequest } from "../translator/index.js";
|
||||
import { FORMATS } from "../translator/formats.js";
|
||||
import { COLORS } from "../utils/stream.js";
|
||||
import { createStreamController } from "../utils/streamHandler.js";
|
||||
import { refreshWithRetry } from "../services/tokenRefresh.js";
|
||||
import { createRequestLogger } from "../utils/requestLogger.js";
|
||||
import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js";
|
||||
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js";
|
||||
import { HTTP_STATUS } from "../config/runtimeConfig.js";
|
||||
import { handleBypassRequest } from "../utils/bypassHandler.js";
|
||||
import { trackPendingRequest, appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js";
|
||||
import { getExecutor } from "../executors/index.js";
|
||||
import { buildRequestDetail, extractRequestConfig } from "./chatCore/requestDetail.js";
|
||||
import { handleForcedSSEToJson } from "./chatCore/sseToJsonHandler.js";
|
||||
import { handleNonStreamingResponse } from "./chatCore/nonStreamingHandler.js";
|
||||
import { handleStreamingResponse, buildOnStreamComplete } from "./chatCore/streamingHandler.js";
|
||||
|
||||
/**
|
||||
* Core chat handler - shared between SSE and Worker
|
||||
* @param {object} options.body - Request body
|
||||
* @param {object} options.modelInfo - { provider, model }
|
||||
* @param {object} options.credentials - Provider credentials
|
||||
* @param {string} options.sourceFormatOverride - Override detected source format (e.g. "openai-responses")
|
||||
*/
|
||||
export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, sourceFormatOverride }) {
|
||||
const { provider, model } = modelInfo;
|
||||
const requestStartTime = Date.now();
|
||||
|
||||
const sourceFormat = sourceFormatOverride || detectFormat(body);
|
||||
|
||||
// Check for bypass patterns (warmup, skip, cc naming)
|
||||
const bypassResponse = handleBypassRequest(body, model, userAgent, ccFilterNaming);
|
||||
if (bypassResponse) return bypassResponse;
|
||||
|
||||
const alias = PROVIDER_ID_TO_ALIAS[provider] || provider;
|
||||
const modelTargetFormat = getModelTargetFormat(alias, model);
|
||||
const targetFormat = modelTargetFormat || getTargetFormat(provider);
|
||||
|
||||
const clientRequestedStreaming = body.stream === true || sourceFormat === FORMATS.ANTIGRAVITY || sourceFormat === FORMATS.GEMINI || sourceFormat === FORMATS.GEMINI_CLI;
|
||||
const providerRequiresStreaming = provider === "openai" || provider === "codex";
|
||||
let stream = providerRequiresStreaming ? true : (body.stream !== false);
|
||||
|
||||
// Check client Accept header preference for non-streaming requests
|
||||
// This fixes AI SDK compatibility where clients send Accept: application/json
|
||||
const acceptHeader = clientRawRequest?.headers?.accept || "";
|
||||
const clientPrefersJson = acceptHeader.includes("application/json");
|
||||
const clientPrefersSSE = acceptHeader.includes("text/event-stream");
|
||||
if (clientPrefersJson && !clientPrefersSSE && body.stream !== true) {
|
||||
stream = false;
|
||||
}
|
||||
|
||||
const reqLogger = await createRequestLogger(sourceFormat, targetFormat, model);
|
||||
if (clientRawRequest) reqLogger.logClientRawRequest(clientRawRequest.endpoint, clientRawRequest.body, clientRawRequest.headers);
|
||||
reqLogger.logRawRequest(body);
|
||||
log?.debug?.("FORMAT", `${sourceFormat} → ${targetFormat} | stream=${stream}`);
|
||||
|
||||
let translatedBody = translateRequest(sourceFormat, targetFormat, model, body, stream, credentials, provider, reqLogger);
|
||||
if (!translatedBody) {
|
||||
trackPendingRequest(model, provider, connectionId, false, true);
|
||||
return createErrorResult(HTTP_STATUS.BAD_REQUEST, `Failed to translate request for ${sourceFormat} → ${targetFormat}`);
|
||||
}
|
||||
const toolNameMap = translatedBody._toolNameMap;
|
||||
delete translatedBody._toolNameMap;
|
||||
translatedBody.model = model;
|
||||
|
||||
const executor = getExecutor(provider);
|
||||
trackPendingRequest(model, provider, connectionId, true);
|
||||
appendRequestLog({ model, provider, connectionId, status: "PENDING" }).catch(() => {});
|
||||
|
||||
const msgCount = translatedBody.messages?.length || translatedBody.input?.length || translatedBody.contents?.length || translatedBody.request?.contents?.length || 0;
|
||||
log?.debug?.("REQUEST", `${provider.toUpperCase()} | ${model} | ${msgCount} msgs`);
|
||||
|
||||
const streamController = createStreamController({
|
||||
onDisconnect: (reason) => {
|
||||
trackPendingRequest(model, provider, connectionId, false);
|
||||
if (onDisconnect) onDisconnect(reason);
|
||||
},
|
||||
onError: () => trackPendingRequest(model, provider, connectionId, false),
|
||||
log, provider, model
|
||||
});
|
||||
|
||||
const proxyOptions = {
|
||||
connectionProxyEnabled: credentials?.providerSpecificData?.connectionProxyEnabled === true,
|
||||
connectionProxyUrl: credentials?.providerSpecificData?.connectionProxyUrl || "",
|
||||
connectionNoProxy: credentials?.providerSpecificData?.connectionNoProxy || "",
|
||||
};
|
||||
|
||||
if (proxyOptions.connectionProxyEnabled && proxyOptions.connectionProxyUrl) {
|
||||
let maskedProxyUrl = proxyOptions.connectionProxyUrl;
|
||||
try {
|
||||
const parsed = new URL(proxyOptions.connectionProxyUrl);
|
||||
const host = parsed.hostname || "";
|
||||
const port = parsed.port ? `:${parsed.port}` : "";
|
||||
const protocol = parsed.protocol || "http:";
|
||||
maskedProxyUrl = `${protocol}//${host}${port}`;
|
||||
} catch {
|
||||
// Keep raw if URL parsing fails
|
||||
}
|
||||
|
||||
const poolId = credentials?.providerSpecificData?.connectionProxyPoolId || "none";
|
||||
const connectionName = credentials?.connectionName || credentials?.connectionId || "unknown";
|
||||
log?.info?.("PROXY", `${provider.toUpperCase()} | ${model} | conn=${connectionName} | pool=${poolId} | url=${maskedProxyUrl}`);
|
||||
}
|
||||
|
||||
if (proxyOptions.connectionProxyEnabled && proxyOptions.connectionNoProxy) {
|
||||
const connectionName = credentials?.connectionName || credentials?.connectionId || "unknown";
|
||||
log?.debug?.("PROXY", `${provider.toUpperCase()} | ${model} | conn=${connectionName} | no_proxy=${proxyOptions.connectionNoProxy}`);
|
||||
}
|
||||
|
||||
// Execute request
|
||||
let providerResponse, providerUrl, providerHeaders, finalBody;
|
||||
try {
|
||||
const result = await executor.execute({ model, body: translatedBody, stream, credentials, signal: streamController.signal, log, proxyOptions });
|
||||
providerResponse = result.response;
|
||||
providerUrl = result.url;
|
||||
providerHeaders = result.headers;
|
||||
finalBody = result.transformedBody;
|
||||
reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
|
||||
} catch (error) {
|
||||
trackPendingRequest(model, provider, connectionId, false, true);
|
||||
appendRequestLog({ model, provider, connectionId, status: `FAILED ${error.name === "AbortError" ? 499 : HTTP_STATUS.BAD_GATEWAY}` }).catch(() => {});
|
||||
saveRequestDetail(buildRequestDetail({
|
||||
provider, model, connectionId,
|
||||
latency: { ttft: 0, total: Date.now() - requestStartTime },
|
||||
tokens: { prompt_tokens: 0, completion_tokens: 0 },
|
||||
request: extractRequestConfig(body, stream),
|
||||
providerRequest: translatedBody || null,
|
||||
response: { error: error.message || String(error), status: error.name === "AbortError" ? 499 : 502, thinking: null },
|
||||
status: "error"
|
||||
})).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
|
||||
if (providerResponse.status === HTTP_STATUS.UNAUTHORIZED || providerResponse.status === HTTP_STATUS.FORBIDDEN) {
|
||||
try {
|
||||
const newCredentials = await refreshWithRetry(() => executor.refreshCredentials(credentials, log), 3, log);
|
||||
if (newCredentials?.accessToken || newCredentials?.copilotToken) {
|
||||
log?.info?.("TOKEN", `${provider.toUpperCase()} | refreshed`);
|
||||
Object.assign(credentials, newCredentials);
|
||||
if (onCredentialsRefreshed) {
|
||||
try { await onCredentialsRefreshed(newCredentials); } catch (e) { log?.warn?.("TOKEN", `onCredentialsRefreshed failed: ${e.message}`); }
|
||||
}
|
||||
try {
|
||||
const retryResult = await executor.execute({ model, body: translatedBody, stream, credentials, signal: streamController.signal, log, proxyOptions });
|
||||
if (retryResult.response.ok) { providerResponse = retryResult.response; providerUrl = retryResult.url; }
|
||||
} catch { log?.warn?.("TOKEN", `${provider.toUpperCase()} | retry after refresh failed`); }
|
||||
} else {
|
||||
log?.warn?.("TOKEN", `${provider.toUpperCase()} | refresh failed`);
|
||||
}
|
||||
} catch (e) {
|
||||
log?.warn?.("TOKEN", `${provider.toUpperCase()} | refresh threw: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Provider returned error
|
||||
if (!providerResponse.ok) {
|
||||
trackPendingRequest(model, provider, connectionId, false, true);
|
||||
const { statusCode, message, retryAfterMs } = await parseUpstreamError(providerResponse, provider);
|
||||
appendRequestLog({ model, provider, connectionId, status: `FAILED ${statusCode}` }).catch(() => {});
|
||||
saveRequestDetail(buildRequestDetail({
|
||||
provider, model, connectionId,
|
||||
latency: { ttft: 0, total: Date.now() - requestStartTime },
|
||||
tokens: { prompt_tokens: 0, completion_tokens: 0 },
|
||||
request: extractRequestConfig(body, stream),
|
||||
providerRequest: finalBody || translatedBody || null,
|
||||
response: { error: message, status: statusCode, thinking: null },
|
||||
status: "error"
|
||||
})).catch(() => {});
|
||||
|
||||
const errMsg = formatProviderError(new Error(message), provider, model, statusCode);
|
||||
console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`);
|
||||
if (retryAfterMs && provider === "antigravity") {
|
||||
log?.debug?.("RETRY", `Antigravity quota reset in ${Math.ceil(retryAfterMs / 1000)}s`);
|
||||
}
|
||||
reqLogger.logError(new Error(message), finalBody || translatedBody);
|
||||
return createErrorResult(statusCode, errMsg, retryAfterMs);
|
||||
}
|
||||
|
||||
const sharedCtx = { provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess };
|
||||
const appendLog = (extra) => appendRequestLog({ model, provider, connectionId, ...extra }).catch(() => {});
|
||||
const trackDone = () => trackPendingRequest(model, provider, connectionId, false);
|
||||
|
||||
// Provider forced streaming but client wants JSON
|
||||
if (!clientRequestedStreaming && providerRequiresStreaming) {
|
||||
const result = await handleForcedSSEToJson({ ...sharedCtx, providerResponse, sourceFormat, trackDone, appendLog });
|
||||
if (result) { streamController.handleComplete(); return result; }
|
||||
}
|
||||
|
||||
// True non-streaming response
|
||||
if (!stream) {
|
||||
const result = await handleNonStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat, reqLogger, trackDone, appendLog });
|
||||
streamController.handleComplete();
|
||||
return result;
|
||||
}
|
||||
|
||||
// Streaming response
|
||||
const { onStreamComplete } = buildOnStreamComplete({ ...sharedCtx });
|
||||
return handleStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, streamController, onStreamComplete });
|
||||
}
|
||||
|
||||
export function isTokenExpiringSoon(expiresAt, bufferMs = 5 * 60 * 1000) {
|
||||
if (!expiresAt) return false;
|
||||
return new Date(expiresAt).getTime() - Date.now() < bufferMs;
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
import { FORMATS } from "../../translator/formats.js";
|
||||
import { needsTranslation } from "../../translator/index.js";
|
||||
import { ollamaBodyToOpenAI } from "../../translator/response/ollama-to-openai.js";
|
||||
import { addBufferToUsage, filterUsageForFormat } from "../../utils/usageTracking.js";
|
||||
import { createErrorResult } from "../../utils/error.js";
|
||||
import { HTTP_STATUS } from "../../config/runtimeConfig.js";
|
||||
import { parseSSEToOpenAIResponse } from "./sseToJsonHandler.js";
|
||||
import { buildRequestDetail, extractRequestConfig, extractUsageFromResponse, saveUsageStats } from "./requestDetail.js";
|
||||
import { appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js";
|
||||
|
||||
/**
|
||||
* Translate non-streaming response body from provider format → OpenAI format.
|
||||
*/
|
||||
export function translateNonStreamingResponse(responseBody, targetFormat, sourceFormat) {
|
||||
if (targetFormat === sourceFormat || targetFormat === FORMATS.OPENAI) return responseBody;
|
||||
|
||||
// Gemini / Antigravity
|
||||
if (targetFormat === FORMATS.GEMINI || targetFormat === FORMATS.ANTIGRAVITY || targetFormat === FORMATS.GEMINI_CLI) {
|
||||
const response = responseBody.response || responseBody;
|
||||
if (!response?.candidates?.[0]) return responseBody;
|
||||
|
||||
const candidate = response.candidates[0];
|
||||
const content = candidate.content;
|
||||
const usage = response.usageMetadata || responseBody.usageMetadata;
|
||||
let textContent = "", reasoningContent = "";
|
||||
const toolCalls = [];
|
||||
|
||||
if (content?.parts) {
|
||||
for (const part of content.parts) {
|
||||
if (part.thought === true && part.text) reasoningContent += part.text;
|
||||
else if (part.text !== undefined) textContent += part.text;
|
||||
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 || {}) }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 = "";
|
||||
|
||||
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 }]
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Claude
|
||||
if (targetFormat === FORMATS.CLAUDE) {
|
||||
if (!responseBody.content) return responseBody;
|
||||
|
||||
let textContent = "", thinkingContent = "";
|
||||
const toolCalls = [];
|
||||
|
||||
for (const block of responseBody.content) {
|
||||
if (block.type === "text") {
|
||||
// Strip markdown code block markers (e.g. kimi wraps JSON in ```json...```)
|
||||
const raw = block.text ?? "";
|
||||
const text = raw.replace(/^\s*```\s*json\s*\n?/i, "").replace(/\n?\s*```\s*$/i, "");
|
||||
textContent += 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;
|
||||
}
|
||||
|
||||
// Ollama
|
||||
if (targetFormat === FORMATS.OLLAMA) {
|
||||
return ollamaBodyToOpenAI(responseBody);
|
||||
}
|
||||
|
||||
return responseBody;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle non-streaming response from provider.
|
||||
*/
|
||||
export async function handleNonStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, trackDone, appendLog }) {
|
||||
trackDone();
|
||||
const contentType = providerResponse.headers.get("content-type") || "";
|
||||
let responseBody;
|
||||
|
||||
if (contentType.includes("text/event-stream")) {
|
||||
const sseText = await providerResponse.text();
|
||||
const parsed = parseSSEToOpenAIResponse(sseText, model);
|
||||
if (!parsed) {
|
||||
appendLog({ status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}` });
|
||||
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Invalid SSE response for non-streaming request");
|
||||
}
|
||||
responseBody = parsed;
|
||||
} else {
|
||||
try {
|
||||
responseBody = await providerResponse.json();
|
||||
} catch (err) {
|
||||
appendLog({ status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}` });
|
||||
console.error(`[ChatCore] Failed to parse JSON from ${provider}:`, err.message);
|
||||
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, `Invalid JSON response from ${provider}`);
|
||||
}
|
||||
}
|
||||
|
||||
reqLogger.logProviderResponse(providerResponse.status, providerResponse.statusText, providerResponse.headers, responseBody);
|
||||
if (onRequestSuccess) await onRequestSuccess();
|
||||
|
||||
const usage = extractUsageFromResponse(responseBody);
|
||||
appendLog({ tokens: usage, status: "200 OK" });
|
||||
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint });
|
||||
|
||||
const translatedResponse = needsTranslation(targetFormat, sourceFormat)
|
||||
? translateNonStreamingResponse(responseBody, targetFormat, sourceFormat)
|
||||
: responseBody;
|
||||
|
||||
// Ensure OpenAI-required fields
|
||||
if (!translatedResponse.object) translatedResponse.object = "chat.completion";
|
||||
if (!translatedResponse.created) translatedResponse.created = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Strip Azure-specific fields
|
||||
delete translatedResponse.prompt_filter_results;
|
||||
if (translatedResponse?.choices) {
|
||||
for (const choice of translatedResponse.choices) delete choice.content_filter_results;
|
||||
}
|
||||
|
||||
if (translatedResponse?.usage) {
|
||||
translatedResponse.usage = filterUsageForFormat(addBufferToUsage(translatedResponse.usage), sourceFormat);
|
||||
}
|
||||
|
||||
reqLogger.logConvertedResponse(translatedResponse);
|
||||
|
||||
const totalLatency = Date.now() - requestStartTime;
|
||||
saveRequestDetail(buildRequestDetail({
|
||||
provider, model, connectionId,
|
||||
latency: { ttft: totalLatency, total: totalLatency },
|
||||
tokens: usage || { prompt_tokens: 0, completion_tokens: 0 },
|
||||
request: extractRequestConfig(body, stream),
|
||||
providerRequest: finalBody || translatedBody || null,
|
||||
providerResponse: responseBody || null,
|
||||
response: {
|
||||
content: translatedResponse?.choices?.[0]?.message?.content || translatedResponse?.content || null,
|
||||
thinking: translatedResponse?.choices?.[0]?.message?.reasoning_content || translatedResponse?.reasoning_content || null,
|
||||
finish_reason: translatedResponse?.choices?.[0]?.finish_reason || "unknown"
|
||||
},
|
||||
status: "success"
|
||||
}, { endpoint: clientRawRequest?.endpoint || null })).catch(err => {
|
||||
console.error("[RequestDetail] Failed to save:", err.message);
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
response: new Response(JSON.stringify(translatedResponse), {
|
||||
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }
|
||||
})
|
||||
};
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { saveRequestUsage, appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js";
|
||||
import { COLORS } from "../../utils/stream.js";
|
||||
|
||||
const OPTIONAL_PARAMS = [
|
||||
"temperature", "top_p", "top_k",
|
||||
"max_tokens", "max_completion_tokens",
|
||||
"thinking", "reasoning", "enable_thinking",
|
||||
"presence_penalty", "frequency_penalty",
|
||||
"seed", "stop", "tools", "tool_choice",
|
||||
"response_format", "prediction", "store", "metadata",
|
||||
"n", "logprobs", "top_logprobs", "logit_bias",
|
||||
"user", "parallel_tool_calls"
|
||||
];
|
||||
|
||||
export function extractRequestConfig(body, stream) {
|
||||
const config = { messages: body.messages || [], model: body.model, stream };
|
||||
for (const param of OPTIONAL_PARAMS) {
|
||||
if (body[param] !== undefined) config[param] = body[param];
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
export function extractUsageFromResponse(responseBody) {
|
||||
if (!responseBody || typeof responseBody !== "object") return null;
|
||||
|
||||
// Claude format
|
||||
if (responseBody.usage?.input_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
|
||||
};
|
||||
}
|
||||
|
||||
// OpenAI format
|
||||
if (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
|
||||
};
|
||||
}
|
||||
|
||||
// Gemini format
|
||||
if (responseBody.usageMetadata) {
|
||||
return {
|
||||
prompt_tokens: responseBody.usageMetadata.promptTokenCount || 0,
|
||||
completion_tokens: responseBody.usageMetadata.candidatesTokenCount || 0,
|
||||
reasoning_tokens: responseBody.usageMetadata.thoughtsTokenCount
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildRequestDetail(base, overrides = {}) {
|
||||
return {
|
||||
provider: base.provider || "unknown",
|
||||
model: base.model || "unknown",
|
||||
connectionId: base.connectionId || undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
latency: base.latency || { ttft: 0, total: 0 },
|
||||
tokens: base.tokens || { prompt_tokens: 0, completion_tokens: 0 },
|
||||
request: base.request,
|
||||
providerRequest: base.providerRequest || null,
|
||||
providerResponse: base.providerResponse || null,
|
||||
response: base.response || {},
|
||||
status: base.status || "success",
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
export function saveUsageStats({ provider, model, tokens, connectionId, apiKey, endpoint, label = "USAGE" }) {
|
||||
if (!tokens || typeof tokens !== "object") return;
|
||||
|
||||
const inTokens = tokens.input_tokens ?? tokens.prompt_tokens ?? 0;
|
||||
const outTokens = tokens.output_tokens ?? tokens.completion_tokens ?? 0;
|
||||
|
||||
if (inTokens === 0 && outTokens === 0) return;
|
||||
|
||||
const time = new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
const accountSuffix = connectionId ? ` | account=${connectionId.slice(0, 8)}...` : "";
|
||||
console.log(`${COLORS.green}[${time}] 📊 [${label}] ${provider.toUpperCase()} | in=${inTokens} | out=${outTokens}${accountSuffix}${COLORS.reset}`);
|
||||
|
||||
// Normalize to OpenAI token shape for storage
|
||||
const normalized = {
|
||||
prompt_tokens: tokens.prompt_tokens ?? tokens.input_tokens ?? 0,
|
||||
completion_tokens: tokens.completion_tokens ?? tokens.output_tokens ?? 0
|
||||
};
|
||||
|
||||
saveRequestUsage({
|
||||
provider: provider || "unknown",
|
||||
model: model || "unknown",
|
||||
tokens: normalized,
|
||||
timestamp: new Date().toISOString(),
|
||||
connectionId: connectionId || undefined,
|
||||
apiKey: apiKey || undefined,
|
||||
endpoint: endpoint || null
|
||||
}).catch(() => {});
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import { convertResponsesStreamToJson } from "../../transformer/streamToJsonConverter.js";
|
||||
import { createErrorResult } from "../../utils/error.js";
|
||||
import { HTTP_STATUS } from "../../config/runtimeConfig.js";
|
||||
import { FORMATS } from "../../translator/formats.js";
|
||||
import { buildRequestDetail, extractRequestConfig, saveUsageStats } from "./requestDetail.js";
|
||||
import { saveRequestDetail, appendRequestLog } from "@/lib/usageDb.js";
|
||||
|
||||
/**
|
||||
* Parse OpenAI-style SSE text into a single chat completion JSON.
|
||||
* Used when provider forces streaming but client wants non-streaming.
|
||||
*/
|
||||
export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) {
|
||||
const chunks = [];
|
||||
|
||||
for (const line of String(rawSSE || "").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith("data:")) continue;
|
||||
const payload = trimmed.slice(5).trim();
|
||||
if (!payload || payload === "[DONE]") continue;
|
||||
try { chunks.push(JSON.parse(payload)); } catch { /* ignore malformed lines */ }
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle case: provider forced streaming but client wants JSON.
|
||||
* Supports both Codex/Responses API SSE and standard Chat Completions SSE.
|
||||
*/
|
||||
export async function handleForcedSSEToJson({ providerResponse, sourceFormat, provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, trackDone, appendLog }) {
|
||||
const contentType = providerResponse.headers.get("content-type") || "";
|
||||
const isSSE = contentType.includes("text/event-stream") || (contentType === "" && provider === "codex");
|
||||
if (!isSSE) return null; // not handled here
|
||||
|
||||
trackDone();
|
||||
|
||||
const ctx = {
|
||||
provider, model, connectionId,
|
||||
request: extractRequestConfig(body, stream),
|
||||
providerRequest: finalBody || translatedBody || null
|
||||
};
|
||||
|
||||
// Codex/Responses API SSE path
|
||||
const isCodexResponsesApi = provider === "codex" || sourceFormat === FORMATS.OPENAI_RESPONSES;
|
||||
if (isCodexResponsesApi) {
|
||||
try {
|
||||
const jsonResponse = await convertResponsesStreamToJson(providerResponse.body);
|
||||
if (onRequestSuccess) await onRequestSuccess();
|
||||
|
||||
const usage = jsonResponse.usage || {};
|
||||
appendLog({ tokens: usage, status: "200 OK" });
|
||||
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint });
|
||||
|
||||
const msgItem = jsonResponse.output?.find(item => item.type === "message");
|
||||
const textContent = msgItem?.content?.find(c => c.type === "output_text")?.text || msgItem?.content?.[0]?.text || null;
|
||||
const totalLatency = Date.now() - requestStartTime;
|
||||
|
||||
saveRequestDetail(buildRequestDetail({
|
||||
...ctx,
|
||||
latency: { ttft: totalLatency, total: totalLatency },
|
||||
tokens: { prompt_tokens: usage.input_tokens || 0, completion_tokens: usage.output_tokens || 0 },
|
||||
response: { content: textContent, thinking: null, finish_reason: jsonResponse.status || "unknown" },
|
||||
status: "success"
|
||||
}, { endpoint: clientRawRequest?.endpoint || null })).catch(() => {});
|
||||
|
||||
// Client is Responses API → return as-is
|
||||
if (sourceFormat === FORMATS.OPENAI_RESPONSES) {
|
||||
return { success: true, response: new Response(JSON.stringify(jsonResponse), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) };
|
||||
}
|
||||
|
||||
// Build client-format response
|
||||
const inTokens = usage.input_tokens || 0;
|
||||
const outTokens = usage.output_tokens || 0;
|
||||
let finalResp;
|
||||
|
||||
if (sourceFormat === FORMATS.ANTIGRAVITY || sourceFormat === FORMATS.GEMINI || sourceFormat === FORMATS.GEMINI_CLI) {
|
||||
finalResp = {
|
||||
response: {
|
||||
candidates: [{ content: { role: "model", parts: [{ text: textContent || "" }] }, finishReason: "STOP", index: 0 }],
|
||||
usageMetadata: { promptTokenCount: inTokens, candidatesTokenCount: outTokens, totalTokenCount: inTokens + outTokens },
|
||||
modelVersion: model,
|
||||
responseId: jsonResponse.id || `resp_${Date.now()}`
|
||||
}
|
||||
};
|
||||
} else {
|
||||
finalResp = {
|
||||
id: jsonResponse.id || `chatcmpl-${Date.now()}`,
|
||||
object: "chat.completion",
|
||||
created: jsonResponse.created_at || Math.floor(Date.now() / 1000),
|
||||
model: jsonResponse.model || model,
|
||||
choices: [{ index: 0, message: { role: "assistant", content: textContent || "" }, finish_reason: jsonResponse.status === "completed" ? "stop" : (jsonResponse.status || "stop") }],
|
||||
usage: { prompt_tokens: inTokens, completion_tokens: outTokens, total_tokens: inTokens + outTokens }
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, response: new Response(JSON.stringify(finalResp), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) };
|
||||
} catch (err) {
|
||||
console.error("[ChatCore] Responses API SSE→JSON failed:", err);
|
||||
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Failed to convert streaming response to JSON");
|
||||
}
|
||||
}
|
||||
|
||||
// Standard Chat Completions SSE path
|
||||
try {
|
||||
const sseText = await providerResponse.text();
|
||||
const parsed = parseSSEToOpenAIResponse(sseText, model);
|
||||
if (!parsed) return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Invalid SSE response for non-streaming request");
|
||||
|
||||
if (onRequestSuccess) await onRequestSuccess();
|
||||
|
||||
const usage = parsed.usage || {};
|
||||
appendLog({ tokens: usage, status: "200 OK" });
|
||||
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint });
|
||||
|
||||
const totalLatency = Date.now() - requestStartTime;
|
||||
saveRequestDetail(buildRequestDetail({
|
||||
...ctx,
|
||||
latency: { ttft: totalLatency, total: totalLatency },
|
||||
tokens: usage,
|
||||
response: {
|
||||
content: parsed.choices?.[0]?.message?.content || null,
|
||||
thinking: parsed.choices?.[0]?.message?.reasoning_content || null,
|
||||
finish_reason: parsed.choices?.[0]?.finish_reason || "unknown"
|
||||
},
|
||||
status: "success"
|
||||
}, { endpoint: clientRawRequest?.endpoint || null })).catch(() => {});
|
||||
|
||||
return { success: true, response: new Response(JSON.stringify(parsed), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) };
|
||||
} catch (err) {
|
||||
console.error("[ChatCore] Chat Completions SSE→JSON failed:", err);
|
||||
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Failed to convert streaming response to JSON");
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { FORMATS } from "../../translator/formats.js";
|
||||
import { needsTranslation } from "../../translator/index.js";
|
||||
import { createSSETransformStreamWithLogger, createPassthroughStreamWithLogger } from "../../utils/stream.js";
|
||||
import { pipeWithDisconnect } from "../../utils/streamHandler.js";
|
||||
import { buildRequestDetail, extractRequestConfig, saveUsageStats } from "./requestDetail.js";
|
||||
import { saveRequestDetail } from "@/lib/usageDb.js";
|
||||
|
||||
const SSE_HEADERS = {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"Access-Control-Allow-Origin": "*"
|
||||
};
|
||||
|
||||
/**
|
||||
* Determine which SSE transform stream to use based on provider/format.
|
||||
*/
|
||||
function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey }) {
|
||||
const isDroidCLI = userAgent?.toLowerCase().includes("droid") || userAgent?.toLowerCase().includes("codex-cli");
|
||||
const needsCodexTranslation = provider === "codex" && targetFormat === FORMATS.OPENAI_RESPONSES && !isDroidCLI;
|
||||
|
||||
if (needsCodexTranslation) {
|
||||
// Codex returns Responses API SSE → translate to client format
|
||||
let codexTarget;
|
||||
if (sourceFormat === FORMATS.OPENAI_RESPONSES) codexTarget = FORMATS.OPENAI_RESPONSES;
|
||||
else if (sourceFormat === FORMATS.CLAUDE) codexTarget = FORMATS.CLAUDE;
|
||||
else if (sourceFormat === FORMATS.ANTIGRAVITY || sourceFormat === FORMATS.GEMINI || sourceFormat === FORMATS.GEMINI_CLI) codexTarget = FORMATS.ANTIGRAVITY;
|
||||
else codexTarget = FORMATS.OPENAI;
|
||||
return createSSETransformStreamWithLogger(FORMATS.OPENAI_RESPONSES, codexTarget, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey);
|
||||
}
|
||||
|
||||
if (needsTranslation(targetFormat, sourceFormat)) {
|
||||
return createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey);
|
||||
}
|
||||
|
||||
return createPassthroughStreamWithLogger(provider, reqLogger, model, connectionId, body, onStreamComplete, apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle streaming response — pipe provider SSE through transform stream to client.
|
||||
*/
|
||||
export function handleStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, userAgent, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, streamController, onStreamComplete }) {
|
||||
if (onRequestSuccess) onRequestSuccess();
|
||||
|
||||
const transformStream = buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey });
|
||||
const transformedBody = pipeWithDisconnect(providerResponse, transformStream, streamController);
|
||||
|
||||
const streamDetailId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
saveRequestDetail(buildRequestDetail({
|
||||
provider, model, connectionId,
|
||||
latency: { ttft: 0, total: Date.now() - requestStartTime },
|
||||
tokens: { prompt_tokens: 0, completion_tokens: 0 },
|
||||
request: extractRequestConfig(body, stream),
|
||||
providerRequest: finalBody || translatedBody || null,
|
||||
providerResponse: "[Streaming - raw response not captured]",
|
||||
response: { content: "[Streaming in progress...]", thinking: null, type: "streaming" },
|
||||
status: "success"
|
||||
}, { id: streamDetailId })).catch(err => {
|
||||
console.error("[RequestDetail] Failed to save streaming request:", err.message);
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
response: new Response(transformedBody, { headers: SSE_HEADERS })
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build onStreamComplete callback for streaming usage tracking.
|
||||
*/
|
||||
export function buildOnStreamComplete({ provider, model, connectionId, apiKey, requestStartTime, body, stream, finalBody, translatedBody, clientRawRequest }) {
|
||||
const streamDetailId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
|
||||
const onStreamComplete = (contentObj, usage, ttftAt) => {
|
||||
const latency = {
|
||||
ttft: ttftAt ? ttftAt - requestStartTime : Date.now() - requestStartTime,
|
||||
total: Date.now() - requestStartTime
|
||||
};
|
||||
const safeContent = contentObj?.content || "[Empty streaming response]";
|
||||
const safeThinking = contentObj?.thinking || null;
|
||||
|
||||
saveRequestDetail(buildRequestDetail({
|
||||
provider, model, connectionId,
|
||||
latency,
|
||||
tokens: usage || { prompt_tokens: 0, completion_tokens: 0 },
|
||||
request: extractRequestConfig(body, stream),
|
||||
providerRequest: finalBody || translatedBody || null,
|
||||
providerResponse: safeContent,
|
||||
response: { content: safeContent, thinking: safeThinking, type: "streaming" },
|
||||
status: "success"
|
||||
}, { id: streamDetailId })).catch(err => {
|
||||
console.error("[RequestDetail] Failed to update streaming content:", err.message);
|
||||
});
|
||||
|
||||
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, label: "STREAM USAGE" });
|
||||
};
|
||||
|
||||
return { onStreamComplete, streamDetailId };
|
||||
}
|
||||
@@ -1,302 +0,0 @@
|
||||
import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js";
|
||||
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js";
|
||||
import { HTTP_STATUS } from "../config/runtimeConfig.js";
|
||||
import { getExecutor } from "../executors/index.js";
|
||||
import { refreshWithRetry } from "../services/tokenRefresh.js";
|
||||
|
||||
// Google AI (Gemini) provider aliases / identifiers
|
||||
const GEMINI_PROVIDERS = new Set(["gemini", "google_ai_studio"]);
|
||||
|
||||
/**
|
||||
* Check whether a provider targets the Google AI (Gemini) embeddings API.
|
||||
* @param {string} provider
|
||||
*/
|
||||
function isGeminiProvider(provider) {
|
||||
return GEMINI_PROVIDERS.has(provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the embeddings request body for the target provider.
|
||||
*
|
||||
* - OpenAI / openai-compatible / openrouter: standard { model, input } format.
|
||||
* - Google AI (Gemini): different format per API spec.
|
||||
* - Single input → embedContent body: { model, content: { parts: [{ text }] } }
|
||||
* - Batch input → batchEmbedContents body: { requests: [{ model, content: { parts: [{ text }] } }] }
|
||||
*/
|
||||
function buildEmbeddingsBody(provider, model, input, encodingFormat) {
|
||||
if (isGeminiProvider(provider)) {
|
||||
// Normalize model name: Gemini API expects "models/<model>" prefix
|
||||
const geminiModel = model.startsWith("models/") ? model : `models/${model}`;
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
// Batch request
|
||||
return {
|
||||
requests: input.map((text) => ({
|
||||
model: geminiModel,
|
||||
content: { parts: [{ text: String(text) }] }
|
||||
}))
|
||||
};
|
||||
} else {
|
||||
// Single request
|
||||
return {
|
||||
model: geminiModel,
|
||||
content: { parts: [{ text: String(input) }] }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Default: OpenAI format
|
||||
const body = { model, input };
|
||||
if (encodingFormat) {
|
||||
body.encoding_format = encodingFormat;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the URL for the embeddings endpoint based on the provider.
|
||||
* @param {string} provider
|
||||
* @param {string} model
|
||||
* @param {object} credentials
|
||||
* @param {string|string[]} input - used to select single vs batch endpoint for Gemini
|
||||
*/
|
||||
function buildEmbeddingsUrl(provider, model, credentials, input) {
|
||||
if (isGeminiProvider(provider)) {
|
||||
const apiKey = credentials.apiKey || credentials.accessToken;
|
||||
// Normalize model name for URL path
|
||||
const modelPath = model.startsWith("models/") ? model : `models/${model}`;
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
// batchEmbedContents for array input (keeps response format consistent even for length=1)
|
||||
return `https://generativelanguage.googleapis.com/v1beta/${modelPath}:batchEmbedContents?key=${encodeURIComponent(apiKey)}`;
|
||||
}
|
||||
return `https://generativelanguage.googleapis.com/v1beta/${modelPath}:embedContent?key=${encodeURIComponent(apiKey)}`;
|
||||
}
|
||||
|
||||
switch (provider) {
|
||||
case "openai":
|
||||
return "https://api.openai.com/v1/embeddings";
|
||||
case "openrouter":
|
||||
return "https://openrouter.ai/api/v1/embeddings";
|
||||
default:
|
||||
// openai-compatible providers: use their baseUrl + /embeddings
|
||||
if (provider?.startsWith?.("openai-compatible-")) {
|
||||
const baseUrl = credentials?.providerSpecificData?.baseUrl || "https://api.openai.com/v1";
|
||||
return `${baseUrl.replace(/\/$/, "")}/embeddings`;
|
||||
}
|
||||
// For other providers, attempt to use their base URL pattern with /embeddings path
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build headers for the embeddings request.
|
||||
*/
|
||||
function buildEmbeddingsHeaders(provider, credentials) {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
|
||||
if (isGeminiProvider(provider)) {
|
||||
// Gemini API uses API key as query param — no Authorization header needed
|
||||
return headers;
|
||||
}
|
||||
|
||||
switch (provider) {
|
||||
case "openai":
|
||||
case "openrouter":
|
||||
headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`;
|
||||
if (provider === "openrouter") {
|
||||
headers["HTTP-Referer"] = "https://endpoint-proxy.local";
|
||||
headers["X-Title"] = "Endpoint Proxy";
|
||||
}
|
||||
break;
|
||||
default:
|
||||
headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the embeddings response to OpenAI format.
|
||||
*
|
||||
* Gemini single response:
|
||||
* { embedding: { values: [0.1, 0.2, ...] } }
|
||||
*
|
||||
* Gemini batch response:
|
||||
* { embeddings: [{ values: [...] }, ...] }
|
||||
*
|
||||
* Target OpenAI format:
|
||||
* { object: "list", data: [{ object: "embedding", index: 0, embedding: [...] }], model, usage: {...} }
|
||||
*/
|
||||
function normalizeEmbeddingsResponse(responseBody, model, provider) {
|
||||
// Already in OpenAI format
|
||||
if (responseBody.object === "list" && Array.isArray(responseBody.data)) {
|
||||
return responseBody;
|
||||
}
|
||||
|
||||
if (isGeminiProvider(provider)) {
|
||||
let embeddingItems = [];
|
||||
|
||||
if (Array.isArray(responseBody.embeddings)) {
|
||||
// Batch response
|
||||
embeddingItems = responseBody.embeddings.map((emb, idx) => ({
|
||||
object: "embedding",
|
||||
index: idx,
|
||||
embedding: emb.values || []
|
||||
}));
|
||||
} else if (responseBody.embedding?.values) {
|
||||
// Single response
|
||||
embeddingItems = [{
|
||||
object: "embedding",
|
||||
index: 0,
|
||||
embedding: responseBody.embedding.values
|
||||
}];
|
||||
}
|
||||
|
||||
return {
|
||||
object: "list",
|
||||
data: embeddingItems,
|
||||
model,
|
||||
usage: {
|
||||
prompt_tokens: 0,
|
||||
total_tokens: 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Try to handle alternate formats gracefully
|
||||
return responseBody;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core embeddings handler — shared between Worker and SSE server.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {object} options.body - Parsed request body { model, input, encoding_format }
|
||||
* @param {object} options.modelInfo - { provider, model }
|
||||
* @param {object} options.credentials - Provider credentials
|
||||
* @param {object} [options.log] - Logger
|
||||
* @param {function} [options.onCredentialsRefreshed] - Called when creds are refreshed
|
||||
* @param {function} [options.onRequestSuccess] - Called on success (clear error state)
|
||||
* @returns {Promise<{ success: boolean, response: Response, status?: number, error?: string }>}
|
||||
*/
|
||||
export async function handleEmbeddingsCore({
|
||||
body,
|
||||
modelInfo,
|
||||
credentials,
|
||||
log,
|
||||
onCredentialsRefreshed,
|
||||
onRequestSuccess
|
||||
}) {
|
||||
const { provider, model } = modelInfo;
|
||||
|
||||
// Validate input
|
||||
const input = body.input;
|
||||
if (!input) {
|
||||
return createErrorResult(HTTP_STATUS.BAD_REQUEST, "Missing required field: input");
|
||||
}
|
||||
if (typeof input !== "string" && !Array.isArray(input)) {
|
||||
return createErrorResult(HTTP_STATUS.BAD_REQUEST, "input must be a string or array of strings");
|
||||
}
|
||||
|
||||
const encodingFormat = body.encoding_format || "float";
|
||||
|
||||
// Determine embeddings URL
|
||||
const url = buildEmbeddingsUrl(provider, model, credentials, input);
|
||||
if (!url) {
|
||||
return createErrorResult(
|
||||
HTTP_STATUS.BAD_REQUEST,
|
||||
`Provider '${provider}' does not support embeddings. Use openai, openrouter, gemini, or an openai-compatible provider.`
|
||||
);
|
||||
}
|
||||
|
||||
const headers = buildEmbeddingsHeaders(provider, credentials);
|
||||
const requestBody = buildEmbeddingsBody(provider, model, input, encodingFormat);
|
||||
|
||||
log?.debug?.("EMBEDDINGS", `${provider.toUpperCase()} | ${model} | input_type=${Array.isArray(input) ? `array[${input.length}]` : "string"}`);
|
||||
|
||||
let providerResponse;
|
||||
try {
|
||||
providerResponse = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
} catch (error) {
|
||||
const errMsg = formatProviderError(error, provider, model, HTTP_STATUS.BAD_GATEWAY);
|
||||
log?.debug?.("EMBEDDINGS", `Fetch error: ${errMsg}`);
|
||||
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, errMsg);
|
||||
}
|
||||
|
||||
// Handle 401/403 — try token refresh
|
||||
if (
|
||||
providerResponse.status === HTTP_STATUS.UNAUTHORIZED ||
|
||||
providerResponse.status === HTTP_STATUS.FORBIDDEN
|
||||
) {
|
||||
const executor = getExecutor(provider);
|
||||
const newCredentials = await refreshWithRetry(
|
||||
() => executor.refreshCredentials(credentials, log),
|
||||
3,
|
||||
log
|
||||
);
|
||||
|
||||
if (newCredentials?.accessToken || newCredentials?.apiKey) {
|
||||
log?.info?.("TOKEN", `${provider.toUpperCase()} | refreshed for embeddings`);
|
||||
Object.assign(credentials, newCredentials);
|
||||
if (onCredentialsRefreshed && newCredentials) {
|
||||
await onCredentialsRefreshed(newCredentials);
|
||||
}
|
||||
|
||||
// Retry with refreshed credentials
|
||||
try {
|
||||
const retryHeaders = buildEmbeddingsHeaders(provider, credentials);
|
||||
// Rebuild URL for Gemini since API key is embedded in query param
|
||||
const retryUrl = isGeminiProvider(provider)
|
||||
? buildEmbeddingsUrl(provider, model, credentials, input)
|
||||
: url;
|
||||
|
||||
providerResponse = await fetch(retryUrl, {
|
||||
method: "POST",
|
||||
headers: retryHeaders,
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
} catch (retryError) {
|
||||
log?.warn?.("TOKEN", `${provider.toUpperCase()} | retry after refresh failed`);
|
||||
}
|
||||
} else {
|
||||
log?.warn?.("TOKEN", `${provider.toUpperCase()} | refresh failed`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!providerResponse.ok) {
|
||||
const { statusCode, message } = await parseUpstreamError(providerResponse, provider);
|
||||
const errMsg = formatProviderError(new Error(message), provider, model, statusCode);
|
||||
log?.debug?.("EMBEDDINGS", `Provider error: ${errMsg}`);
|
||||
return createErrorResult(statusCode, errMsg);
|
||||
}
|
||||
|
||||
let responseBody;
|
||||
try {
|
||||
responseBody = await providerResponse.json();
|
||||
} catch (parseError) {
|
||||
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, `Invalid JSON response from ${provider}`);
|
||||
}
|
||||
|
||||
if (onRequestSuccess) {
|
||||
await onRequestSuccess();
|
||||
}
|
||||
|
||||
const normalized = normalizeEmbeddingsResponse(responseBody, model, provider);
|
||||
|
||||
log?.debug?.("EMBEDDINGS", `Success | usage=${JSON.stringify(normalized.usage || {})}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
response: new Response(JSON.stringify(normalized), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*"
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* 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";
|
||||
import { convertResponsesStreamToJson } from "../transformer/streamToJsonConverter.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);
|
||||
|
||||
// Preserve client's stream preference (matches OpenClaw behavior)
|
||||
// Default to false if omitted: Boolean(undefined) = false
|
||||
const clientRequestedStreaming = convertedBody.stream === true;
|
||||
if (convertedBody.stream === undefined) {
|
||||
convertedBody.stream = false;
|
||||
}
|
||||
|
||||
// Call chat core handler — force sourceFormat so streaming path knows this is a Responses API client
|
||||
const result = await handleChatCore({
|
||||
body: convertedBody,
|
||||
modelInfo,
|
||||
credentials,
|
||||
log,
|
||||
onCredentialsRefreshed,
|
||||
onRequestSuccess,
|
||||
onDisconnect,
|
||||
connectionId,
|
||||
sourceFormatOverride: "openai-responses"
|
||||
});
|
||||
|
||||
if (!result.success || !result.response) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const response = result.response;
|
||||
const contentType = response.headers.get("Content-Type") || "";
|
||||
|
||||
// Case 1: Client wants non-streaming, but got SSE (provider forced it, e.g., Codex)
|
||||
if (!clientRequestedStreaming && contentType.includes("text/event-stream")) {
|
||||
try {
|
||||
const jsonResponse = await convertResponsesStreamToJson(response.body);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
response: new Response(JSON.stringify(jsonResponse), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "no-cache",
|
||||
"Access-Control-Allow-Origin": "*"
|
||||
}
|
||||
})
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[Responses API] Stream-to-JSON conversion failed:", error);
|
||||
return {
|
||||
success: false,
|
||||
status: 500,
|
||||
error: "Failed to convert streaming response to JSON"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: Client wants streaming, got SSE - transform it
|
||||
if (clientRequestedStreaming && contentType.includes("text/event-stream")) {
|
||||
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": "*"
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
// Case 3: Non-SSE response (error or non-streaming from provider) - return as-is
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
// Patch global fetch with proxy support (must be first)
|
||||
import "./utils/proxyFetch.js";
|
||||
|
||||
// Config
|
||||
export { PROVIDERS } from "./config/providers.js";
|
||||
export { OAUTH_ENDPOINTS, CLAUDE_SYSTEM_PROMPT } from "./config/appConstants.js";
|
||||
export { CACHE_TTL, DEFAULT_MAX_TOKENS, COOLDOWN_MS, BACKOFF_CONFIG } from "./config/runtimeConfig.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";
|
||||
@@ -1,251 +0,0 @@
|
||||
import { COOLDOWN_MS, BACKOFF_CONFIG, HTTP_STATUS } from "../config/runtimeConfig.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 };
|
||||
}
|
||||
|
||||
// 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(" ")}`;
|
||||
}
|
||||
|
||||
/** Prefix for model lock flat fields on connection record */
|
||||
export const MODEL_LOCK_PREFIX = "modelLock_";
|
||||
|
||||
/** Special key used when no model is known (account-level lock) */
|
||||
export const MODEL_LOCK_ALL = `${MODEL_LOCK_PREFIX}__all`;
|
||||
|
||||
/** Build the flat field key for a model lock */
|
||||
export function getModelLockKey(model) {
|
||||
return model ? `${MODEL_LOCK_PREFIX}${model}` : MODEL_LOCK_ALL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model lock on a connection is still active.
|
||||
* Reads flat field `modelLock_${model}` (or `modelLock___all` when model=null).
|
||||
*/
|
||||
export function isModelLockActive(connection, model) {
|
||||
const key = getModelLockKey(model);
|
||||
const expiry = connection[key] || connection[MODEL_LOCK_ALL];
|
||||
if (!expiry) return false;
|
||||
return new Date(expiry).getTime() > Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get earliest active model lock expiry across all modelLock_* fields.
|
||||
* Used for UI cooldown display.
|
||||
*/
|
||||
export function getEarliestModelLockUntil(connection) {
|
||||
if (!connection) return null;
|
||||
let earliest = null;
|
||||
const now = Date.now();
|
||||
for (const [key, val] of Object.entries(connection)) {
|
||||
if (!key.startsWith(MODEL_LOCK_PREFIX) || !val) continue;
|
||||
const t = new Date(val).getTime();
|
||||
if (t <= now) continue;
|
||||
if (!earliest || t < earliest) earliest = t;
|
||||
}
|
||||
return earliest ? new Date(earliest).toISOString() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build update object to set a model lock on a connection.
|
||||
*/
|
||||
export function buildModelLockUpdate(model, cooldownMs) {
|
||||
const key = getModelLockKey(model);
|
||||
return { [key]: new Date(Date.now() + cooldownMs).toISOString() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build update object to clear all model locks on a connection.
|
||||
*/
|
||||
export function buildClearModelLocksUpdate(connection) {
|
||||
const cleared = {};
|
||||
for (const key of Object.keys(connection)) {
|
||||
if (key.startsWith(MODEL_LOCK_PREFIX)) cleared[key] = null;
|
||||
}
|
||||
return cleared;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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"
|
||||
};
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
/**
|
||||
* Shared combo (model combo) handling with fallback support
|
||||
*/
|
||||
|
||||
import { checkFallbackError, formatRetryAfter } from "./accountFallback.js";
|
||||
import { unavailableResponse } from "../utils/error.js";
|
||||
|
||||
/**
|
||||
* Get combo models from combos data
|
||||
* @param {string} modelStr - Model string to check
|
||||
* @param {Array|Object} combosData - Array of combos or object with combos
|
||||
* @returns {string[]|null} Array of models or null if not a combo
|
||||
*/
|
||||
export function getComboModelsFromData(modelStr, combosData) {
|
||||
// Don't check if it's in provider/model format
|
||||
if (modelStr.includes("/")) return null;
|
||||
|
||||
// Handle both array and object formats
|
||||
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.models;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle combo chat with fallback
|
||||
* @param {Object} options
|
||||
* @param {Object} options.body - Request body
|
||||
* @param {string[]} options.models - Array of model strings to try
|
||||
* @param {Function} options.handleSingleModel - Function to handle single model: (body, modelStr) => Promise<Response>
|
||||
* @param {Object} options.log - Logger object
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
export async function handleComboChat({ body, models, handleSingleModel, log }) {
|
||||
let lastError = null;
|
||||
let earliestRetryAfter = null;
|
||||
let lastStatus = null;
|
||||
|
||||
for (let i = 0; i < models.length; i++) {
|
||||
const modelStr = models[i];
|
||||
log.info("COMBO", `Trying model ${i + 1}/${models.length}: ${modelStr}`);
|
||||
|
||||
try {
|
||||
const result = await handleSingleModel(body, modelStr);
|
||||
|
||||
// Success (2xx) - return response
|
||||
if (result.ok) {
|
||||
log.info("COMBO", `Model ${modelStr} succeeded`);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Extract error info from response
|
||||
let errorText = result.statusText || "";
|
||||
let retryAfter = null;
|
||||
try {
|
||||
const errorBody = await result.clone().json();
|
||||
errorText = errorBody?.error?.message || errorBody?.error || errorBody?.message || errorText;
|
||||
retryAfter = errorBody?.retryAfter || null;
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
|
||||
// Track earliest retryAfter across all combo models
|
||||
if (retryAfter && (!earliestRetryAfter || new Date(retryAfter) < new Date(earliestRetryAfter))) {
|
||||
earliestRetryAfter = retryAfter;
|
||||
}
|
||||
|
||||
// Normalize error text to string (Worker-safe)
|
||||
if (typeof errorText !== "string") {
|
||||
try { errorText = JSON.stringify(errorText); } catch { errorText = String(errorText); }
|
||||
}
|
||||
|
||||
// Check if should fallback to next model
|
||||
const { shouldFallback } = checkFallbackError(result.status, errorText);
|
||||
|
||||
if (!shouldFallback) {
|
||||
log.warn("COMBO", `Model ${modelStr} failed (no fallback)`, { status: result.status });
|
||||
return result;
|
||||
}
|
||||
|
||||
// Fallback to next model
|
||||
lastError = errorText || String(result.status);
|
||||
if (!lastStatus) lastStatus = result.status;
|
||||
log.warn("COMBO", `Model ${modelStr} failed, trying next`, { status: result.status });
|
||||
} catch (error) {
|
||||
// Catch unexpected exceptions to ensure fallback continues
|
||||
lastError = error.message || String(error);
|
||||
if (!lastStatus) lastStatus = 500;
|
||||
log.warn("COMBO", `Model ${modelStr} threw error, trying next`, { error: lastError });
|
||||
}
|
||||
}
|
||||
|
||||
// All models failed
|
||||
const status = 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" } }
|
||||
);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
/**
|
||||
* Shared combo (model combo) handling with fallback support
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get combo models from combos data
|
||||
* @param {string} modelStr - Model string to check
|
||||
* @param {Array|Object} combosData - Array of combos or object with combos
|
||||
* @returns {string[]|null} Array of models or null if not a combo
|
||||
*/
|
||||
export function getComboModelsFromData(modelStr, combosData) {
|
||||
// Don't check if it's in provider/model format
|
||||
if (modelStr.includes("/")) return null;
|
||||
|
||||
// Handle both array and object formats
|
||||
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.models;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle combo chat with fallback
|
||||
* @param {Object} options
|
||||
* @param {Object} options.body - Request body
|
||||
* @param {string[]} options.models - Array of model strings to try
|
||||
* @param {Function} options.handleSingleModel - Function to handle single model: (body, modelStr) => Promise<Response>
|
||||
* @param {Object} options.log - Logger object
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
export async function handleComboChat({ body, models, handleSingleModel, log }) {
|
||||
let lastError = null;
|
||||
|
||||
for (let i = 0; i < models.length; i++) {
|
||||
const modelStr = models[i];
|
||||
log.info("COMBO", `Trying model ${i + 1}/${models.length}: ${modelStr}`);
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await handleSingleModel(body, modelStr);
|
||||
} catch (e) {
|
||||
lastError = `${modelStr}: ${e.message}`;
|
||||
log.warn("COMBO", `Model threw exception, trying next`, { model: modelStr, error: e.message });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Success or client error - return response
|
||||
if (result.ok || result.status < 500) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// 5xx error - try next model
|
||||
lastError = `${modelStr}: ${result.statusText || result.status}`;
|
||||
log.warn("COMBO", `Model failed, trying next`, { model: modelStr, status: result.status });
|
||||
}
|
||||
|
||||
log.warn("COMBO", "All models failed");
|
||||
|
||||
// Return 503 with last error
|
||||
return new Response(
|
||||
JSON.stringify({ error: lastError || "All combo models unavailable" }),
|
||||
{
|
||||
status: 503,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
// Provider alias to ID mapping
|
||||
const ALIAS_TO_PROVIDER_ID = {
|
||||
cc: "claude",
|
||||
cx: "codex",
|
||||
gc: "gemini-cli",
|
||||
qw: "qwen",
|
||||
if: "iflow",
|
||||
ag: "antigravity",
|
||||
gh: "github",
|
||||
kr: "kiro",
|
||||
cu: "cursor",
|
||||
kc: "kilocode",
|
||||
kmc: "kimi-coding",
|
||||
cl: "cline",
|
||||
// API Key providers
|
||||
openai: "openai",
|
||||
anthropic: "anthropic",
|
||||
gemini: "gemini",
|
||||
openrouter: "openrouter",
|
||||
glm: "glm",
|
||||
kimi: "kimi",
|
||||
minimax: "minimax",
|
||||
"minimax-cn": "minimax-cn",
|
||||
ds: "deepseek",
|
||||
deepseek: "deepseek",
|
||||
groq: "groq",
|
||||
xai: "xai",
|
||||
mistral: "mistral",
|
||||
pplx: "perplexity",
|
||||
perplexity: "perplexity",
|
||||
together: "together",
|
||||
fireworks: "fireworks",
|
||||
cerebras: "cerebras",
|
||||
cohere: "cohere",
|
||||
nvidia: "nvidia",
|
||||
nebius: "nebius",
|
||||
siliconflow: "siliconflow",
|
||||
hyp: "hyperbolic",
|
||||
hyperbolic: "hyperbolic",
|
||||
dg: "deepgram",
|
||||
deepgram: "deepgram",
|
||||
aai: "assemblyai",
|
||||
assemblyai: "assemblyai",
|
||||
nb: "nanobanana",
|
||||
nanobanana: "nanobanana",
|
||||
ch: "chutes",
|
||||
chutes: "chutes",
|
||||
cursor: "cursor",
|
||||
vx: "vertex",
|
||||
vertex: "vertex",
|
||||
vxp: "vertex-partner",
|
||||
"vertex-partner": "vertex-partner",
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve provider alias to provider ID
|
||||
*/
|
||||
export function resolveProviderAlias(aliasOrId) {
|
||||
return ALIAS_TO_PROVIDER_ID[aliasOrId] || aliasOrId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return {
|
||||
provider: parsed.provider,
|
||||
model: parsed.model,
|
||||
};
|
||||
}
|
||||
|
||||
// Get aliases (from object or function)
|
||||
const aliases =
|
||||
typeof aliasesOrGetter === "function"
|
||||
? await aliasesOrGetter()
|
||||
: aliasesOrGetter;
|
||||
|
||||
// Resolve alias
|
||||
const resolved = resolveModelAliasFromMap(parsed.model, aliases);
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// Fallback: infer provider from model name prefix
|
||||
return {
|
||||
provider: inferProviderFromModelName(parsed.model),
|
||||
model: parsed.model,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer provider from model name prefix
|
||||
* Used as fallback when no provider prefix or alias is given
|
||||
*/
|
||||
function inferProviderFromModelName(modelName) {
|
||||
if (!modelName) return "openai";
|
||||
const m = modelName.toLowerCase();
|
||||
if (m.startsWith("claude-")) return "anthropic";
|
||||
if (m.startsWith("gemini-")) return "gemini";
|
||||
if (m.startsWith("gpt-")) return "openai";
|
||||
if (m.startsWith("o1") || m.startsWith("o3") || m.startsWith("o4"))
|
||||
return "openai";
|
||||
if (m.startsWith("deepseek-")) return "openrouter";
|
||||
// Default fallback
|
||||
return "openai";
|
||||
}
|
||||
@@ -1,307 +0,0 @@
|
||||
/**
|
||||
* Project ID Service - Fetch and cache real Project IDs from Google Cloud Code API
|
||||
*
|
||||
* Reference: CLIProxyAPI internal/auth/antigravity/auth.go (FetchProjectID + OnboardUser)
|
||||
*
|
||||
* Instead of generating random project IDs (e.g. "useful-spark-a1b2c"),
|
||||
* this service fetches the real Project ID bound to the authenticated user's account.
|
||||
* This significantly reduces the risk of being flagged by Google's anti-abuse systems.
|
||||
*/
|
||||
|
||||
import { CLOUD_CODE_API, LOAD_CODE_ASSIST_HEADERS, LOAD_CODE_ASSIST_METADATA } from "../config/appConstants.js";
|
||||
|
||||
// ─── Cache ────────────────────────────────────────────────────────────────────
|
||||
// connectionId -> { projectId: string, fetchedAt: number }
|
||||
const projectIdCache = new Map();
|
||||
|
||||
/** How long a cached project ID is considered fresh (1 hour). */
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
// ─── Pending-fetch deduplication ─────────────────────────────────────────────
|
||||
// connectionId -> { promise: Promise<string|null>, controller: AbortController, startedAt: number }
|
||||
const pendingFetches = new Map();
|
||||
|
||||
/** Abort and evict a pending fetch that has been running longer than this (2 min). */
|
||||
const PENDING_TTL_MS = 2 * 60 * 1000;
|
||||
|
||||
// ─── Periodic cleanup ────────────────────────────────────────────────────────
|
||||
/** How often the background sweep runs (10 min). */
|
||||
const CLEANUP_INTERVAL_MS = 10 * 60 * 1000;
|
||||
|
||||
let _cleanupTimer = null;
|
||||
|
||||
/** Run one sweep immediately: evict stale cache entries and abort orphaned pending fetches. */
|
||||
export function cleanupNow() {
|
||||
const now = Date.now();
|
||||
|
||||
for (const [id, entry] of projectIdCache) {
|
||||
if (!entry || now - entry.fetchedAt >= CACHE_TTL_MS) {
|
||||
projectIdCache.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, item] of pendingFetches) {
|
||||
if (!item || typeof item.startedAt !== "number") {
|
||||
pendingFetches.delete(id);
|
||||
continue;
|
||||
}
|
||||
if (now - item.startedAt > PENDING_TTL_MS) {
|
||||
try { item.controller.abort(); } catch (_) { /* ignore */ }
|
||||
pendingFetches.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Start the periodic background cleanup (idempotent). Called automatically on module load. */
|
||||
export function startCacheCleanup() {
|
||||
if (_cleanupTimer) return;
|
||||
_cleanupTimer = setInterval(() => {
|
||||
try { cleanupNow(); } catch (e) {
|
||||
console.warn("[ProjectId] cleanup sweep error:", e?.message ?? e);
|
||||
}
|
||||
}, CLEANUP_INTERVAL_MS);
|
||||
// Unref so the timer doesn't prevent Node from exiting when it is otherwise idle
|
||||
_cleanupTimer?.unref?.();
|
||||
}
|
||||
|
||||
/** Stop the periodic background cleanup (e.g. during graceful shutdown). */
|
||||
export function stopCacheCleanup() {
|
||||
if (!_cleanupTimer) return;
|
||||
clearInterval(_cleanupTimer);
|
||||
_cleanupTimer = null;
|
||||
}
|
||||
|
||||
// Start automatically when the module is first imported
|
||||
startCacheCleanup();
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the Project ID for a connection, with caching.
|
||||
* Returns null on failure (callers should fall back to random generation).
|
||||
*
|
||||
* @param {string} connectionId - The connection identifier for cache keying
|
||||
* @param {string} accessToken - Valid OAuth access token
|
||||
* @returns {Promise<string|null>} Real project ID or null
|
||||
*/
|
||||
export async function getProjectIdForConnection(connectionId, accessToken) {
|
||||
if (!connectionId || !accessToken) return null;
|
||||
|
||||
// Return cached value if still fresh
|
||||
const cached = projectIdCache.get(connectionId);
|
||||
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
||||
return cached.projectId;
|
||||
}
|
||||
|
||||
// Deduplicate concurrent fetches for the same connection
|
||||
if (pendingFetches.has(connectionId)) {
|
||||
return pendingFetches.get(connectionId).promise;
|
||||
}
|
||||
|
||||
// Each fetch gets its own AbortController so it can be canceled via removeConnection()
|
||||
const controller = new AbortController();
|
||||
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const projectId = await fetchProjectId(accessToken, controller.signal);
|
||||
if (projectId) {
|
||||
projectIdCache.set(connectionId, {projectId, fetchedAt: Date.now()});
|
||||
return projectId;
|
||||
}
|
||||
console.warn("[ProjectId] could not fetch projectId for connection", connectionId.slice(0, 8));
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.warn(`[ProjectId] Error fetching project ID: ${error.message}`);
|
||||
return null;
|
||||
} finally {
|
||||
pendingFetches.delete(connectionId);
|
||||
}
|
||||
})();
|
||||
|
||||
pendingFetches.set(connectionId, {promise, controller, startedAt: Date.now()});
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the cached project ID for a connection.
|
||||
* Call this when a connection's credentials are fully revoked or refreshed.
|
||||
*/
|
||||
export function invalidateProjectId(connectionId) {
|
||||
projectIdCache.delete(connectionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fully remove a connection: abort any in-flight fetch and delete its cached project ID.
|
||||
* Wire this into your connection close / disconnect lifecycle events to prevent memory leaks.
|
||||
*
|
||||
* @param {string} connectionId
|
||||
*/
|
||||
export function removeConnection(connectionId) {
|
||||
if (!connectionId) return;
|
||||
projectIdCache.delete(connectionId);
|
||||
const pending = pendingFetches.get(connectionId);
|
||||
if (pending) {
|
||||
try { pending.controller.abort(); } catch (_) { /* ignore */ }
|
||||
pendingFetches.delete(connectionId);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Internal helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch project ID via loadCodeAssist endpoint.
|
||||
* Falls back to onboardUser when loadCodeAssist returns no project.
|
||||
*
|
||||
* @param {string} accessToken
|
||||
* @param {AbortSignal} signal
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
async function fetchProjectId(accessToken, signal) {
|
||||
const response = await fetch(CLOUD_CODE_API.loadCodeAssist, {
|
||||
method: "POST",
|
||||
headers: { ...LOAD_CODE_ASSIST_HEADERS, "Authorization": `Bearer ${accessToken}` },
|
||||
body: JSON.stringify({ metadata: LOAD_CODE_ASSIST_METADATA }),
|
||||
signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => "");
|
||||
throw new Error(`loadCodeAssist failed: HTTP ${response.status} ${errorText.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const projectId = extractProjectId(data);
|
||||
if (projectId) return projectId;
|
||||
|
||||
// Determine the tier to use for onboarding
|
||||
let tierID = "legacy-tier";
|
||||
if (Array.isArray(data.allowedTiers)) {
|
||||
for (const tier of data.allowedTiers) {
|
||||
if (tier && typeof tier === "object" && tier.isDefault === true) {
|
||||
if (tier.id && typeof tier.id === "string" && tier.id.trim()) {
|
||||
tierID = tier.id.trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return onboardUser(accessToken, tierID, signal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch project ID via onboardUser endpoint (polls until done).
|
||||
*
|
||||
* @param {string} accessToken
|
||||
* @param {string} tierID
|
||||
* @param {AbortSignal} externalSignal – propagated from the connection's AbortController
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
async function onboardUser(accessToken, tierID, externalSignal) {
|
||||
console.log(`[ProjectId] Onboarding user with tier: ${tierID}`);
|
||||
|
||||
const reqBody = { tierId: tierID, metadata: LOAD_CODE_ASSIST_METADATA };
|
||||
const MAX_ATTEMPTS = 5;
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
// Bail out immediately if the connection was removed
|
||||
if (externalSignal?.aborted) return null;
|
||||
|
||||
// Per-attempt timeout controller; forwards external abort as well
|
||||
const localCtrl = new AbortController();
|
||||
const timeoutId = setTimeout(() => localCtrl.abort(), 30_000);
|
||||
const forwardAbort = () => localCtrl.abort();
|
||||
externalSignal?.addEventListener("abort", forwardAbort);
|
||||
|
||||
try {
|
||||
const response = await fetch(CLOUD_CODE_API.onboardUser, {
|
||||
method: "POST",
|
||||
headers: { ...LOAD_CODE_ASSIST_HEADERS, "Authorization": `Bearer ${accessToken}` },
|
||||
body: JSON.stringify(reqBody),
|
||||
signal: localCtrl.signal
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => "");
|
||||
throw new Error(`onboardUser HTTP ${response.status}: ${errorText.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.done === true) {
|
||||
const projectId = extractProjectIdFromOnboard(data);
|
||||
if (projectId) {
|
||||
console.log(`[ProjectId] Successfully onboarded, project ID: ${projectId}`);
|
||||
return projectId;
|
||||
}
|
||||
throw new Error("onboardUser done but no project_id in response");
|
||||
}
|
||||
|
||||
// Server not done yet – wait and retry
|
||||
console.log(`[ProjectId] Onboard attempt ${attempt}/${MAX_ATTEMPTS}: not done yet, waiting...`);
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
if (error.name === "AbortError") {
|
||||
console.warn(`[ProjectId] onboardUser attempt ${attempt} aborted (timeout or connection removed)`);
|
||||
if (externalSignal?.aborted) return null; // connection gone – stop retrying
|
||||
continue;
|
||||
}
|
||||
if (attempt === MAX_ATTEMPTS) {
|
||||
console.warn(`[ProjectId] onboardUser failed after ${MAX_ATTEMPTS} attempts: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
// Continue to next attempt instead of throwing (which would skip remaining retries)
|
||||
console.warn(`[ProjectId] onboardUser attempt ${attempt} failed: ${error.message}, retrying...`);
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
externalSignal?.removeEventListener("abort", forwardAbort);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract project ID from loadCodeAssist response.
|
||||
*/
|
||||
function extractProjectId(data) {
|
||||
if (!data) return null;
|
||||
|
||||
if (typeof data.cloudaicompanionProject === "string") {
|
||||
const id = data.cloudaicompanionProject.trim();
|
||||
if (id) return id;
|
||||
}
|
||||
|
||||
if (data.cloudaicompanionProject && typeof data.cloudaicompanionProject === "object") {
|
||||
const id = data.cloudaicompanionProject.id;
|
||||
if (typeof id === "string" && id.trim()) return id.trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract project ID from onboardUser response.
|
||||
*/
|
||||
function extractProjectIdFromOnboard(data) {
|
||||
if (!data?.response) return null;
|
||||
|
||||
const project = data.response.cloudaicompanionProject;
|
||||
|
||||
if (typeof project === "string") {
|
||||
const id = project.trim();
|
||||
if (id) return id;
|
||||
}
|
||||
|
||||
if (project && typeof project === "object") {
|
||||
const id = project.id;
|
||||
if (typeof id === "string" && id.trim()) return id.trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,356 +0,0 @@
|
||||
import { PROVIDERS } from "../config/providers.js";
|
||||
import { buildClineHeaders } from "../../src/shared/utils/clineAuth.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`;
|
||||
}
|
||||
|
||||
function buildQwenBaseUrl(resourceUrl, fallbackBaseUrl) {
|
||||
const fallback = (fallbackBaseUrl || "").replace(/\/chat\/completions$/, "");
|
||||
const raw = typeof resourceUrl === "string" ? resourceUrl.trim() : "";
|
||||
if (!raw) return fallback;
|
||||
if (raw.startsWith("http://") || raw.startsWith("https://")) {
|
||||
return raw.replace(/\/$/, "");
|
||||
}
|
||||
return `https://${raw.replace(/\/$/, "")}/v1`;
|
||||
}
|
||||
|
||||
// Detect request format from body structure
|
||||
export function detectFormat(body) {
|
||||
// OpenAI Responses API: has input (array or string) instead of messages[]
|
||||
// The Responses API accepts both input as array and input as a plain string
|
||||
if (body.input && (Array.isArray(body.input) || typeof body.input === "string") && !body.messages) {
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
// 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 config = getProviderConfig(provider);
|
||||
|
||||
switch (provider) {
|
||||
case "claude":
|
||||
return `${config.baseUrl}?beta=true`;
|
||||
|
||||
case "gemini": {
|
||||
const action = stream ? "streamGenerateContent?alt=sse" : "generateContent";
|
||||
return `${config.baseUrl}/${model}:${action}`;
|
||||
}
|
||||
|
||||
case "gemini-cli": {
|
||||
const action = stream ? "streamGenerateContent?alt=sse" : "generateContent";
|
||||
return `${config.baseUrl}:${action}`;
|
||||
}
|
||||
|
||||
case "antigravity": {
|
||||
// Use baseUrlIndex from options or default to 0
|
||||
const urlIndex = options?.baseUrlIndex || 0;
|
||||
const baseUrl = config.baseUrls[urlIndex] || config.baseUrls[0];
|
||||
const path = stream ? "/v1internal:streamGenerateContent?alt=sse" : "/v1internal:generateContent";
|
||||
return `${baseUrl}${path}`;
|
||||
}
|
||||
|
||||
case "codex":
|
||||
return config.baseUrl;
|
||||
|
||||
case "qwen": {
|
||||
const baseUrl = buildQwenBaseUrl(options?.qwenResourceUrl, config.baseUrl);
|
||||
return `${baseUrl}/chat/completions`;
|
||||
}
|
||||
|
||||
case "github":
|
||||
return config.baseUrl;
|
||||
|
||||
case "glm":
|
||||
case "kimi":
|
||||
case "minimax":
|
||||
// Claude-compatible providers
|
||||
return `${config.baseUrl}?beta=true`;
|
||||
|
||||
default:
|
||||
return config.baseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// Build provider headers
|
||||
export function buildProviderHeaders(provider, credentials, stream = true, body = null) {
|
||||
const config = getProviderConfig(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;
|
||||
// Do NOT send Authorization header when apiKey is present for Anthropic Compatible
|
||||
// as it causes issues with some providers (e.g. opencode.ai)
|
||||
} else if (credentials.accessToken) {
|
||||
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
|
||||
}
|
||||
// Add default Anthropic version if not present (some proxies require it)
|
||||
if (!headers["anthropic-version"]) {
|
||||
headers["anthropic-version"] = "2023-06-01";
|
||||
}
|
||||
} else {
|
||||
switch (provider) {
|
||||
case "gemini":
|
||||
if (credentials.apiKey) {
|
||||
headers["x-goog-api-key"] = credentials.apiKey;
|
||||
} else if (credentials.accessToken) {
|
||||
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
|
||||
}
|
||||
break;
|
||||
|
||||
case "antigravity":
|
||||
case "gemini-cli":
|
||||
// Antigravity and Gemini CLI use OAuth access token
|
||||
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
|
||||
break;
|
||||
|
||||
case "claude":
|
||||
// Claude uses x-api-key header for API key, or Authorization for OAuth
|
||||
if (credentials.apiKey) {
|
||||
headers["x-api-key"] = credentials.apiKey;
|
||||
} else if (credentials.accessToken) {
|
||||
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
|
||||
}
|
||||
break;
|
||||
|
||||
case "github": {
|
||||
// GitHub Copilot requires special headers to mimic VSCode
|
||||
// Prioritize copilotToken from providerSpecificData, fallback to accessToken
|
||||
const githubToken = credentials.copilotToken || credentials.accessToken;
|
||||
// Add headers in exact same order as test endpoint
|
||||
headers["Authorization"] = `Bearer ${githubToken}`;
|
||||
headers["Content-Type"] = "application/json";
|
||||
headers["copilot-integration-id"] = "vscode-chat";
|
||||
headers["editor-version"] = "vscode/1.107.1";
|
||||
headers["editor-plugin-version"] = "copilot-chat/0.26.7";
|
||||
headers["user-agent"] = "GitHubCopilotChat/0.26.7";
|
||||
headers["openai-intent"] = "conversation-panel";
|
||||
headers["x-github-api-version"] = "2025-04-01";
|
||||
// Generate a UUID for x-request-id (Cloudflare Workers compatible)
|
||||
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);
|
||||
});
|
||||
headers["x-vscode-user-agent-library-version"] = "electron-fetch";
|
||||
headers["X-Initiator"] = "user";
|
||||
headers["Accept"] = "application/json";
|
||||
break;
|
||||
}
|
||||
|
||||
case "codex":
|
||||
case "qwen":
|
||||
case "openai":
|
||||
case "openrouter":
|
||||
headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`;
|
||||
break;
|
||||
|
||||
case "cline":
|
||||
Object.assign(headers, buildClineHeaders(credentials.apiKey || credentials.accessToken));
|
||||
break;
|
||||
|
||||
case "glm":
|
||||
case "kimi":
|
||||
case "minimax":
|
||||
// Claude-compatible API providers use x-api-key
|
||||
headers["x-api-key"] = credentials.apiKey;
|
||||
break;
|
||||
|
||||
case "vertex":
|
||||
case "vertex-partner":
|
||||
// Vertex uses async token minting — headers are set by VertexExecutor._buildHeadersAsync()
|
||||
// Do NOT set Authorization here; it would leak the raw SA JSON as Bearer token
|
||||
break;
|
||||
|
||||
default:
|
||||
headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 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";
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -1,731 +0,0 @@
|
||||
import { PROVIDERS } from "../config/providers.js";
|
||||
import { OAUTH_ENDPOINTS, GITHUB_COPILOT } from "../config/appConstants.js";
|
||||
|
||||
// Token expiry buffer (refresh if expires within 5 minutes)
|
||||
export const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Refresh OAuth access token using refresh token
|
||||
*/
|
||||
export async function refreshAccessToken(provider, refreshToken, credentials, log) {
|
||||
const config = PROVIDERS[provider];
|
||||
|
||||
if (!config || !config.refreshUrl) {
|
||||
log?.warn?.("TOKEN_REFRESH", `No refresh URL configured for provider: ${provider}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!refreshToken) {
|
||||
log?.warn?.("TOKEN_REFRESH", `No refresh token available for provider: ${provider}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(config.refreshUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
}),
|
||||
});
|
||||
|
||||
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 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, 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) {
|
||||
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: 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, 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 Google token: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
providerSpecificData: tokens.resource_url
|
||||
? { resourceUrl: tokens.resource_url }
|
||||
: undefined,
|
||||
};
|
||||
} else {
|
||||
const errorText = await response.text().catch(() => "");
|
||||
log?.warn?.("TOKEN_REFRESH", `Error with Qwen endpoint`, {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
log?.warn?.("TOKEN_REFRESH", `Network error trying Qwen endpoint`, {
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Qwen token");
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Specialized refresh for Codex (OpenAI) OAuth tokens
|
||||
*/
|
||||
export async function refreshCodexToken(refreshToken, log) {
|
||||
try {
|
||||
const response = await fetch(OAUTH_ENDPOINTS.openai.token, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: PROVIDERS.codex.clientId,
|
||||
scope: "openid profile email offline_access",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
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,
|
||||
};
|
||||
} catch (error) {
|
||||
log?.error?.("TOKEN_REFRESH", `Network error refreshing Codex token: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
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",
|
||||
"User-Agent": "kiro-cli/1.0.0",
|
||||
},
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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": GITHUB_COPILOT.USER_AGENT,
|
||||
"Editor-Version": `vscode/${GITHUB_COPILOT.VSCODE_VERSION}`,
|
||||
"Editor-Plugin-Version": `copilot-chat/${GITHUB_COPILOT.COPILOT_CHAT_VERSION}`,
|
||||
"Accept": "application/json",
|
||||
"x-github-api-version": GITHUB_COPILOT.API_VERSION
|
||||
}
|
||||
});
|
||||
|
||||
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
|
||||
*/
|
||||
export async function getAccessToken(provider, credentials, log) {
|
||||
if (!credentials || !credentials.refreshToken) {
|
||||
log?.warn?.("TOKEN_REFRESH", `No refresh token available for provider: ${provider}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
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 "vertex":
|
||||
case "vertex-partner": {
|
||||
const saJson = parseVertexSaJson(credentials.apiKey);
|
||||
if (!saJson) return null;
|
||||
return await refreshVertexToken(saJson, log);
|
||||
}
|
||||
|
||||
default:
|
||||
log?.warn?.("TOKEN_REFRESH", `Unsupported provider for token refresh: ${provider}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh token by provider type (helper for handlers)
|
||||
*/
|
||||
export async function refreshTokenByProvider(provider, credentials, log) {
|
||||
if (!credentials.refreshToken) return null;
|
||||
|
||||
switch (provider) {
|
||||
case "gemini-cli":
|
||||
case "antigravity":
|
||||
return refreshGoogleToken(
|
||||
credentials.refreshToken,
|
||||
PROVIDERS[provider].clientId,
|
||||
PROVIDERS[provider].clientSecret,
|
||||
log
|
||||
);
|
||||
case "claude":
|
||||
return refreshClaudeOAuthToken(credentials.refreshToken, log);
|
||||
case "codex":
|
||||
return refreshCodexToken(credentials.refreshToken, log);
|
||||
case "qwen":
|
||||
return refreshQwenToken(credentials.refreshToken, log);
|
||||
case "iflow":
|
||||
return refreshIflowToken(credentials.refreshToken, log);
|
||||
case "github":
|
||||
return refreshGitHubToken(credentials.refreshToken, log);
|
||||
case "kiro":
|
||||
return refreshKiroToken(
|
||||
credentials.refreshToken,
|
||||
credentials.providerSpecificData,
|
||||
log
|
||||
);
|
||||
case "vertex":
|
||||
case "vertex-partner": {
|
||||
const saJson = parseVertexSaJson(credentials.apiKey);
|
||||
if (!saJson) return null;
|
||||
return refreshVertexToken(saJson, log);
|
||||
}
|
||||
default:
|
||||
return refreshAccessToken(provider, credentials.refreshToken, credentials, log);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
projectId: credentials.projectId
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Vertex AI Service Account JSON from apiKey string
|
||||
*/
|
||||
export function parseVertexSaJson(apiKey) {
|
||||
if (typeof apiKey !== "string") return null;
|
||||
try {
|
||||
const parsed = JSON.parse(apiKey);
|
||||
if (parsed.type === "service_account" && parsed.client_email && parsed.private_key && parsed.project_id) {
|
||||
return parsed;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache Vertex tokens keyed by service account email { token, expiresAt }
|
||||
const vertexTokenCache = new Map();
|
||||
|
||||
/**
|
||||
* Mint a short-lived OAuth2 Bearer token for Google Cloud Vertex AI
|
||||
* using Service Account JSON + jose (RS256 JWT assertion flow).
|
||||
* Token is cached until 5 minutes before expiry.
|
||||
*/
|
||||
export async function refreshVertexToken(saJson, log) {
|
||||
const cacheKey = saJson.client_email;
|
||||
const cached = vertexTokenCache.get(cacheKey);
|
||||
|
||||
// Return cached token if still valid (5-min buffer)
|
||||
if (cached && cached.expiresAt - Date.now() > 5 * 60 * 1000) {
|
||||
return { accessToken: cached.token, expiresAt: cached.expiresAt };
|
||||
}
|
||||
|
||||
try {
|
||||
const { SignJWT, importPKCS8 } = await import("jose");
|
||||
log?.debug?.("TOKEN_REFRESH", `Vertex minting token for ${saJson.client_email}`);
|
||||
const privateKey = await importPKCS8(saJson.private_key.replace(/\\n/g, "\n"), "RS256");
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const jwt = await new SignJWT({ scope: "https://www.googleapis.com/auth/cloud-platform" })
|
||||
.setProtectedHeader({ alg: "RS256" })
|
||||
.setIssuer(saJson.client_email)
|
||||
.setAudience("https://oauth2.googleapis.com/token")
|
||||
.setIssuedAt(now)
|
||||
.setExpirationTime(now + 3600)
|
||||
.sign(privateKey);
|
||||
|
||||
const res = await fetch("https://oauth2.googleapis.com/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
||||
assertion: jwt,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
log?.error?.("TOKEN_REFRESH", `Vertex token mint failed: ${err}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const { access_token, expires_in } = await res.json();
|
||||
const expiresAt = Date.now() + (expires_in ?? 3600) * 1000;
|
||||
|
||||
vertexTokenCache.set(cacheKey, { token: access_token, expiresAt });
|
||||
log?.info?.("TOKEN_REFRESH", `Vertex token minted for ${saJson.client_email}`);
|
||||
|
||||
return { accessToken: access_token, expiresAt };
|
||||
} catch (error) {
|
||||
log?.error?.("TOKEN_REFRESH", `Vertex token error: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh token with retry and exponential backoff
|
||||
* Retries on failure with increasing delay: 1s, 2s, 3s...
|
||||
* @param {function} refreshFn - Async function that returns token or null
|
||||
* @param {number} maxRetries - Max retry attempts (default 3)
|
||||
* @param {object} log - Logger instance (optional)
|
||||
* @returns {Promise<object|null>} Token result or null if all retries fail
|
||||
*/
|
||||
export async function refreshWithRetry(refreshFn, maxRetries = 3, log = null) {
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
if (attempt > 0) {
|
||||
const delay = attempt * 1000;
|
||||
log?.debug?.("TOKEN_REFRESH", `Retry ${attempt}/${maxRetries} after ${delay}ms`);
|
||||
await new Promise(r => setTimeout(r, delay));
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await refreshFn();
|
||||
if (result) return result;
|
||||
} catch (error) {
|
||||
log?.warn?.("TOKEN_REFRESH", `Attempt ${attempt + 1}/${maxRetries} failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
log?.error?.("TOKEN_REFRESH", `All ${maxRetries} retry attempts failed`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,708 +0,0 @@
|
||||
/**
|
||||
* Usage Fetcher - Get usage data from provider APIs
|
||||
*/
|
||||
|
||||
import { CLIENT_METADATA, getPlatformUserAgent } from "../config/appConstants.js";
|
||||
|
||||
// GitHub API config
|
||||
const GITHUB_CONFIG = {
|
||||
apiVersion: "2022-11-28",
|
||||
userAgent: "GitHubCopilotChat/0.26.7",
|
||||
};
|
||||
|
||||
// Antigravity API config (from Quotio)
|
||||
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",
|
||||
clientId: "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com",
|
||||
clientSecret: "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",
|
||||
userAgent: getPlatformUserAgent(),
|
||||
};
|
||||
|
||||
// Codex (OpenAI) API config
|
||||
const CODEX_CONFIG = {
|
||||
usageUrl: "https://chatgpt.com/backend-api/wham/usage",
|
||||
};
|
||||
|
||||
// Claude API config
|
||||
const CLAUDE_CONFIG = {
|
||||
oauthUsageUrl: "https://api.anthropic.com/api/oauth/usage",
|
||||
usageUrl: "https://api.anthropic.com/v1/organizations/{org_id}/usage",
|
||||
settingsUrl: "https://api.anthropic.com/v1/settings",
|
||||
apiVersion: "2023-06-01",
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 Usage - Fetch quota from Google Cloud Code API
|
||||
*/
|
||||
async function getAntigravityUsage(accessToken, providerSpecificData) {
|
||||
try {
|
||||
// Fetch subscription info once — reuse for both projectId and plan
|
||||
const subscriptionInfo = await getAntigravitySubscriptionInfo(accessToken);
|
||||
const projectId = subscriptionInfo?.cloudaicompanionProject || null;
|
||||
|
||||
// Fetch quota data with timeout
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(ANTIGRAVITY_CONFIG.quotaApiUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
"User-Agent": ANTIGRAVITY_CONFIG.userAgent,
|
||||
"Content-Type": "application/json",
|
||||
"X-Client-Name": "antigravity",
|
||||
"X-Client-Version": "1.107.0",
|
||||
"x-request-source": "local", // MITM bypass
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...(projectId ? { project: projectId } : {})
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
if (response.status === 403) {
|
||||
return {
|
||||
message: "Antigravity quota API access forbidden. Chat may still work.",
|
||||
quotas: {}
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
return {
|
||||
message: "Antigravity quota API authentication expired. Chat may still work.",
|
||||
quotas: {}
|
||||
};
|
||||
}
|
||||
|
||||
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-sonnet-4-6',
|
||||
'gemini-3.1-pro-high',
|
||||
'gemini-3.1-pro-low',
|
||||
'gemini-3-flash',
|
||||
'gpt-oss-120b-medium',
|
||||
];
|
||||
|
||||
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
|
||||
const total = 1000; // 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: subscriptionInfo?.currentTier?.name || "Unknown",
|
||||
quotas,
|
||||
subscriptionInfo,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[Antigravity Usage] Error:", error.message, error.cause);
|
||||
return { message: `Antigravity error: ${error.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Antigravity project ID from subscription info
|
||||
*/
|
||||
async function getAntigravityProjectId(accessToken) {
|
||||
try {
|
||||
const info = await getAntigravitySubscriptionInfo(accessToken);
|
||||
return info?.cloudaicompanionProject || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Antigravity subscription info
|
||||
*/
|
||||
async function getAntigravitySubscriptionInfo(accessToken) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
|
||||
try {
|
||||
const response = await fetch(ANTIGRAVITY_CONFIG.loadProjectApiUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
"User-Agent": ANTIGRAVITY_CONFIG.userAgent,
|
||||
"Content-Type": "application/json",
|
||||
"x-request-source": "local", // MITM bypass
|
||||
},
|
||||
body: JSON.stringify({ metadata: CLIENT_METADATA, mode: 1 }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) return null;
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error("[Antigravity Subscription] Error:", error.message);
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Usage - Primary: OAuth endpoint, Fallback: legacy settings/org endpoint
|
||||
*/
|
||||
async function getClaudeUsage(accessToken) {
|
||||
try {
|
||||
// Primary: OAuth usage endpoint (Claude Code consumer OAuth tokens)
|
||||
const oauthResponse = await fetch(CLAUDE_CONFIG.oauthUsageUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
"anthropic-version": CLAUDE_CONFIG.apiVersion,
|
||||
},
|
||||
});
|
||||
|
||||
if (oauthResponse.ok) {
|
||||
const data = await oauthResponse.json();
|
||||
const quotas = {};
|
||||
|
||||
// utilization = % USED (e.g. 87 means 87% used, 13% remaining)
|
||||
const hasUtilization = (window) =>
|
||||
window && typeof window === "object" && typeof window.utilization === "number";
|
||||
|
||||
const createQuotaObject = (window) => {
|
||||
const used = window.utilization;
|
||||
const remaining = Math.max(0, 100 - used);
|
||||
return {
|
||||
used,
|
||||
total: 100,
|
||||
remaining,
|
||||
remainingPercentage: remaining,
|
||||
resetAt: parseResetTime(window.resets_at),
|
||||
unlimited: false,
|
||||
};
|
||||
};
|
||||
|
||||
if (hasUtilization(data.five_hour)) {
|
||||
quotas["session (5h)"] = createQuotaObject(data.five_hour);
|
||||
}
|
||||
|
||||
if (hasUtilization(data.seven_day)) {
|
||||
quotas["weekly (7d)"] = createQuotaObject(data.seven_day);
|
||||
}
|
||||
|
||||
// Parse model-specific weekly windows (e.g. seven_day_sonnet, seven_day_opus)
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (key.startsWith("seven_day_") && key !== "seven_day" && hasUtilization(value)) {
|
||||
const modelName = key.replace("seven_day_", "");
|
||||
quotas[`weekly ${modelName} (7d)`] = createQuotaObject(value);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
plan: "Claude Code",
|
||||
extraUsage: data.extra_usage ?? null,
|
||||
quotas,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: legacy settings + org usage endpoint
|
||||
console.warn(`[Claude Usage] OAuth endpoint returned ${oauthResponse.status}, falling back to legacy`);
|
||||
return await getClaudeUsageLegacy(accessToken);
|
||||
} catch (error) {
|
||||
return { message: `Claude connected. Unable to fetch usage: ${error.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy Claude usage for API key / org admin users
|
||||
*/
|
||||
async function getClaudeUsageLegacy(accessToken) {
|
||||
try {
|
||||
const settingsResponse = await fetch(CLAUDE_CONFIG.settingsUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
"anthropic-version": CLAUDE_CONFIG.apiVersion,
|
||||
},
|
||||
});
|
||||
|
||||
if (settingsResponse.ok) {
|
||||
const settings = await settingsResponse.json();
|
||||
|
||||
if (settings.organization_id) {
|
||||
const usageResponse = await fetch(
|
||||
CLAUDE_CONFIG.usageUrl.replace("{org_id}", settings.organization_id),
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
"anthropic-version": CLAUDE_CONFIG.apiVersion,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
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.",
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
// Default profileArn fallback
|
||||
const DEFAULT_PROFILE_ARN = "arn:aws:codewhisperer:us-east-1:638616132270:profile/AAAACCCCXXXX";
|
||||
const profileArn = providerSpecificData?.profileArn || DEFAULT_PROFILE_ARN;
|
||||
|
||||
try {
|
||||
// Try old API first (POST method)
|
||||
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();
|
||||
|
||||
// Handle authentication errors gracefully
|
||||
if (response.status === 403 || response.status === 401) {
|
||||
return {
|
||||
message: "Kiro quota API authentication expired. Chat may still work.",
|
||||
quotas: {}
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
// Fallback to new API (GET method)
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
origin: "AI_EDITOR",
|
||||
profileArn: profileArn,
|
||||
resourceType: "AGENTIC_REQUEST",
|
||||
});
|
||||
|
||||
const fallbackResponse = await fetch(`https://q.us-east-1.amazonaws.com/getUsageLimits?${params}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
"Accept": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!fallbackResponse.ok) {
|
||||
throw new Error(`Fallback API error (${fallbackResponse.status})`);
|
||||
}
|
||||
|
||||
const fallbackData = await fallbackResponse.json();
|
||||
|
||||
// Parse new API response structure
|
||||
const usageList = fallbackData.usageBreakdownList || [];
|
||||
const quotaInfo = {};
|
||||
const resetAt = parseResetTime(fallbackData.nextDateReset || fallbackData.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: parseResetTime(breakdown.freeTrialInfo.freeTrialExpiry),
|
||||
unlimited: false,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
plan: fallbackData.subscriptionInfo?.subscriptionTitle || "Kiro",
|
||||
quotas: quotaInfo,
|
||||
};
|
||||
} catch (fallbackError) {
|
||||
throw new Error(`Failed to fetch Kiro usage: ${error.message} | Fallback: ${fallbackError.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." };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,439 +0,0 @@
|
||||
/**
|
||||
* Responses API Transformer
|
||||
* Converts OpenAI Chat Completions SSE to Codex Responses API SSE format
|
||||
* Can be used in both Next.js and Cloudflare Workers
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
// Create log directory for responses (Node.js only)
|
||||
export function createResponsesLogger(model, logsDir = null) {
|
||||
// Skip logging in worker environment (no fs)
|
||||
if (typeof fs.mkdirSync !== "function") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15);
|
||||
const uniqueId = Math.random().toString(36).slice(2, 8);
|
||||
const baseDir = logsDir || (typeof process !== "undefined" ? process.cwd() : ".");
|
||||
const logDir = path.join(baseDir, "logs", `responses_${model}_${timestamp}_${uniqueId}`);
|
||||
|
||||
try {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let inputEvents = [];
|
||||
let outputEvents = [];
|
||||
|
||||
return {
|
||||
logInput: (event) => {
|
||||
inputEvents.push(event);
|
||||
},
|
||||
logOutput: (event) => {
|
||||
outputEvents.push(event);
|
||||
},
|
||||
flush: () => {
|
||||
try {
|
||||
fs.writeFileSync(path.join(logDir, "1_input_stream.txt"), inputEvents.join("\n"));
|
||||
fs.writeFileSync(path.join(logDir, "2_output_stream.txt"), outputEvents.join("\n"));
|
||||
} catch (e) {
|
||||
console.log("[RESPONSES] Failed to write logs:", e.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create TransformStream that converts Chat Completions SSE to Responses API SSE
|
||||
* @param {Object} logger - Optional logger instance
|
||||
* @returns {TransformStream}
|
||||
*/
|
||||
export function createResponsesApiTransformStream(logger = null) {
|
||||
const state = {
|
||||
seq: 0,
|
||||
responseId: `resp_${Date.now()}`,
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
started: false,
|
||||
msgTextBuf: {},
|
||||
msgItemAdded: {},
|
||||
msgContentAdded: {},
|
||||
msgItemDone: {},
|
||||
reasoningId: "",
|
||||
reasoningIndex: -1,
|
||||
reasoningBuf: "",
|
||||
reasoningPartAdded: false,
|
||||
reasoningDone: false,
|
||||
inThinking: false,
|
||||
funcArgsBuf: {},
|
||||
funcNames: {},
|
||||
funcCallIds: {},
|
||||
funcArgsDone: {},
|
||||
funcItemDone: {},
|
||||
buffer: "",
|
||||
completedSent: false
|
||||
};
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const nextSeq = () => ++state.seq;
|
||||
|
||||
const emit = (controller, eventType, data) => {
|
||||
data.sequence_number = nextSeq();
|
||||
const output = `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
logger?.logOutput(output.trim());
|
||||
controller.enqueue(encoder.encode(output));
|
||||
};
|
||||
|
||||
// Helper to start reasoning
|
||||
const startReasoning = (controller, idx) => {
|
||||
if (!state.reasoningId) {
|
||||
state.reasoningId = `rs_${state.responseId}_${idx}`;
|
||||
state.reasoningIndex = idx;
|
||||
|
||||
emit(controller, "response.output_item.added", {
|
||||
type: "response.output_item.added",
|
||||
output_index: idx,
|
||||
item: {
|
||||
id: state.reasoningId,
|
||||
type: "reasoning",
|
||||
summary: []
|
||||
}
|
||||
});
|
||||
|
||||
emit(controller, "response.reasoning_summary_part.added", {
|
||||
type: "response.reasoning_summary_part.added",
|
||||
item_id: state.reasoningId,
|
||||
output_index: idx,
|
||||
summary_index: 0,
|
||||
part: { type: "summary_text", text: "" }
|
||||
});
|
||||
state.reasoningPartAdded = true;
|
||||
}
|
||||
};
|
||||
|
||||
const emitReasoningDelta = (controller, text) => {
|
||||
if (!text) return;
|
||||
state.reasoningBuf += text;
|
||||
emit(controller, "response.reasoning_summary_text.delta", {
|
||||
type: "response.reasoning_summary_text.delta",
|
||||
item_id: state.reasoningId,
|
||||
output_index: state.reasoningIndex,
|
||||
summary_index: 0,
|
||||
delta: text
|
||||
});
|
||||
};
|
||||
|
||||
const closeReasoning = (controller) => {
|
||||
if (state.reasoningId && !state.reasoningDone) {
|
||||
state.reasoningDone = true;
|
||||
|
||||
emit(controller, "response.reasoning_summary_text.done", {
|
||||
type: "response.reasoning_summary_text.done",
|
||||
item_id: state.reasoningId,
|
||||
output_index: state.reasoningIndex,
|
||||
summary_index: 0,
|
||||
text: state.reasoningBuf
|
||||
});
|
||||
|
||||
emit(controller, "response.reasoning_summary_part.done", {
|
||||
type: "response.reasoning_summary_part.done",
|
||||
item_id: state.reasoningId,
|
||||
output_index: state.reasoningIndex,
|
||||
summary_index: 0,
|
||||
part: { type: "summary_text", text: state.reasoningBuf }
|
||||
});
|
||||
|
||||
emit(controller, "response.output_item.done", {
|
||||
type: "response.output_item.done",
|
||||
output_index: state.reasoningIndex,
|
||||
item: {
|
||||
id: state.reasoningId,
|
||||
type: "reasoning",
|
||||
summary: [{ type: "summary_text", text: state.reasoningBuf }]
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const closeMessage = (controller, idx) => {
|
||||
if (state.msgItemAdded[idx] && !state.msgItemDone[idx]) {
|
||||
state.msgItemDone[idx] = true;
|
||||
const fullText = state.msgTextBuf[idx] || "";
|
||||
const msgId = `msg_${state.responseId}_${idx}`;
|
||||
|
||||
emit(controller, "response.output_text.done", {
|
||||
type: "response.output_text.done",
|
||||
item_id: msgId,
|
||||
output_index: parseInt(idx),
|
||||
content_index: 0,
|
||||
text: fullText,
|
||||
logprobs: []
|
||||
});
|
||||
|
||||
emit(controller, "response.content_part.done", {
|
||||
type: "response.content_part.done",
|
||||
item_id: msgId,
|
||||
output_index: parseInt(idx),
|
||||
content_index: 0,
|
||||
part: { type: "output_text", annotations: [], logprobs: [], text: fullText }
|
||||
});
|
||||
|
||||
emit(controller, "response.output_item.done", {
|
||||
type: "response.output_item.done",
|
||||
output_index: parseInt(idx),
|
||||
item: {
|
||||
id: msgId,
|
||||
type: "message",
|
||||
content: [{ type: "output_text", annotations: [], logprobs: [], text: fullText }],
|
||||
role: "assistant"
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const closeToolCall = (controller, idx) => {
|
||||
const callId = state.funcCallIds[idx];
|
||||
if (callId && !state.funcItemDone[idx]) {
|
||||
const args = state.funcArgsBuf[idx] || "{}";
|
||||
|
||||
emit(controller, "response.function_call_arguments.done", {
|
||||
type: "response.function_call_arguments.done",
|
||||
item_id: `fc_${callId}`,
|
||||
output_index: parseInt(idx),
|
||||
arguments: args
|
||||
});
|
||||
|
||||
emit(controller, "response.output_item.done", {
|
||||
type: "response.output_item.done",
|
||||
output_index: parseInt(idx),
|
||||
item: {
|
||||
id: `fc_${callId}`,
|
||||
type: "function_call",
|
||||
arguments: args,
|
||||
call_id: callId,
|
||||
name: state.funcNames[idx] || ""
|
||||
}
|
||||
});
|
||||
|
||||
state.funcItemDone[idx] = true;
|
||||
state.funcArgsDone[idx] = true;
|
||||
}
|
||||
};
|
||||
|
||||
const sendCompleted = (controller) => {
|
||||
if (!state.completedSent) {
|
||||
state.completedSent = true;
|
||||
emit(controller, "response.completed", {
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: state.responseId,
|
||||
object: "response",
|
||||
created_at: state.created,
|
||||
status: "completed",
|
||||
background: false,
|
||||
error: null
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
const text = new TextDecoder().decode(chunk);
|
||||
logger?.logInput(text.trim());
|
||||
state.buffer += text;
|
||||
|
||||
const messages = state.buffer.split("\n\n");
|
||||
state.buffer = messages.pop() || "";
|
||||
|
||||
for (const msg of messages) {
|
||||
if (!msg.trim()) continue;
|
||||
|
||||
const dataMatch = msg.match(/^data:\s*(.+)$/m);
|
||||
if (!dataMatch) continue;
|
||||
|
||||
const dataStr = dataMatch[1].trim();
|
||||
if (dataStr === "[DONE]") continue;
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(dataStr);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!parsed.choices?.length) continue;
|
||||
|
||||
const choice = parsed.choices[0];
|
||||
const idx = choice.index || 0;
|
||||
const delta = choice.delta || {};
|
||||
|
||||
// Emit initial events
|
||||
if (!state.started) {
|
||||
state.started = true;
|
||||
state.responseId = parsed.id ? `resp_${parsed.id}` : state.responseId;
|
||||
|
||||
emit(controller, "response.created", {
|
||||
type: "response.created",
|
||||
response: {
|
||||
id: state.responseId,
|
||||
object: "response",
|
||||
created_at: state.created,
|
||||
status: "in_progress",
|
||||
background: false,
|
||||
error: null,
|
||||
output: []
|
||||
}
|
||||
});
|
||||
|
||||
emit(controller, "response.in_progress", {
|
||||
type: "response.in_progress",
|
||||
response: {
|
||||
id: state.responseId,
|
||||
object: "response",
|
||||
created_at: state.created,
|
||||
status: "in_progress"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle reasoning_content (OpenAI native format)
|
||||
if (delta.reasoning_content) {
|
||||
startReasoning(controller, idx);
|
||||
emitReasoningDelta(controller, delta.reasoning_content);
|
||||
}
|
||||
|
||||
// Handle text content (may contain <think> tags)
|
||||
if (delta.content) {
|
||||
let content = delta.content;
|
||||
|
||||
if (content.includes("<think>")) {
|
||||
state.inThinking = true;
|
||||
content = content.replace("<think>", "");
|
||||
startReasoning(controller, idx);
|
||||
}
|
||||
|
||||
if (content.includes("</think>")) {
|
||||
const parts = content.split("</think>");
|
||||
const thinkPart = parts[0];
|
||||
const textPart = parts.slice(1).join("</think>");
|
||||
|
||||
if (thinkPart) emitReasoningDelta(controller, thinkPart);
|
||||
closeReasoning(controller);
|
||||
state.inThinking = false;
|
||||
content = textPart;
|
||||
}
|
||||
|
||||
if (state.inThinking && content) {
|
||||
emitReasoningDelta(controller, content);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular text content
|
||||
if (content) {
|
||||
if (!state.msgItemAdded[idx]) {
|
||||
state.msgItemAdded[idx] = true;
|
||||
const msgId = `msg_${state.responseId}_${idx}`;
|
||||
|
||||
emit(controller, "response.output_item.added", {
|
||||
type: "response.output_item.added",
|
||||
output_index: idx,
|
||||
item: { id: msgId, type: "message", content: [], role: "assistant" }
|
||||
});
|
||||
}
|
||||
|
||||
if (!state.msgContentAdded[idx]) {
|
||||
state.msgContentAdded[idx] = true;
|
||||
|
||||
emit(controller, "response.content_part.added", {
|
||||
type: "response.content_part.added",
|
||||
item_id: `msg_${state.responseId}_${idx}`,
|
||||
output_index: idx,
|
||||
content_index: 0,
|
||||
part: { type: "output_text", annotations: [], logprobs: [], text: "" }
|
||||
});
|
||||
}
|
||||
|
||||
emit(controller, "response.output_text.delta", {
|
||||
type: "response.output_text.delta",
|
||||
item_id: `msg_${state.responseId}_${idx}`,
|
||||
output_index: idx,
|
||||
content_index: 0,
|
||||
delta: content,
|
||||
logprobs: []
|
||||
});
|
||||
|
||||
if (!state.msgTextBuf[idx]) state.msgTextBuf[idx] = "";
|
||||
state.msgTextBuf[idx] += content;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tool_calls
|
||||
if (delta.tool_calls) {
|
||||
closeMessage(controller, idx);
|
||||
|
||||
for (const tc of delta.tool_calls) {
|
||||
const tcIdx = tc.index ?? 0;
|
||||
const newCallId = tc.id;
|
||||
const funcName = tc.function?.name;
|
||||
|
||||
if (funcName) state.funcNames[tcIdx] = funcName;
|
||||
|
||||
if (!state.funcCallIds[tcIdx] && newCallId) {
|
||||
state.funcCallIds[tcIdx] = newCallId;
|
||||
|
||||
emit(controller, "response.output_item.added", {
|
||||
type: "response.output_item.added",
|
||||
output_index: tcIdx,
|
||||
item: {
|
||||
id: `fc_${newCallId}`,
|
||||
type: "function_call",
|
||||
arguments: "",
|
||||
call_id: newCallId,
|
||||
name: state.funcNames[tcIdx] || ""
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!state.funcArgsBuf[tcIdx]) state.funcArgsBuf[tcIdx] = "";
|
||||
|
||||
if (tc.function?.arguments) {
|
||||
const refCallId = state.funcCallIds[tcIdx] || newCallId;
|
||||
if (refCallId) {
|
||||
emit(controller, "response.function_call_arguments.delta", {
|
||||
type: "response.function_call_arguments.delta",
|
||||
item_id: `fc_${refCallId}`,
|
||||
output_index: tcIdx,
|
||||
delta: tc.function.arguments
|
||||
});
|
||||
}
|
||||
state.funcArgsBuf[tcIdx] += tc.function.arguments;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle finish_reason
|
||||
if (choice.finish_reason) {
|
||||
for (const i in state.msgItemAdded) closeMessage(controller, i);
|
||||
closeReasoning(controller);
|
||||
for (const i in state.funcCallIds) closeToolCall(controller, i);
|
||||
sendCompleted(controller);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
flush(controller) {
|
||||
for (const i in state.msgItemAdded) closeMessage(controller, i);
|
||||
closeReasoning(controller);
|
||||
for (const i in state.funcCallIds) closeToolCall(controller, i);
|
||||
sendCompleted(controller);
|
||||
|
||||
logger?.logOutput("data: [DONE]");
|
||||
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
||||
logger?.flush();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* Stream-to-JSON Converter
|
||||
* Converts Responses API SSE stream to single JSON response
|
||||
* Used when client requests non-streaming but provider forces streaming (e.g., Codex)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Process a single SSE message and update state accordingly.
|
||||
*/
|
||||
function processSSEMessage(msg, state) {
|
||||
if (!msg.trim()) return;
|
||||
|
||||
const eventMatch = msg.match(/^event:\s*(.+)$/m);
|
||||
const dataMatch = msg.match(/^data:\s*(.+)$/m);
|
||||
if (!eventMatch || !dataMatch) return;
|
||||
|
||||
const eventType = eventMatch[1].trim();
|
||||
const dataStr = dataMatch[1].trim();
|
||||
if (dataStr === "[DONE]") return;
|
||||
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(dataStr); }
|
||||
catch { return; }
|
||||
|
||||
if (eventType === "response.created") {
|
||||
state.responseId = parsed.response?.id || state.responseId;
|
||||
state.created = parsed.response?.created_at || state.created;
|
||||
} else if (eventType === "response.output_item.done") {
|
||||
state.items.set(parsed.output_index ?? 0, parsed.item);
|
||||
} else if (eventType === "response.completed") {
|
||||
state.status = "completed";
|
||||
if (parsed.response?.usage) {
|
||||
state.usage.input_tokens = parsed.response.usage.input_tokens || 0;
|
||||
state.usage.output_tokens = parsed.response.usage.output_tokens || 0;
|
||||
state.usage.total_tokens = parsed.response.usage.total_tokens || 0;
|
||||
}
|
||||
} else if (eventType === "response.failed") {
|
||||
state.status = "failed";
|
||||
}
|
||||
}
|
||||
|
||||
const EMPTY_RESPONSE = { input_tokens: 0, output_tokens: 0, total_tokens: 0 };
|
||||
|
||||
/**
|
||||
* Convert Responses API SSE stream to single JSON response
|
||||
* @param {ReadableStream} stream - SSE stream from provider
|
||||
* @returns {Promise<Object>} Final JSON response in Responses API format
|
||||
*/
|
||||
export async function convertResponsesStreamToJson(stream) {
|
||||
if (!stream || typeof stream.getReader !== "function") {
|
||||
return { id: `resp_${Date.now()}`, object: "response", created_at: Math.floor(Date.now() / 1000), status: "failed", output: [], usage: { ...EMPTY_RESPONSE } };
|
||||
}
|
||||
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
const state = {
|
||||
responseId: "",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
status: "in_progress",
|
||||
usage: { ...EMPTY_RESPONSE },
|
||||
items: new Map()
|
||||
};
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const messages = buffer.split("\n\n");
|
||||
buffer = messages.pop() || "";
|
||||
|
||||
for (const msg of messages) {
|
||||
processSSEMessage(msg, state);
|
||||
}
|
||||
}
|
||||
|
||||
// Flush remaining buffer (last event may not end with \n\n)
|
||||
if (buffer.trim()) {
|
||||
processSSEMessage(buffer, state);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
// Build output array from accumulated items (ordered by index)
|
||||
const output = [];
|
||||
const maxIndex = state.items.size > 0 ? Math.max(...state.items.keys()) : -1;
|
||||
for (let i = 0; i <= maxIndex; i++) {
|
||||
output.push(state.items.get(i) || { type: "message", content: [], role: "assistant" });
|
||||
}
|
||||
|
||||
return {
|
||||
id: state.responseId || `resp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
object: "response",
|
||||
created_at: state.created,
|
||||
status: state.status || "completed",
|
||||
output,
|
||||
usage: state.usage
|
||||
};
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
// 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",
|
||||
OLLAMA: "ollama"
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect source format from request URL pathname + body.
|
||||
* Returns null to fall back to body-based detection.
|
||||
*/
|
||||
export function detectFormatByEndpoint(pathname, body) {
|
||||
// /v1/messages is always claude/anthropic format
|
||||
if (pathname.includes("/v1/messages")) return FORMATS.CLAUDE;
|
||||
|
||||
// /v1/responses is always openai-responses
|
||||
if (pathname.includes("/v1/responses")) return FORMATS.OPENAI_RESPONSES;
|
||||
|
||||
// /v1/chat/completions + input[] → treat as openai (Cursor CLI sends Responses body via chat endpoint)
|
||||
if (pathname.includes("/v1/chat/completions") && Array.isArray(body?.input)) {
|
||||
return FORMATS.OPENAI;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
// Claude helper functions for translator
|
||||
import { DEFAULT_THINKING_CLAUDE_SIGNATURE } from "../../config/defaultThinkingSignature.js";
|
||||
import { adjustMaxTokens } from "./maxTokensHelper.js";
|
||||
import { applyCloaking } from "../../utils/claudeCloaking.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
|
||||
// - Apply cloaking (billing header + fake user ID) for OAuth tokens
|
||||
export function prepareClaudeRequest(body, provider = null, apiKey = 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 endpoint only
|
||||
if (provider === "claude") {
|
||||
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: filter built-in tools for non-Anthropic providers, then handle cache_control
|
||||
if (body.tools && Array.isArray(body.tools)) {
|
||||
// Strip built-in tools (e.g. web_search_20250305) for providers that don't support them
|
||||
if (provider !== "claude") {
|
||||
body.tools = body.tools.filter(tool => !tool.type || tool.type === "function");
|
||||
}
|
||||
|
||||
// Fix: Anthropic's API rejects empty domain lists on the web_search
|
||||
// tool with "Empty list of domains is ambiguous". The Claude Code CLI
|
||||
// sends both `blocked_domains: []` and `allowed_domains: []` meaning
|
||||
// "no restrictions", but the API wants the fields omitted entirely
|
||||
// when empty. Clean up here so the fix applies to both direct-API
|
||||
// and 9Router-subscription paths.
|
||||
for (const tool of body.tools) {
|
||||
if (tool.type && tool.type.startsWith("web_search")) {
|
||||
if (Array.isArray(tool.blocked_domains) && tool.blocked_domains.length === 0) {
|
||||
delete tool.blocked_domains;
|
||||
}
|
||||
if (Array.isArray(tool.allowed_domains) && tool.allowed_domains.length === 0) {
|
||||
delete tool.allowed_domains;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
// Remove tools array and tool_choice if empty after filtering
|
||||
if (body.tools.length === 0) {
|
||||
delete body.tools;
|
||||
delete body.tool_choice;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply cloaking for OAuth tokens (billing header + fake user ID)
|
||||
if (provider === "claude" && apiKey) {
|
||||
body = applyCloaking(body, apiKey);
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
@@ -1,337 +0,0 @@
|
||||
// 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 (binary-compatible format: UUID + timestamp)
|
||||
export function generateSessionId() {
|
||||
return crypto.randomUUID() + Date.now().toString();
|
||||
}
|
||||
|
||||
// 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
|
||||
// Also strips all vendor extension fields (x- prefixed) not supported by Gemini
|
||||
function removeUnsupportedKeywords(obj, keywords) {
|
||||
if (!obj || typeof obj !== "object") return;
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
for (const item of obj) {
|
||||
removeUnsupportedKeywords(item, keywords);
|
||||
}
|
||||
} else {
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (keywords.includes(key) || key.startsWith("x-")) {
|
||||
delete obj[key];
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { DEFAULT_MAX_TOKENS, DEFAULT_MIN_TOKENS } from "../../config/runtimeConfig.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
|
||||
if (body.tools && Array.isArray(body.tools) && body.tools.length > 0) {
|
||||
if (maxTokens < DEFAULT_MIN_TOKENS) {
|
||||
maxTokens = DEFAULT_MIN_TOKENS;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure max_tokens > thinking.budget_tokens (Claude API requirement)
|
||||
if (body.thinking?.budget_tokens && maxTokens <= body.thinking.budget_tokens) {
|
||||
maxTokens = DEFAULT_MAX_TOKENS;
|
||||
}
|
||||
|
||||
return maxTokens;
|
||||
}
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
// 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: thinking, redacted_thinking, signature, and other non-OpenAI blocks
|
||||
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 = [];
|
||||
|
||||
for (const block of msg.content) {
|
||||
// Skip thinking blocks
|
||||
if (block.type === "thinking" || block.type === "redacted_thinking") continue;
|
||||
|
||||
// Only keep valid OpenAI content types
|
||||
if (VALID_OPENAI_CONTENT_TYPES.includes(block.type)) {
|
||||
// Remove signature field if exists
|
||||
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: "" });
|
||||
}
|
||||
|
||||
return { ...msg, content: filteredContent };
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
/**
|
||||
* Normalize Responses API input to array format.
|
||||
* Accepts string or array, returns array of message items.
|
||||
* @param {string|Array} input - raw input from Responses API body
|
||||
* @returns {Array|null} normalized array or null if invalid
|
||||
*/
|
||||
export function normalizeResponsesInput(input) {
|
||||
if (typeof input === "string") {
|
||||
const text = input.trim() === "" ? "..." : input;
|
||||
return [{ type: "message", role: "user", content: [{ type: "input_text", text }] }];
|
||||
}
|
||||
if (Array.isArray(input)) return input;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = [];
|
||||
|
||||
const inputItems = normalizeResponsesInput(body.input);
|
||||
if (!inputItems) return body;
|
||||
|
||||
for (const item of inputItems) {
|
||||
// Determine item type - Droid CLI sends role-based items without 'type' field
|
||||
// Fallback: if no type but has role property, treat as message
|
||||
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, input_image → image_url
|
||||
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 };
|
||||
if (c.type === "input_image") {
|
||||
const url = c.image_url || c.file_id || "";
|
||||
return { type: "image_url", image_url: { url, detail: c.detail || "auto" } };
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
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
|
||||
const requestRegistry = new Map();
|
||||
const responseRegistry = new Map();
|
||||
|
||||
// Track initialization state
|
||||
let initialized = false;
|
||||
|
||||
// Register translator
|
||||
export function register(from, to, requestFn, responseFn) {
|
||||
const key = `${from}:${to}`;
|
||||
if (requestFn) {
|
||||
requestRegistry.set(key, requestFn);
|
||||
}
|
||||
if (responseFn) {
|
||||
responseRegistry.set(key, responseFn);
|
||||
}
|
||||
}
|
||||
|
||||
// Lazy load translators (called once on first use)
|
||||
function ensureInitialized() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
// Request translators - sync require pattern for bundler
|
||||
require("./request/claude-to-openai.js");
|
||||
require("./request/openai-to-claude.js");
|
||||
require("./request/gemini-to-openai.js");
|
||||
require("./request/openai-to-gemini.js");
|
||||
require("./request/antigravity-to-openai.js");
|
||||
require("./request/openai-responses.js");
|
||||
require("./request/openai-to-kiro.js");
|
||||
require("./request/openai-to-cursor.js");
|
||||
require("./request/openai-to-ollama.js");
|
||||
|
||||
// Response translators
|
||||
require("./response/claude-to-openai.js");
|
||||
require("./response/openai-to-claude.js");
|
||||
require("./response/gemini-to-openai.js");
|
||||
require("./response/openai-to-antigravity.js");
|
||||
require("./response/openai-responses.js");
|
||||
require("./response/kiro-to-openai.js");
|
||||
require("./response/cursor-to-openai.js");
|
||||
require("./response/ollama-to-openai.js");
|
||||
}
|
||||
|
||||
// Translate request: source -> openai -> target
|
||||
export function translateRequest(sourceFormat, targetFormat, model, body, stream = true, credentials = null, provider = null, reqLogger = null) {
|
||||
ensureInitialized();
|
||||
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) {
|
||||
// Step 1: source -> openai (if source is not openai)
|
||||
if (sourceFormat !== FORMATS.OPENAI) {
|
||||
const toOpenAI = requestRegistry.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 = requestRegistry.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) {
|
||||
const apiKey = credentials?.accessToken || credentials?.apiKey || null;
|
||||
result = prepareClaudeRequest(result, provider, apiKey);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Translate response chunk: target -> openai -> source
|
||||
export function translateResponse(targetFormat, sourceFormat, chunk, state) {
|
||||
ensureInitialized();
|
||||
// If same format, return as-is
|
||||
if (sourceFormat === targetFormat) {
|
||||
return [chunk];
|
||||
}
|
||||
|
||||
let results = [chunk];
|
||||
let openaiResults = null; // Store OpenAI intermediate results
|
||||
|
||||
// Step 1: target -> openai (if target is not openai)
|
||||
if (targetFormat !== FORMATS.OPENAI) {
|
||||
const toOpenAI = responseRegistry.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 = responseRegistry.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 (kept for backward compatibility)
|
||||
export function initTranslators() {
|
||||
ensureInitialized();
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
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);
|
||||
@@ -1,241 +0,0 @@
|
||||
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 — strip the claude-agent-sdk's "You are Claude Code..."
|
||||
// identity sentence before forwarding to non-Anthropic providers. Without
|
||||
// this, Gemini/Codex/etc receive the Claude Code preset's first line as
|
||||
// gospel and dutifully introduce themselves as Claude. The rest of the
|
||||
// preset (tool conventions, scaffolding) stays intact.
|
||||
if (body.system) {
|
||||
let systemContent = Array.isArray(body.system)
|
||||
? body.system.map(s => s.text || "").join("\n")
|
||||
: body.system;
|
||||
|
||||
systemContent = systemContent.replace(
|
||||
/^You are [^.]*?(?:Claude Code|Claude agent)[^.]*?\.\s*/,
|
||||
""
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
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);
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
/**
|
||||
* 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";
|
||||
import { normalizeResponsesInput } from "../helpers/responsesApiHelper.js";
|
||||
|
||||
/**
|
||||
* Convert OpenAI Responses API request to OpenAI Chat Completions format
|
||||
*/
|
||||
export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) {
|
||||
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 pendingToolResults = [];
|
||||
|
||||
const inputItems = normalizeResponsesInput(body.input);
|
||||
if (!inputItems) return body;
|
||||
|
||||
for (const item of inputItems) {
|
||||
// Determine item type - Droid CLI sends role-based items without 'type' field
|
||||
// Fallback: if no type but has role property, treat as message
|
||||
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, input_image → image_url
|
||||
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 };
|
||||
if (c.type === "input_image") {
|
||||
const url = c.image_url || c.file_id || "";
|
||||
return { type: "image_url", image_url: { url, detail: c.detail || "auto" } };
|
||||
}
|
||||
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.
|
||||
// Responses API supports "hosted" tools (e.g. { type: "request_user_input" }) that carry no
|
||||
// explicit `name` field and cannot be represented as Chat Completions function declarations.
|
||||
// Filter them out to avoid sending nameless functionDeclarations to downstream providers
|
||||
// such as Gemini, which strictly validates function names.
|
||||
if (body.tools && Array.isArray(body.tools)) {
|
||||
result.tools = body.tools
|
||||
.map(tool => {
|
||||
// Already in Chat Completions format: { type: "function", function: { name, ... } }
|
||||
if (tool.function) return tool;
|
||||
// Responses API function tool: { type: "function", name, description, parameters }
|
||||
// Only convert when a non-empty name is present; skip hosted tools without one.
|
||||
const name = tool.name;
|
||||
if (!name || typeof name !== "string" || name.trim() === "") return null;
|
||||
return {
|
||||
type: "function",
|
||||
function: {
|
||||
name,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters,
|
||||
strict: tool.strict
|
||||
}
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Body already in Responses API format (e.g. Cursor CLI calling /chat/completions with input[])
|
||||
if (body.input) return { ...body, model, stream: true };
|
||||
|
||||
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/assistant messages to input items
|
||||
if (msg.role === "user" || msg.role === "assistant") {
|
||||
const contentType = msg.role === "user" ? "input_text" : "output_text";
|
||||
const content = typeof msg.content === "string"
|
||||
? [{ type: contentType, text: msg.content }]
|
||||
: Array.isArray(msg.content)
|
||||
? msg.content.map(c => {
|
||||
if (c.type === "text") return { type: contentType, text: c.text };
|
||||
// Convert Chat Completions image_url → Responses API input_image
|
||||
// Responses API expects: { type: "input_image", image_url: "<url string>" }
|
||||
// Chat Completions sends: { type: "image_url", image_url: { url: "...", detail: "..." } }
|
||||
if (c.type === "image_url") {
|
||||
const url = typeof c.image_url === "string" ? c.image_url : c.image_url?.url;
|
||||
return { type: "input_image", image_url: url, detail: c.image_url?.detail || "auto" };
|
||||
}
|
||||
if (c.type === "input_image") return c;
|
||||
// Serialize any unknown type (tool_use, tool_result, thinking, etc.) as text
|
||||
const text = c.text || c.content || JSON.stringify(c);
|
||||
return { type: contentType, text: typeof text === "string" ? text : JSON.stringify(text) };
|
||||
})
|
||||
: [];
|
||||
|
||||
// Only push a message block if content is non-empty.
|
||||
// Assistant messages with only tool_calls have content: null — skip the
|
||||
// message block in that case; the tool_calls are pushed separately below.
|
||||
if (content.length > 0) {
|
||||
result.input.push({
|
||||
type: "message",
|
||||
role: msg.role,
|
||||
content
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Convert tool calls
|
||||
if (msg.role === "assistant" && 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 - output must be a string for Responses API
|
||||
if (msg.role === "tool") {
|
||||
const output = typeof msg.content === "string"
|
||||
? msg.content
|
||||
: Array.isArray(msg.content)
|
||||
? msg.content.map(c => c.text || JSON.stringify(c)).join("")
|
||||
: JSON.stringify(msg.content);
|
||||
result.input.push({
|
||||
type: "function_call_output",
|
||||
call_id: msg.tool_call_id,
|
||||
output
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If no system message, leave instructions empty (will be filled by executor)
|
||||
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);
|
||||
@@ -1,348 +0,0 @@
|
||||
import { register } from "../index.js";
|
||||
import { FORMATS } from "../formats.js";
|
||||
import { CLAUDE_SYSTEM_PROMPT } from "../../config/appConstants.js";
|
||||
import { adjustMaxTokens } from "../helpers/maxTokensHelper.js";
|
||||
|
||||
// Empty prefix matches real Claude Code behavior (no tool name prefix).
|
||||
// Previously "proxy_" was used but this is a detectable fingerprint difference.
|
||||
const CLAUDE_OAUTH_TOOL_PREFIX = "";
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle response_format for JSON mode
|
||||
if (body.response_format) {
|
||||
const responseFormat = body.response_format;
|
||||
if (responseFormat.type === "json_schema" && responseFormat.json_schema?.schema) {
|
||||
const schemaJson = JSON.stringify(responseFormat.json_schema.schema, null, 2);
|
||||
systemParts.push(`You must respond with valid JSON that strictly follows this JSON schema:
|
||||
\`\`\`json
|
||||
${schemaJson}
|
||||
\`\`\`
|
||||
Respond ONLY with the JSON object, no other text.`);
|
||||
} else if (responseFormat.type === "json_object") {
|
||||
systemParts.push("You must respond with valid JSON. Respond ONLY with a JSON object, no other text.");
|
||||
}
|
||||
}
|
||||
|
||||
// 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 = [];
|
||||
for (const tool of body.tools) {
|
||||
// Pass-through built-in tools (e.g. web_search_20250305) without prefix or conversion
|
||||
const toolType = tool.type;
|
||||
if (toolType && toolType !== "function") {
|
||||
result.tools.push(tool);
|
||||
continue;
|
||||
}
|
||||
|
||||
const toolData = toolType === "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);
|
||||
|
||||
result.tools.push({
|
||||
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") {
|
||||
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_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);
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
/**
|
||||
* OpenAI to Cursor Request Translator
|
||||
* Converts OpenAI messages to Cursor ask/agent format.
|
||||
*
|
||||
* Important: Cursor can loop when tool outputs are sent via protobuf tool_results
|
||||
* with partial schema mismatches. For stability, tool outputs are represented as
|
||||
* structured text blocks in user messages.
|
||||
*/
|
||||
import { register } from "../index.js";
|
||||
import { FORMATS } from "../formats.js";
|
||||
|
||||
function extractContent(content) {
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.filter(part => {
|
||||
if (!part || typeof part !== "object") return false;
|
||||
return part.type === "text" && typeof part.text === "string";
|
||||
})
|
||||
.map(part => part.text || "")
|
||||
.join("");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function sanitizeToolResultText(text) {
|
||||
// Strip non-printable control chars that can produce backend request errors
|
||||
return text.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "");
|
||||
}
|
||||
|
||||
function escapeXml(text) {
|
||||
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function buildToolResultBlock(toolName, toolCallId, resultText) {
|
||||
const cleanResult = sanitizeToolResultText(resultText || "");
|
||||
return [
|
||||
"<tool_result>",
|
||||
`<tool_name>${escapeXml(toolName || "tool")}</tool_name>`,
|
||||
`<tool_call_id>${escapeXml(toolCallId || "")}</tool_call_id>`,
|
||||
`<result>${escapeXml(cleanResult)}</result>`,
|
||||
"</tool_result>"
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function normalizeToolCallId(id) {
|
||||
return typeof id === "string" ? id.split("\n")[0] : "";
|
||||
}
|
||||
|
||||
function convertMessages(messages) {
|
||||
const result = [];
|
||||
|
||||
// Build a map of tool_call_id -> tool name from assistant tool calls
|
||||
const toolCallMetaMap = new Map();
|
||||
const rememberToolMeta = (toolCallId, toolName) => {
|
||||
if (!toolCallId) return;
|
||||
const name = toolName || "tool";
|
||||
toolCallMetaMap.set(toolCallId, { name });
|
||||
const normalized = normalizeToolCallId(toolCallId);
|
||||
if (normalized && normalized !== toolCallId) {
|
||||
toolCallMetaMap.set(normalized, { name });
|
||||
}
|
||||
};
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "assistant" && msg.tool_calls) {
|
||||
for (const tc of msg.tool_calls) {
|
||||
rememberToolMeta(tc.id || "", tc.function?.name || "tool");
|
||||
}
|
||||
}
|
||||
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
||||
for (const part of msg.content) {
|
||||
if (part?.type !== "tool_use") continue;
|
||||
rememberToolMeta(part.id || "", part.name || "tool");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
|
||||
if (msg.role === "system") {
|
||||
result.push({
|
||||
role: "user",
|
||||
content: `[System Instructions]\n${extractContent(msg.content)}`
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.role === "tool") {
|
||||
const toolContent = extractContent(msg.content);
|
||||
const toolCallId = msg.tool_call_id || "";
|
||||
const toolMeta = toolCallMetaMap.get(toolCallId) || {};
|
||||
const toolName = msg.name || toolMeta.name || "tool";
|
||||
result.push({
|
||||
role: "user",
|
||||
content: buildToolResultBlock(toolName, toolCallId, toolContent)
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.role === "user" || msg.role === "assistant") {
|
||||
if (msg.role === "user" && Array.isArray(msg.content)) {
|
||||
const parts = [];
|
||||
for (const block of msg.content) {
|
||||
if (!block || typeof block !== "object") continue;
|
||||
if (block.type === "text") {
|
||||
if (typeof block.text === "string") {
|
||||
parts.push(block.text || "");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (block.type === "tool_result") {
|
||||
const toolCallId = block.tool_use_id || "";
|
||||
const toolMeta =
|
||||
toolCallMetaMap.get(toolCallId) ||
|
||||
toolCallMetaMap.get(normalizeToolCallId(toolCallId));
|
||||
const toolName = toolMeta?.name || "tool";
|
||||
const toolContent = extractContent(block.content);
|
||||
parts.push(buildToolResultBlock(toolName, toolCallId, toolContent));
|
||||
}
|
||||
}
|
||||
const joined = parts.filter(Boolean).join("\n");
|
||||
if (joined) result.push({ role: "user", content: joined });
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = extractContent(msg.content);
|
||||
|
||||
if (msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0) {
|
||||
const assistantMsg = { role: "assistant", content: content || "" };
|
||||
assistantMsg.tool_calls = msg.tool_calls.map(tc => {
|
||||
const { index, ...rest } = tc || {};
|
||||
return rest;
|
||||
});
|
||||
result.push(assistantMsg);
|
||||
} else if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
||||
const extractedToolCalls = msg.content
|
||||
.filter(b => b?.type === "tool_use")
|
||||
.map(b => ({
|
||||
id: b.id || "",
|
||||
type: "function",
|
||||
function: {
|
||||
name: b.name || "tool",
|
||||
arguments: JSON.stringify(b.input || {})
|
||||
}
|
||||
}))
|
||||
.filter(tc => tc.id);
|
||||
|
||||
if (extractedToolCalls.length > 0) {
|
||||
result.push({
|
||||
role: "assistant",
|
||||
content: content || "",
|
||||
tool_calls: extractedToolCalls
|
||||
});
|
||||
} else if (content) {
|
||||
result.push({ role: "assistant", content });
|
||||
}
|
||||
} else {
|
||||
if (content) {
|
||||
result.push({ role: msg.role, content });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function buildCursorRequest(model, body, stream, credentials) {
|
||||
const messages = convertMessages(body.messages || []);
|
||||
// Strip fields irrelevant to Cursor (OpenAI/Anthropic-specific)
|
||||
const { user, metadata, tool_choice, stream_options, system, ...rest } = body;
|
||||
return {
|
||||
...rest,
|
||||
messages,
|
||||
max_tokens: 32000
|
||||
};
|
||||
}
|
||||
|
||||
register(FORMATS.OPENAI, FORMATS.CURSOR, buildCursorRequest, null);
|
||||
@@ -1,449 +0,0 @@
|
||||
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/appConstants.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";
|
||||
import { deriveSessionId } from "../../utils/sessionManager.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 = [];
|
||||
|
||||
// Gemini 3 models require a valid per-session `thoughtSignature`
|
||||
// on thinking blocks and function calls. Real signatures are lost
|
||||
// during the Gemini→OpenAI→Claude→OpenAI→Gemini translation
|
||||
// round-trip (no intermediate format has a field for them).
|
||||
//
|
||||
// Google's official workaround for proxy/translation layers:
|
||||
// set thoughtSignature to the literal "skip_thought_signature_validator"
|
||||
// which bypasses validation. Per Google's docs this is a "last
|
||||
// resort" that "negatively impacts model performance" because the
|
||||
// model can't build on its prior reasoning across turns — but it
|
||||
// lets multi-turn tool use work through any translation layer.
|
||||
// See: https://docs.cloud.google.com/vertex-ai/generative-ai/docs/thought-signatures
|
||||
const SKIP_SIG = "skip_thought_signature_validator";
|
||||
|
||||
if (msg.reasoning_content) {
|
||||
parts.push({
|
||||
thought: true,
|
||||
text: msg.reasoning_content
|
||||
});
|
||||
parts.push({
|
||||
thoughtSignature: SKIP_SIG,
|
||||
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: SKIP_SIG,
|
||||
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");
|
||||
|
||||
// Pass through thinking config from the incoming request. Gemini 3
|
||||
// models have thinking always-on (can't be disabled per Google's docs).
|
||||
// The thought-signature round-trip issue is handled by using
|
||||
// "skip_thought_signature_validator" on all function call and thinking
|
||||
// parts in the conversation history (see assistant message handling
|
||||
// above at lines ~87-125). This lets thinking work with the trade-off
|
||||
// that the model can't build on prior reasoning across turns.
|
||||
if (body.reasoning_effort) {
|
||||
const budgetMap = { low: 1024, medium: 8192, high: 32768 };
|
||||
const budget = budgetMap[body.reasoning_effort] || 8192;
|
||||
gemini.generationConfig.thinkingConfig = {
|
||||
thinkingBudget: budget,
|
||||
includeThoughts: true
|
||||
};
|
||||
}
|
||||
|
||||
if (body.thinking?.type === "enabled" && body.thinking.budget_tokens) {
|
||||
gemini.generationConfig.thinkingConfig = {
|
||||
thinkingBudget: body.thinking.budget_tokens,
|
||||
includeThoughts: 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: isAntigravity ? deriveSessionId(credentials?.email || credentials?.connectionId) : 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
|
||||
// Inject required default system prompt for Antigravity (double injection)
|
||||
const systemParts = [
|
||||
{ text: ANTIGRAVITY_DEFAULT_SYSTEM },
|
||||
{ text: `Please ignore the following [ignore]${ANTIGRAVITY_DEFAULT_SYSTEM}[/ignore]` }
|
||||
];
|
||||
|
||||
if (envelope.request.systemInstruction?.parts) {
|
||||
envelope.request.systemInstruction.parts.unshift(...systemParts);
|
||||
} else {
|
||||
envelope.request.systemInstruction = { role: "user", parts: systemParts };
|
||||
}
|
||||
|
||||
// Add toolConfig for Antigravity
|
||||
if (geminiCLI.tools?.length > 0) {
|
||||
envelope.request.toolConfig = {
|
||||
functionCallingConfig: { mode: "VALIDATED" }
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Keep safetySettings for Gemini CLI
|
||||
envelope.request.safetySettings = geminiCLI.safetySettings;
|
||||
}
|
||||
|
||||
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: deriveSessionId(credentials?.email || credentials?.connectionId),
|
||||
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 - double injection + user system prompt)
|
||||
const systemParts = [
|
||||
{ text: ANTIGRAVITY_DEFAULT_SYSTEM },
|
||||
{ text: `Please ignore the following [ignore]${ANTIGRAVITY_DEFAULT_SYSTEM}[/ignore]` }
|
||||
];
|
||||
|
||||
// Merge user system prompt from claudeRequest
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
// Merge existing systemInstruction parts (from contents conversion)
|
||||
if (envelope.request.systemInstruction?.parts) {
|
||||
envelope.request.systemInstruction.parts.unshift(...systemParts);
|
||||
} else {
|
||||
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);
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
/**
|
||||
* 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 pendingImages = [];
|
||||
let currentRole = null;
|
||||
|
||||
const flushPending = () => {
|
||||
if (currentRole === "user") {
|
||||
const content = pendingUserContent.join("\n\n").trim() || "continue";
|
||||
const userMsg = {
|
||||
userInputMessage: {
|
||||
content: content,
|
||||
modelId: ""
|
||||
}
|
||||
};
|
||||
|
||||
// Attach images if present (Kiro API supports images field)
|
||||
if (pendingImages.length > 0) {
|
||||
userMsg.userInputMessage.images = pendingImages;
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
const schema = t.function?.parameters || t.parameters || t.input_schema || {};
|
||||
// Normalize schema: Kiro requires required[] and proper type/properties
|
||||
const normalizedSchema = Object.keys(schema).length === 0
|
||||
? { type: "object", properties: {}, required: [] }
|
||||
: { ...schema, required: schema.required ?? [] };
|
||||
|
||||
return {
|
||||
toolSpecification: {
|
||||
name,
|
||||
description,
|
||||
inputSchema: { json: normalizedSchema }
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
history.push(userMsg);
|
||||
currentMessage = userMsg;
|
||||
pendingUserContent = [];
|
||||
pendingToolResults = [];
|
||||
pendingImages = [];
|
||||
} 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 = [];
|
||||
for (const c of msg.content) {
|
||||
if (c.type === "text" || c.text) {
|
||||
textParts.push(c.text || "");
|
||||
} else if (c.type === "image_url") {
|
||||
const url = c.image_url?.url || "";
|
||||
const base64Match = url.match(/^data:([^;]+);base64,(.+)$/);
|
||||
if (base64Match) {
|
||||
// Extract format from media type (e.g. "image/png" → "png")
|
||||
const mediaType = base64Match[1];
|
||||
const format = mediaType.split("/")[1] || mediaType;
|
||||
pendingImages.push({ format, source: { bytes: base64Match[2] } });
|
||||
} else if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||
// Kiro images field only supports base64 — fallback to URL text
|
||||
textParts.push(`[Image: ${url}]`);
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
@@ -1,278 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
function convertMessages(messages, tools, model) {
|
||||
let history = [];
|
||||
let currentMessage = null;
|
||||
let systemPrompt = "";
|
||||
|
||||
const toolResultsMap = new Map();
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "tool" && msg.tool_call_id) {
|
||||
const content = typeof msg.content === "string" ? msg.content :
|
||||
(Array.isArray(msg.content) ? msg.content.map(c => c.text || "").join("\n") : "");
|
||||
toolResultsMap.set(msg.tool_call_id, content);
|
||||
}
|
||||
|
||||
if (msg.role === "user" && Array.isArray(msg.content)) {
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "tool_result" && block.tool_use_id) {
|
||||
const content = Array.isArray(block.content)
|
||||
? block.content.map(c => c.text || "").join("\n")
|
||||
: (typeof block.content === "string" ? block.content : "");
|
||||
toolResultsMap.set(block.tool_use_id, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
const role = msg.role;
|
||||
|
||||
if (role === "tool") continue;
|
||||
|
||||
const content = typeof msg.content === "string" ? msg.content :
|
||||
(Array.isArray(msg.content) ? msg.content.map(c => c.text || "").join("\n") : "");
|
||||
|
||||
if (role === "system") {
|
||||
systemPrompt += (systemPrompt ? "\n" : "") + content;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (role === "user") {
|
||||
let finalContent = content;
|
||||
let toolResults = [];
|
||||
|
||||
// Check if this user message contains tool_result blocks
|
||||
if (Array.isArray(msg.content)) {
|
||||
const toolResultBlocks = msg.content.filter(c => c.type === "tool_result");
|
||||
if (toolResultBlocks.length > 0) {
|
||||
toolResults = toolResultBlocks.map(block => {
|
||||
const text = Array.isArray(block.content)
|
||||
? block.content.map(c => c.text || "").join("\n")
|
||||
: (typeof block.content === "string" ? block.content : "");
|
||||
|
||||
return {
|
||||
toolUseId: block.tool_use_id,
|
||||
status: "success",
|
||||
content: [{ text: text }]
|
||||
};
|
||||
});
|
||||
|
||||
// Set simple content when tool results exist
|
||||
finalContent = content || "Continue";
|
||||
}
|
||||
}
|
||||
|
||||
const userMsg = {
|
||||
userInputMessage: {
|
||||
content: finalContent,
|
||||
modelId: "",
|
||||
}
|
||||
};
|
||||
|
||||
// Add tool results to userInputMessageContext
|
||||
if (toolResults.length > 0) {
|
||||
if (!userMsg.userInputMessage.userInputMessageContext) {
|
||||
userMsg.userInputMessage.userInputMessageContext = {};
|
||||
}
|
||||
userMsg.userInputMessage.userInputMessageContext.toolResults = toolResults;
|
||||
}
|
||||
|
||||
// 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 || {}
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
currentMessage = userMsg;
|
||||
history.push(userMsg);
|
||||
}
|
||||
|
||||
if (role === "assistant") {
|
||||
// Extract text content and tool uses separately from content array
|
||||
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();
|
||||
}
|
||||
|
||||
// Fallback for OpenAI tool_calls format
|
||||
if (msg.tool_calls && msg.tool_calls.length > 0) {
|
||||
toolUses = msg.tool_calls;
|
||||
}
|
||||
|
||||
const assistantMsg = {
|
||||
assistantResponseMessage: {
|
||||
content: textContent || "Call tools"
|
||||
}
|
||||
};
|
||||
|
||||
if (toolUses.length > 0) {
|
||||
assistantMsg.assistantResponseMessage.toolUses = toolUses.map(tc => {
|
||||
if (tc.function) {
|
||||
// OpenAI format
|
||||
return {
|
||||
toolUseId: tc.id || uuidv4(),
|
||||
name: tc.function.name,
|
||||
input: typeof tc.function.arguments === "string"
|
||||
? JSON.parse(tc.function.arguments)
|
||||
: (tc.function.arguments || {})
|
||||
};
|
||||
} else {
|
||||
// Anthropic format
|
||||
return {
|
||||
toolUseId: tc.id || uuidv4(),
|
||||
name: tc.name,
|
||||
input: tc.input || {}
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
history.push(assistantMsg);
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
|
||||
// Merge consecutive user messages (Kiro requires alternating user/assistant)
|
||||
const mergedHistory = [];
|
||||
for (let i = 0; i < history.length; i++) {
|
||||
const current = history[i];
|
||||
|
||||
if (current.userInputMessage &&
|
||||
mergedHistory.length > 0 &&
|
||||
mergedHistory[mergedHistory.length - 1].userInputMessage) {
|
||||
const prev = mergedHistory[mergedHistory.length - 1];
|
||||
prev.userInputMessage.content += "\n\n" + current.userInputMessage.content;
|
||||
} else {
|
||||
mergedHistory.push(current);
|
||||
}
|
||||
}
|
||||
history = mergedHistory;
|
||||
|
||||
return { history, currentMessage, systemPrompt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Kiro payload from OpenAI format
|
||||
*/
|
||||
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, systemPrompt } = convertMessages(messages, tools, model);
|
||||
|
||||
const profileArn = credentials?.providerSpecificData?.profileArn || "";
|
||||
|
||||
let finalContent = currentMessage?.userInputMessage?.content || "";
|
||||
if (systemPrompt) {
|
||||
finalContent = `[System: ${systemPrompt}]\n\n${finalContent}`;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
export { buildKiroPayload };
|
||||
@@ -1,159 +0,0 @@
|
||||
import { register } from "../index.js";
|
||||
import { FORMATS } from "../formats.js";
|
||||
|
||||
/**
|
||||
* Convert OpenAI request to Ollama format
|
||||
*
|
||||
* Ollama expects:
|
||||
* - model: string
|
||||
* - messages: Array<{role: string, content: string}>
|
||||
* - stream: boolean
|
||||
* - options?: {temperature?: number, num_predict?: number}
|
||||
*
|
||||
* Key differences from OpenAI:
|
||||
* - Content must be string, not array
|
||||
* - No support for tool_calls in request (tools are handled differently)
|
||||
* - tool role maps to user
|
||||
*/
|
||||
export function openaiToOllamaRequest(model, body, stream) {
|
||||
const result = {
|
||||
model: model,
|
||||
messages: normalizeMessages(body.messages),
|
||||
stream: stream
|
||||
};
|
||||
|
||||
// Temperature
|
||||
if (body.temperature !== undefined) {
|
||||
result.options = result.options || {};
|
||||
result.options.temperature = body.temperature;
|
||||
}
|
||||
|
||||
// Max tokens (Ollama uses num_predict)
|
||||
if (body.max_tokens !== undefined) {
|
||||
result.options = result.options || {};
|
||||
result.options.num_predict = body.max_tokens;
|
||||
}
|
||||
|
||||
// Top_p
|
||||
if (body.top_p !== undefined) {
|
||||
result.options = result.options || {};
|
||||
result.options.top_p = body.top_p;
|
||||
}
|
||||
|
||||
// Tools (Ollama supports tools in OpenAI format)
|
||||
if (body.tools && Array.isArray(body.tools)) {
|
||||
result.tools = body.tools;
|
||||
}
|
||||
|
||||
// Tool choice
|
||||
if (body.tool_choice) {
|
||||
result.tool_choice = body.tool_choice;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize messages to Ollama format
|
||||
* - Content must be string
|
||||
* - tool messages: convert tool_call_id to tool_name
|
||||
* - assistant messages: keep tool_calls as-is
|
||||
*/
|
||||
function normalizeMessages(messages) {
|
||||
if (!Array.isArray(messages)) return messages;
|
||||
|
||||
const result = [];
|
||||
const toolCallMap = new Map(); // Map tool_call_id -> tool_name
|
||||
|
||||
// First pass: build tool_call_id -> tool_name map from assistant messages
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "assistant" && msg.tool_calls) {
|
||||
for (const tc of msg.tool_calls) {
|
||||
if (tc.id && tc.function?.name) {
|
||||
toolCallMap.set(tc.id, tc.function.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: convert messages
|
||||
for (const msg of messages) {
|
||||
// Handle tool result messages (OpenAI format -> Ollama format)
|
||||
if (msg.role === "tool") {
|
||||
const toolResult = normalizeContent(msg.content);
|
||||
if (!toolResult) continue;
|
||||
|
||||
// Get tool_name from map or use msg.name as fallback
|
||||
const toolName = toolCallMap.get(msg.tool_call_id) || msg.name || "unknown_tool";
|
||||
|
||||
result.push({
|
||||
role: "tool",
|
||||
tool_name: toolName,
|
||||
content: toolResult
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle assistant messages with tool_calls
|
||||
if (msg.role === "assistant" && msg.tool_calls) {
|
||||
const content = normalizeContent(msg.content) || "";
|
||||
|
||||
// Convert OpenAI tool_calls format to Ollama format
|
||||
const ollamaToolCalls = msg.tool_calls.map(tc => ({
|
||||
type: "function",
|
||||
function: {
|
||||
index: tc.index || 0,
|
||||
name: tc.function?.name || "",
|
||||
arguments: typeof tc.function?.arguments === "string"
|
||||
? JSON.parse(tc.function.arguments || "{}")
|
||||
: tc.function?.arguments || {}
|
||||
}
|
||||
}));
|
||||
|
||||
result.push({
|
||||
role: "assistant",
|
||||
content: content,
|
||||
tool_calls: ollamaToolCalls
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normal messages
|
||||
const role = msg.role;
|
||||
const content = normalizeContent(msg.content);
|
||||
|
||||
// Skip empty messages (except assistant)
|
||||
if (!content && role !== "assistant") continue;
|
||||
|
||||
result.push({
|
||||
role: role,
|
||||
content: content
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize content to string
|
||||
* Ollama only accepts string content
|
||||
*/
|
||||
function normalizeContent(content) {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
// Extract text from content array
|
||||
const textParts = content
|
||||
.filter(block => block && block.type === "text" && block.text)
|
||||
.map(block => block.text);
|
||||
|
||||
return textParts.join("\n") || "";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
// Register translator
|
||||
register(FORMATS.OPENAI, FORMATS.OLLAMA, openaiToOllamaRequest, null);
|
||||
@@ -1,206 +0,0 @@
|
||||
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 === "server_tool_use") {
|
||||
// Built-in tool (web search) - Claude handles internally, skip
|
||||
state.serverToolBlockIndex = chunk.index;
|
||||
break;
|
||||
}
|
||||
if (block?.type === "text") {
|
||||
state.textBlockStarted = true;
|
||||
} else if (block?.type === "thinking") {
|
||||
state.inThinkingBlock = true;
|
||||
state.currentBlockIndex = chunk.index;
|
||||
results.push(createChunk(state, { content: "<think>" }));
|
||||
} else if (block?.type === "tool_use") {
|
||||
const toolCallIndex = state.toolCallIndex++;
|
||||
// Restore original tool name from mapping (Claude OAuth)
|
||||
const toolName = state.toolNameMap?.get(block.name) || block.name;
|
||||
const toolCall = {
|
||||
index: toolCallIndex,
|
||||
id: block.id,
|
||||
type: "function",
|
||||
function: {
|
||||
name: toolName,
|
||||
arguments: ""
|
||||
}
|
||||
};
|
||||
state.toolCalls.set(chunk.index, toolCall);
|
||||
results.push(createChunk(state, { tool_calls: [toolCall] }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "content_block_delta": {
|
||||
// Skip deltas for built-in server tool blocks (web search)
|
||||
if (chunk.index === state.serverToolBlockIndex) break;
|
||||
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, { reasoning_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": {
|
||||
// Skip stop for built-in server tool blocks (web search)
|
||||
if (chunk.index === state.serverToolBlockIndex) {
|
||||
state.serverToolBlockIndex = -1;
|
||||
break;
|
||||
}
|
||||
if (state.inThinkingBlock && chunk.index === state.currentBlockIndex) {
|
||||
results.push(createChunk(state, { reasoning_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;
|
||||
|
||||
// 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,
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: outputTokens
|
||||
};
|
||||
|
||||
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 }]
|
||||
};
|
||||
|
||||
if (state.usage) {
|
||||
finalChunk.usage = {
|
||||
prompt_tokens: state.usage.prompt_tokens,
|
||||
completion_tokens: state.usage.completion_tokens,
|
||||
total_tokens: state.usage.total_tokens
|
||||
};
|
||||
|
||||
const cacheRead = state.usage.cache_read_input_tokens;
|
||||
const cacheCreate = state.usage.cache_creation_input_tokens;
|
||||
if (cacheRead > 0 || cacheCreate > 0) {
|
||||
finalChunk.usage.prompt_tokens_details = {};
|
||||
if (cacheRead > 0) finalChunk.usage.prompt_tokens_details.cached_tokens = cacheRead;
|
||||
if (cacheCreate > 0) finalChunk.usage.prompt_tokens_details.cache_creation_tokens = cacheCreate;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user