mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
Merge branch 'decolua:master' into master
This commit is contained in:
@@ -67,3 +67,4 @@ README1.md
|
|||||||
deploy.sh
|
deploy.sh
|
||||||
ecosystem.config.*
|
ecosystem.config.*
|
||||||
start.sh
|
start.sh
|
||||||
|
src/mitm/server2.js
|
||||||
|
|||||||
@@ -5,11 +5,7 @@
|
|||||||
|
|
||||||
**Never stop coding. Auto-route to FREE & cheap AI models with smart fallback.**
|
**Never stop coding. Auto-route to FREE & cheap AI models with smart fallback.**
|
||||||
|
|
||||||
**Free AI Provider for OpenClaw.**
|
**Connect All AI Code Tools (Claude Code, Cursor, Antigravity, Copilot, Codex, Gemini, OpenCode, Cline, OpenClaw...) to 40+ AI Providers & 100+ Models.**
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<img src="./public/providers/openclaw.png" alt="OpenClaw" width="80"/>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
[](https://www.npmjs.com/package/9router)
|
[](https://www.npmjs.com/package/9router)
|
||||||
[](https://www.npmjs.com/package/9router)
|
[](https://www.npmjs.com/package/9router)
|
||||||
@@ -1122,21 +1118,6 @@ Notes:
|
|||||||
**Dashboard opens on wrong port**
|
**Dashboard opens on wrong port**
|
||||||
- Set `PORT=20128` and `NEXT_PUBLIC_BASE_URL=http://localhost:20128`
|
- Set `PORT=20128` and `NEXT_PUBLIC_BASE_URL=http://localhost:20128`
|
||||||
|
|
||||||
**Cloud sync errors**
|
|
||||||
- Verify `BASE_URL` points to your running instance (example: `http://localhost:20128`)
|
|
||||||
- Verify `CLOUD_URL` points to your expected cloud endpoint (example: `https://9router.com`)
|
|
||||||
- Keep `NEXT_PUBLIC_*` values aligned with server-side values when possible.
|
|
||||||
|
|
||||||
**Cloud endpoint `stream=false` returns 500 (`Unexpected token 'd'...`)**
|
|
||||||
- Symptom usually appears on public cloud endpoint (`https://9router.com/v1`) for non-streaming calls.
|
|
||||||
- Root cause: upstream returns SSE payload (`data: ...`) while client expects JSON.
|
|
||||||
- Workaround: use `stream=true` for cloud direct calls.
|
|
||||||
- Local 9Router runtime includes SSE→JSON fallback for non-streaming calls when upstream returns `text/event-stream`.
|
|
||||||
|
|
||||||
**Cloud says connected, but request still fails with `Invalid API key`**
|
|
||||||
- Create a fresh key from local dashboard (`/api/keys`) and run cloud sync (`Enable Cloud` then `Sync Now`).
|
|
||||||
- Old/non-synced keys can still return `401` on cloud even if local endpoint works.
|
|
||||||
|
|
||||||
**First login not working**
|
**First login not working**
|
||||||
- Check `INITIAL_PASSWORD` in `.env`
|
- Check `INITIAL_PASSWORD` in `.env`
|
||||||
- If unset, fallback password is `123456`
|
- If unset, fallback password is `123456`
|
||||||
@@ -1184,80 +1165,6 @@ Authorization: Bearer your-api-key
|
|||||||
→ Returns all models + combos in OpenAI format
|
→ Returns all models + combos in OpenAI format
|
||||||
```
|
```
|
||||||
|
|
||||||
### Compatibility Endpoints
|
|
||||||
|
|
||||||
- `POST /v1/chat/completions`
|
|
||||||
- `POST /v1/messages`
|
|
||||||
- `POST /v1/responses`
|
|
||||||
- `GET /v1/models`
|
|
||||||
- `POST /v1/messages/count_tokens`
|
|
||||||
- `GET /v1beta/models`
|
|
||||||
- `POST /v1beta/models/{...path}` (Gemini-style `generateContent`)
|
|
||||||
- `POST /v1/api/chat` (Ollama-style transform path)
|
|
||||||
|
|
||||||
### Cloud Validation Scripts
|
|
||||||
|
|
||||||
Added test scripts under `tester/security/`:
|
|
||||||
|
|
||||||
- `tester/security/test-docker-hardening.sh`
|
|
||||||
- Builds Docker image and validates hardening checks (`/api/cloud/auth` auth guard, `REQUIRE_API_KEY`, secure auth cookie behavior).
|
|
||||||
- `tester/security/test-cloud-openai-compatible.sh`
|
|
||||||
- Sends a direct OpenAI-compatible request to cloud endpoint (`https://9router.com/v1/chat/completions`) with provided model/key.
|
|
||||||
- `tester/security/test-cloud-sync-and-call.sh`
|
|
||||||
- End-to-end flow: create local key -> enable/sync cloud -> call cloud endpoint with retry.
|
|
||||||
- Includes fallback check with `stream=true` to distinguish auth errors from non-streaming parse issues.
|
|
||||||
|
|
||||||
Security note for cloud test scripts:
|
|
||||||
|
|
||||||
- Never hardcode real API keys in scripts/commits.
|
|
||||||
- Provide keys only via environment variables:
|
|
||||||
- `API_KEY`, `CLOUD_API_KEY`, or `OPENAI_API_KEY` (supported by `test-cloud-openai-compatible.sh`)
|
|
||||||
- Example:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
OPENAI_API_KEY="your-cloud-key" bash tester/security/test-cloud-openai-compatible.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected behavior from recent validation:
|
|
||||||
|
|
||||||
- Local runtime (`http://127.0.0.1:20128/v1/chat/completions`): works with `stream=false` and `stream=true`.
|
|
||||||
- Docker runtime (same API path exposed by container): hardening checks pass, cloud auth guard works, strict API key mode works when enabled.
|
|
||||||
- Public cloud endpoint (`https://9router.com/v1/chat/completions`):
|
|
||||||
- `stream=true`: expected to succeed (SSE chunks returned).
|
|
||||||
- `stream=false`: may fail with `500` + parse error (`Unexpected token 'd'`) when upstream returns SSE content to a non-streaming client path.
|
|
||||||
|
|
||||||
### Dashboard and Management API
|
|
||||||
|
|
||||||
- Auth/settings: `/api/auth/login`, `/api/auth/logout`, `/api/settings`, `/api/settings/require-login`
|
|
||||||
- Provider management: `/api/providers`, `/api/providers/[id]`, `/api/providers/[id]/test`, `/api/providers/[id]/models`, `/api/providers/validate`, `/api/provider-nodes*`
|
|
||||||
- OAuth flows: `/api/oauth/[provider]/[action]` (+ provider-specific imports like Cursor/Kiro)
|
|
||||||
- Routing config: `/api/models/alias`, `/api/combos*`, `/api/keys*`, `/api/pricing`
|
|
||||||
- Usage/logs: `/api/usage/history`, `/api/usage/logs`, `/api/usage/request-logs`, `/api/usage/[connectionId]`
|
|
||||||
- Cloud sync: `/api/sync/cloud`, `/api/sync/initialize`, `/api/cloud/*`
|
|
||||||
- CLI helpers: `/api/cli-tools/claude-settings`, `/api/cli-tools/codex-settings`, `/api/cli-tools/droid-settings`, `/api/cli-tools/openclaw-settings`
|
|
||||||
|
|
||||||
### Authentication Behavior
|
|
||||||
|
|
||||||
- Dashboard routes (`/dashboard/*`) use `auth_token` cookie protection.
|
|
||||||
- Login uses saved password hash when present; otherwise it falls back to `INITIAL_PASSWORD`.
|
|
||||||
- `requireLogin` can be toggled via `/api/settings/require-login`.
|
|
||||||
|
|
||||||
### Request Processing (High Level)
|
|
||||||
|
|
||||||
1. Client sends request to `/v1/*`.
|
|
||||||
2. Route handler calls `handleChat` (`src/sse/handlers/chat.js`).
|
|
||||||
3. Model is resolved (direct provider/model or alias/combo resolution).
|
|
||||||
4. Credentials are selected from local DB with account availability filtering.
|
|
||||||
5. `handleChatCore` (`open-sse/handlers/chatCore.js`) detects format and translates request.
|
|
||||||
6. Provider executor sends upstream request.
|
|
||||||
7. Stream is translated back to client format when needed.
|
|
||||||
8. Usage/logging is recorded (`src/lib/usageDb.js`).
|
|
||||||
9. Fallback applies on provider/account/model errors according to combo rules.
|
|
||||||
|
|
||||||
Full architecture reference: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📧 Support
|
## 📧 Support
|
||||||
|
|
||||||
- **Website**: [9router.com](https://9router.com)
|
- **Website**: [9router.com](https://9router.com)
|
||||||
@@ -1278,17 +1185,7 @@ Thanks to all contributors who helped make 9Router better!
|
|||||||
|
|
||||||
[](https://starchart.cc/decolua/9router)
|
[](https://starchart.cc/decolua/9router)
|
||||||
|
|
||||||
### How to Contribute
|
|
||||||
|
|
||||||
1. Fork the repository
|
|
||||||
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
|
|
||||||
3. Commit your changes (`git commit -m 'Add amazing feature'`)
|
|
||||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
|
||||||
5. Open a Pull Request
|
|
||||||
|
|
||||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔀 Forks
|
## 🔀 Forks
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { handleChatCore } from "open-sse/handlers/chatCore.js";
|
|||||||
import { errorResponse } from "open-sse/utils/error.js";
|
import { errorResponse } from "open-sse/utils/error.js";
|
||||||
import { checkFallbackError, isAccountUnavailable, getUnavailableUntil, getEarliestRateLimitedUntil, formatRetryAfter } from "open-sse/services/accountFallback.js";
|
import { checkFallbackError, isAccountUnavailable, getUnavailableUntil, getEarliestRateLimitedUntil, formatRetryAfter } from "open-sse/services/accountFallback.js";
|
||||||
import { getComboModelsFromData, handleComboChat } from "open-sse/services/combo.js";
|
import { getComboModelsFromData, handleComboChat } from "open-sse/services/combo.js";
|
||||||
import { HTTP_STATUS } from "open-sse/config/constants.js";
|
import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
|
||||||
import * as log from "../utils/logger.js";
|
import * as log from "../utils/logger.js";
|
||||||
import { refreshTokenByProvider } from "../services/tokenRefresh.js";
|
import { refreshTokenByProvider } from "../services/tokenRefresh.js";
|
||||||
import { parseApiKey, extractBearerToken } from "../utils/apiKey.js";
|
import { parseApiKey, extractBearerToken } from "../utils/apiKey.js";
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
getUnavailableUntil,
|
getUnavailableUntil,
|
||||||
formatRetryAfter
|
formatRetryAfter
|
||||||
} from "open-sse/services/accountFallback.js";
|
} from "open-sse/services/accountFallback.js";
|
||||||
import { HTTP_STATUS } from "open-sse/config/constants.js";
|
import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
|
||||||
import * as log from "../utils/logger.js";
|
import * as log from "../utils/logger.js";
|
||||||
import { parseApiKey, extractBearerToken } from "../utils/apiKey.js";
|
import { parseApiKey, extractBearerToken } from "../utils/apiKey.js";
|
||||||
import { getMachineData, saveMachineData } from "../services/storage.js";
|
import { getMachineData, saveMachineData } from "../services/storage.js";
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 740 KiB |
@@ -0,0 +1,133 @@
|
|||||||
|
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 = {
|
||||||
|
"X-Client-Name": "antigravity",
|
||||||
|
"X-Client-Version": "1.107.0",
|
||||||
|
"x-goog-api-client": "gl-node/18.18.2 fire/0.8.6 grpc/1.10.x",
|
||||||
|
"User-Agent": "antigravity/1.107.0 darwin/arm64"
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI" }),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LOAD_CODE_ASSIST_METADATA = {
|
||||||
|
ideType: "IDE_UNSPECIFIED",
|
||||||
|
platform: "PLATFORM_UNSPECIFIED",
|
||||||
|
pluginType: "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,566 +1,4 @@
|
|||||||
import { platform, arch } from "os";
|
// Barrel re-export — consumers can migrate to specific files over time
|
||||||
|
export * from "./providers.js";
|
||||||
function mapStainlessOs() {
|
export * from "./appConstants.js";
|
||||||
switch (platform()) {
|
export * from "./runtimeConfig.js";
|
||||||
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()}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Gemini CLI Version Constants ===
|
|
||||||
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";
|
|
||||||
|
|
||||||
function mapGeminiCLIOs() {
|
|
||||||
switch (platform()) {
|
|
||||||
case "darwin": return "darwin";
|
|
||||||
case "win32": return "windows";
|
|
||||||
case "linux": return "linux";
|
|
||||||
case "freebsd": return "freebsd";
|
|
||||||
default: return platform();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapGeminiCLIArch() {
|
|
||||||
switch (arch()) {
|
|
||||||
case "x64": return "x64";
|
|
||||||
case "arm64": return "arm64";
|
|
||||||
case "ia32": return "x86";
|
|
||||||
default: return arch();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns User-Agent matching native Gemini CLI format: GeminiCLI/<version>/<model> (<os>; <arch>) */
|
|
||||||
export function geminiCLIUserAgent(model = "unknown") {
|
|
||||||
return `GeminiCLI/${GEMINI_CLI_VERSION}/${model || "unknown"} (${mapGeminiCLIOs()}; ${mapGeminiCLIArch()})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// === GitHub Copilot Version Constants ===
|
|
||||||
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 Binary Alignment: Numeric Enums ===
|
|
||||||
// Reference: Antigravity binary analysis - google.internal.cloud.code.v1internal.ClientMetadata
|
|
||||||
|
|
||||||
// IDE Type enum (numeric values as expected by Cloud Code API)
|
|
||||||
export const IDE_TYPE = {
|
|
||||||
UNSPECIFIED: 0,
|
|
||||||
JETSKI: 10, // Internal codename for Gemini CLI
|
|
||||||
ANTIGRAVITY: 9,
|
|
||||||
PLUGINS: 7
|
|
||||||
};
|
|
||||||
|
|
||||||
// Platform enum (as specified in Antigravity binary)
|
|
||||||
export const PLATFORM = {
|
|
||||||
UNSPECIFIED: 0,
|
|
||||||
DARWIN_AMD64: 1,
|
|
||||||
DARWIN_ARM64: 2,
|
|
||||||
LINUX_AMD64: 3,
|
|
||||||
LINUX_ARM64: 4,
|
|
||||||
WINDOWS_AMD64: 5
|
|
||||||
};
|
|
||||||
|
|
||||||
// Plugin type enum (as specified in Antigravity binary)
|
|
||||||
export const PLUGIN_TYPE = {
|
|
||||||
UNSPECIFIED: 0,
|
|
||||||
CLOUD_CODE: 1,
|
|
||||||
GEMINI: 2
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the platform enum value based on the current OS.
|
|
||||||
* @returns {number} Platform enum value
|
|
||||||
*/
|
|
||||||
export function getPlatformEnum() {
|
|
||||||
const os = platform();
|
|
||||||
const architecture = arch();
|
|
||||||
|
|
||||||
if (os === "darwin") {
|
|
||||||
return architecture === "arm64" ? PLATFORM.DARWIN_ARM64 : PLATFORM.DARWIN_AMD64;
|
|
||||||
} else if (os === "linux") {
|
|
||||||
return architecture === "arm64" ? PLATFORM.LINUX_ARM64 : PLATFORM.LINUX_AMD64;
|
|
||||||
} else if (os === "win32") {
|
|
||||||
return PLATFORM.WINDOWS_AMD64;
|
|
||||||
}
|
|
||||||
return PLATFORM.UNSPECIFIED;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate platform-specific User-Agent string.
|
|
||||||
* @returns {string} User-Agent in format "antigravity/version os/arch"
|
|
||||||
*/
|
|
||||||
export function getPlatformUserAgent() {
|
|
||||||
const os = platform();
|
|
||||||
const architecture = arch();
|
|
||||||
return `antigravity/1.104.0 ${os}/${architecture}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Centralized client metadata (used in request bodies for loadCodeAssist, onboardUser, etc.)
|
|
||||||
// Using numeric enum values as expected by the Cloud Code API
|
|
||||||
export const CLIENT_METADATA = {
|
|
||||||
ideType: IDE_TYPE.ANTIGRAVITY, // 9 - identifies as Antigravity client
|
|
||||||
platform: getPlatformEnum(), // Runtime platform detection
|
|
||||||
pluginType: PLUGIN_TYPE.GEMINI // 2
|
|
||||||
};
|
|
||||||
|
|
||||||
// Internal anti-loop header to identify requests originating from this proxy
|
|
||||||
export const INTERNAL_REQUEST_HEADER = { name: "x-request-source", value: "local" };
|
|
||||||
|
|
||||||
// Antigravity headers (for chat/stream requests)
|
|
||||||
export const ANTIGRAVITY_HEADERS = {
|
|
||||||
"X-Client-Name": "antigravity",
|
|
||||||
"X-Client-Version": "1.107.0",
|
|
||||||
"x-goog-api-client": "gl-node/18.18.2 fire/0.8.6 grpc/1.10.x",
|
|
||||||
"User-Agent": "antigravity/1.107.0 darwin/arm64"
|
|
||||||
};
|
|
||||||
|
|
||||||
// Cloud Code Assist API endpoints (for Project ID discovery)
|
|
||||||
export const CLOUD_CODE_API = {
|
|
||||||
loadCodeAssist: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
|
|
||||||
onboardUser: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Headers for loadCodeAssist / onboardUser API calls (matches CLIProxyAPI Go source)
|
|
||||||
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_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI" }),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Metadata body for loadCodeAssist / onboardUser (string enum, matches CLIProxyAPI Go source)
|
|
||||||
export const LOAD_CODE_ASSIST_METADATA = {
|
|
||||||
ideType: "IDE_UNSPECIFIED",
|
|
||||||
platform: "PLATFORM_UNSPECIFIED",
|
|
||||||
pluginType: "GEMINI",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Provider configurations
|
|
||||||
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"
|
|
||||||
},
|
|
||||||
// Claude OAuth configuration
|
|
||||||
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", // Use OpenAI Responses API format (reuse translator)
|
|
||||||
headers: {
|
|
||||||
"originator": "codex-cli",
|
|
||||||
"User-Agent": "codex-cli/1.0.18 (macOS; arm64)"
|
|
||||||
},
|
|
||||||
// OpenAI OAuth configuration
|
|
||||||
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"
|
|
||||||
},
|
|
||||||
// Qwen OAuth configuration
|
|
||||||
clientId: "f0304373b74a44d2b584a3fb70ca9e56", // From CLIProxyAPI
|
|
||||||
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"
|
|
||||||
},
|
|
||||||
// iFlow OAuth configuration (from CLIProxyAPI)
|
|
||||||
clientId: "10009311001",
|
|
||||||
clientSecret: "4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW",
|
|
||||||
tokenUrl: "https://iflow.cn/oauth/token",
|
|
||||||
authUrl: "https://iflow.cn/oauth"
|
|
||||||
},
|
|
||||||
antigravity: {
|
|
||||||
baseUrls: [
|
|
||||||
"https://daily-cloudcode-pa.googleapis.com",
|
|
||||||
"https://cloudcode-pa.googleapis.com",
|
|
||||||
],
|
|
||||||
format: "antigravity",
|
|
||||||
headers: {
|
|
||||||
"User-Agent": getPlatformUserAgent()
|
|
||||||
},
|
|
||||||
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: {
|
|
||||||
"Anthropic-Version": "2023-06-01",
|
|
||||||
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"glm-cn": {
|
|
||||||
baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions",
|
|
||||||
format: "openai",
|
|
||||||
headers: {}
|
|
||||||
},
|
|
||||||
kimi: {
|
|
||||||
baseUrl: "https://api.kimi.com/coding/v1/messages",
|
|
||||||
format: "claude",
|
|
||||||
headers: {
|
|
||||||
"Anthropic-Version": "2023-06-01",
|
|
||||||
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
minimax: {
|
|
||||||
baseUrl: "https://api.minimax.io/anthropic/v1/messages",
|
|
||||||
format: "claude",
|
|
||||||
headers: {
|
|
||||||
"Anthropic-Version": "2023-06-01",
|
|
||||||
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"minimax-cn": {
|
|
||||||
baseUrl: "https://api.minimaxi.com/anthropic/v1/messages",
|
|
||||||
format: "claude",
|
|
||||||
headers: {
|
|
||||||
"Anthropic-Version": "2023-06-01",
|
|
||||||
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
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", // GitHub Copilot API endpoint for chat
|
|
||||||
responsesUrl: "https://api.githubcopilot.com/responses",
|
|
||||||
format: "openai", // GitHub Copilot uses OpenAI-compatible format
|
|
||||||
headers: {
|
|
||||||
"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-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"
|
|
||||||
},
|
|
||||||
// Kiro OAuth endpoints
|
|
||||||
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: "https://api.kimi.com/coding/v1/messages",
|
|
||||||
format: "claude",
|
|
||||||
headers: {
|
|
||||||
"Anthropic-Version": "2023-06-01",
|
|
||||||
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14"
|
|
||||||
},
|
|
||||||
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: {
|
|
||||||
"Anthropic-Version": "2023-06-01",
|
|
||||||
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Claude system prompt
|
|
||||||
export const CLAUDE_SYSTEM_PROMPT = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
|
|
||||||
|
|
||||||
// Antigravity default system prompt (required for API to work)
|
|
||||||
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", // From CLIProxyAPI
|
|
||||||
auth: "https://chat.qwen.ai/api/v1/oauth2/device/code" // From CLIProxyAPI
|
|
||||||
},
|
|
||||||
iflow: {
|
|
||||||
token: "https://iflow.cn/oauth/token",
|
|
||||||
auth: "https://iflow.cn/oauth"
|
|
||||||
},
|
|
||||||
github: {
|
|
||||||
token: "https://github.com/login/oauth/access_token",
|
|
||||||
auth: "https://github.com/login/oauth/authorize",
|
|
||||||
deviceCode: "https://github.com/login/device/code"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Cache TTLs (seconds)
|
|
||||||
export const CACHE_TTL = {
|
|
||||||
userInfo: 300, // 5 minutes
|
|
||||||
modelAlias: 3600 // 1 hour
|
|
||||||
};
|
|
||||||
|
|
||||||
// Default max tokens
|
|
||||||
export const DEFAULT_MAX_TOKENS = 64000;
|
|
||||||
|
|
||||||
// Minimum max tokens for tool calling (to prevent truncated arguments)
|
|
||||||
export const DEFAULT_MIN_TOKENS = 32000;
|
|
||||||
|
|
||||||
// Retry config for 429 responses (used by BaseExecutor)
|
|
||||||
export const RETRY_CONFIG = {
|
|
||||||
maxAttempts: 2,
|
|
||||||
delayMs: 2000
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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"
|
|
||||||
};
|
|
||||||
|
|
||||||
// Exponential backoff config for rate limits (like CLIProxyAPI)
|
|
||||||
export const BACKOFF_CONFIG = {
|
|
||||||
base: 1000, // 1 second base
|
|
||||||
max: 2 * 60 * 1000, // 2 minutes max
|
|
||||||
maxLevel: 15 // Cap backoff level
|
|
||||||
};
|
|
||||||
|
|
||||||
// Error-based cooldown times (aligned with CLIProxyAPI)
|
|
||||||
export const COOLDOWN_MS = {
|
|
||||||
unauthorized: 2 * 60 * 1000, // 401 → 30 min
|
|
||||||
paymentRequired: 2 * 60 * 1000, // 402/403 → 30 min
|
|
||||||
notFound: 2 * 60 * 1000, // 404 → 2 minutes
|
|
||||||
transient: 30 * 1000, // 408/500/502/503/504 → 1 min
|
|
||||||
requestNotAllowed: 5 * 1000, // "Request not allowed" → 5 sec
|
|
||||||
// Legacy aliases for backward compatibility
|
|
||||||
rateLimit: 2 * 60 * 1000,
|
|
||||||
serviceUnavailable: 2 * 1000,
|
|
||||||
authExpired: 2 * 60 * 1000
|
|
||||||
};
|
|
||||||
|
|
||||||
// Skip patterns - requests containing these texts will bypass provider
|
|
||||||
export const SKIP_PATTERNS = [
|
|
||||||
"Please write a 5-10 word title for the following conversation:"
|
|
||||||
];
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { PROVIDERS } from "./providers.js";
|
||||||
|
|
||||||
// Provider models - Single source of truth
|
// Provider models - Single source of truth
|
||||||
// Key = alias (cc, cx, gc, qw, if, ag, gh for OAuth; id for API Key)
|
// 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)
|
// Field "provider" for special cases (e.g. AntiGravity models that call different backends)
|
||||||
@@ -105,6 +107,9 @@ export const PROVIDER_MODELS = {
|
|||||||
// { id: "claude-opus-4.5", name: "Claude Opus 4.5" },
|
// { id: "claude-opus-4.5", name: "Claude Opus 4.5" },
|
||||||
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" },
|
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" },
|
||||||
{ id: "claude-haiku-4.5", name: "Claude Haiku 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
|
cu: [ // Cursor IDE
|
||||||
{ id: "default", name: "Auto (Server Picks)" },
|
{ id: "default", name: "Auto (Server Picks)" },
|
||||||
@@ -161,7 +166,10 @@ export const PROVIDER_MODELS = {
|
|||||||
{ id: "claude-3-5-sonnet-20241022", name: "Claude 3.5 Sonnet" },
|
{ id: "claude-3-5-sonnet-20241022", name: "Claude 3.5 Sonnet" },
|
||||||
],
|
],
|
||||||
gemini: [
|
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-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-pro", name: "Gemini 2.5 Pro" },
|
||||||
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
|
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
|
||||||
{ id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite" },
|
{ id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite" },
|
||||||
@@ -298,6 +306,26 @@ export const PROVIDER_MODELS = {
|
|||||||
{ id: "Qwen/Qwen2.5-Coder-32B-Instruct", name: "Qwen 2.5 Coder 32B" },
|
{ 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" },
|
{ 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
|
// Helper functions
|
||||||
@@ -331,8 +359,8 @@ export function getModelTargetFormat(aliasOrId, modelId) {
|
|||||||
return found?.targetFormat || null;
|
return found?.targetFormat || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provider ID to alias mapping
|
// OAuth providers that use short aliases (everything else: alias = id)
|
||||||
export const PROVIDER_ID_TO_ALIAS = {
|
const OAUTH_ALIASES = {
|
||||||
claude: "cc",
|
claude: "cc",
|
||||||
codex: "cx",
|
codex: "cx",
|
||||||
"gemini-cli": "gc",
|
"gemini-cli": "gc",
|
||||||
@@ -345,32 +373,15 @@ export const PROVIDER_ID_TO_ALIAS = {
|
|||||||
"kimi-coding": "kmc",
|
"kimi-coding": "kmc",
|
||||||
kilocode: "kc",
|
kilocode: "kc",
|
||||||
cline: "cl",
|
cline: "cl",
|
||||||
openai: "openai",
|
vertex: "vertex",
|
||||||
anthropic: "anthropic",
|
"vertex-partner": "vertex-partner",
|
||||||
gemini: "gemini",
|
|
||||||
openrouter: "openrouter",
|
|
||||||
glm: "glm",
|
|
||||||
"glm-cn": "glm-cn",
|
|
||||||
kimi: "kimi",
|
|
||||||
minimax: "minimax",
|
|
||||||
"minimax-cn": "minimax-cn",
|
|
||||||
alicode: "alicode",
|
|
||||||
"alicode-intl": "alicode-intl",
|
|
||||||
deepseek: "deepseek",
|
|
||||||
groq: "groq",
|
|
||||||
xai: "xai",
|
|
||||||
mistral: "mistral",
|
|
||||||
perplexity: "perplexity",
|
|
||||||
together: "together",
|
|
||||||
fireworks: "fireworks",
|
|
||||||
cerebras: "cerebras",
|
|
||||||
cohere: "cohere",
|
|
||||||
nvidia: "nvidia",
|
|
||||||
nebius: "nebius",
|
|
||||||
siliconflow: "siliconflow",
|
|
||||||
hyperbolic: "hyperbolic",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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) {
|
export function getModelsByProviderId(providerId) {
|
||||||
const alias = PROVIDER_ID_TO_ALIAS[providerId] || providerId;
|
const alias = PROVIDER_ID_TO_ALIAS[providerId] || providerId;
|
||||||
return PROVIDER_MODELS[alias] || [];
|
return PROVIDER_MODELS[alias] || [];
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
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://cloudcode-pa.googleapis.com",
|
||||||
|
],
|
||||||
|
format: "antigravity",
|
||||||
|
headers: { "User-Agent": `antigravity/1.104.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"
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// 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,6 +1,8 @@
|
|||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { BaseExecutor } from "./base.js";
|
import { BaseExecutor } from "./base.js";
|
||||||
import { PROVIDERS, OAUTH_ENDPOINTS, HTTP_STATUS, ANTIGRAVITY_HEADERS, INTERNAL_REQUEST_HEADER } from "../config/constants.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 { deriveSessionId } from "../utils/sessionManager.js";
|
||||||
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
||||||
|
|
||||||
@@ -167,7 +169,9 @@ export class AntigravityExecutor extends BaseExecutor {
|
|||||||
let lastError = null;
|
let lastError = null;
|
||||||
let lastStatus = 0;
|
let lastStatus = 0;
|
||||||
const MAX_AUTO_RETRIES = 3;
|
const MAX_AUTO_RETRIES = 3;
|
||||||
|
const MAX_RETRY_AFTER_RETRIES = 3;
|
||||||
const retryAttemptsByUrl = {}; // Track retry attempts per URL
|
const retryAttemptsByUrl = {}; // Track retry attempts per URL
|
||||||
|
const retryAfterAttemptsByUrl = {}; // Track Retry-After retries per URL
|
||||||
|
|
||||||
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
|
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
|
||||||
const url = this.buildUrl(model, stream, urlIndex);
|
const url = this.buildUrl(model, stream, urlIndex);
|
||||||
@@ -175,10 +179,13 @@ export class AntigravityExecutor extends BaseExecutor {
|
|||||||
const sessionId = transformedBody.request?.sessionId;
|
const sessionId = transformedBody.request?.sessionId;
|
||||||
const headers = this.buildHeaders(credentials, stream, sessionId);
|
const headers = this.buildHeaders(credentials, stream, sessionId);
|
||||||
|
|
||||||
// Initialize retry counter for this URL
|
// Initialize retry counters for this URL
|
||||||
if (!retryAttemptsByUrl[urlIndex]) {
|
if (!retryAttemptsByUrl[urlIndex]) {
|
||||||
retryAttemptsByUrl[urlIndex] = 0;
|
retryAttemptsByUrl[urlIndex] = 0;
|
||||||
}
|
}
|
||||||
|
if (!retryAfterAttemptsByUrl[urlIndex]) {
|
||||||
|
retryAfterAttemptsByUrl[urlIndex] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await proxyAwareFetch(url, {
|
const response = await proxyAwareFetch(url, {
|
||||||
@@ -204,8 +211,9 @@ export class AntigravityExecutor extends BaseExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (retryMs && retryMs <= MAX_RETRY_AFTER_MS) {
|
if (retryMs && retryMs <= MAX_RETRY_AFTER_MS && retryAfterAttemptsByUrl[urlIndex] < MAX_RETRY_AFTER_RETRIES) {
|
||||||
log?.debug?.("RETRY", `${response.status} with Retry-After: ${Math.ceil(retryMs / 1000)}s, waiting...`);
|
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));
|
await new Promise(resolve => setTimeout(resolve, retryMs));
|
||||||
urlIndex--;
|
urlIndex--;
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { HTTP_STATUS, RETRY_CONFIG } from "../config/constants.js";
|
import { HTTP_STATUS, RETRY_CONFIG } from "../config/runtimeConfig.js";
|
||||||
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { BaseExecutor } from "./base.js";
|
import { BaseExecutor } from "./base.js";
|
||||||
import { CODEX_DEFAULT_INSTRUCTIONS } from "../config/codexInstructions.js";
|
import { CODEX_DEFAULT_INSTRUCTIONS } from "../config/codexInstructions.js";
|
||||||
import { PROVIDERS } from "../config/constants.js";
|
import { PROVIDERS } from "../config/providers.js";
|
||||||
import { normalizeResponsesInput } from "../translator/helpers/responsesApiHelper.js";
|
import { normalizeResponsesInput } from "../translator/helpers/responsesApiHelper.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+283
-142
@@ -1,16 +1,15 @@
|
|||||||
import { BaseExecutor } from "./base.js";
|
import { BaseExecutor } from "./base.js";
|
||||||
import { PROVIDERS, HTTP_STATUS } from "../config/constants.js";
|
import { PROVIDERS } from "../config/providers.js";
|
||||||
|
import { HTTP_STATUS } from "../config/runtimeConfig.js";
|
||||||
import {
|
import {
|
||||||
generateCursorBody,
|
generateCursorBody,
|
||||||
parseConnectRPCFrame,
|
parseConnectRPCFrame,
|
||||||
extractTextFromResponse
|
extractTextFromResponse
|
||||||
} from "../utils/cursorProtobuf.js";
|
} from "../utils/cursorProtobuf.js";
|
||||||
|
import { buildCursorHeaders } from "../utils/cursorChecksum.js";
|
||||||
import { estimateUsage } from "../utils/usageTracking.js";
|
import { estimateUsage } from "../utils/usageTracking.js";
|
||||||
import { FORMATS } from "../translator/formats.js";
|
import { FORMATS } from "../translator/formats.js";
|
||||||
import { buildCursorRequest } from "../translator/request/openai-to-cursor.js";
|
|
||||||
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
||||||
import crypto from "crypto";
|
|
||||||
import { v5 as uuidv5 } from "uuid";
|
|
||||||
import zlib from "zlib";
|
import zlib from "zlib";
|
||||||
|
|
||||||
// Detect cloud environment
|
// Detect cloud environment
|
||||||
@@ -37,20 +36,52 @@ const COMPRESS_FLAG = {
|
|||||||
GZIP_TRAILER: 0x03
|
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) {
|
function decompressPayload(payload, flags) {
|
||||||
// ConnectRPC trailer frame (flags & 0x02) - contains status JSON, not compressed data
|
// Check if payload is JSON error (starts with {"error")
|
||||||
if (flags & COMPRESS_FLAG.TRAILER) {
|
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;
|
return payload;
|
||||||
}
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
if (flags === COMPRESS_FLAG.GZIP) {
|
if (
|
||||||
|
flags === COMPRESS_FLAG.GZIP ||
|
||||||
|
flags === COMPRESS_FLAG.TRAILER ||
|
||||||
|
flags === COMPRESS_FLAG.GZIP_TRAILER
|
||||||
|
) {
|
||||||
|
// Primary: try gzip decompression (standard gzip header 0x1f 0x8b)
|
||||||
try {
|
try {
|
||||||
return zlib.gunzipSync(payload);
|
return zlib.gunzipSync(payload);
|
||||||
} catch (err) {
|
} catch (gzipErr) {
|
||||||
console.log(`[DECOMPRESS ERROR] flags=${flags}, payloadSize=${payload.length}, error=${err.message}`);
|
// 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,46 +114,6 @@ export class CursorExecutor extends BaseExecutor {
|
|||||||
return `${this.config.baseUrl}${this.config.chatPath}`;
|
return `${this.config.baseUrl}${this.config.chatPath}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Jyh cipher checksum for Cursor API authentication
|
|
||||||
generateChecksum(machineId) {
|
|
||||||
const timestamp = Math.floor(Date.now() / 1000000);
|
|
||||||
const byteArray = new Uint8Array([
|
|
||||||
(timestamp >> 40) & 0xFF,
|
|
||||||
(timestamp >> 32) & 0xFF,
|
|
||||||
(timestamp >> 24) & 0xFF,
|
|
||||||
(timestamp >> 16) & 0xFF,
|
|
||||||
(timestamp >> 8) & 0xFF,
|
|
||||||
timestamp & 0xFF
|
|
||||||
]);
|
|
||||||
|
|
||||||
let t = 165;
|
|
||||||
for (let i = 0; i < byteArray.length; i++) {
|
|
||||||
byteArray[i] = ((byteArray[i] ^ t) + (i % 256)) & 0xFF;
|
|
||||||
t = byteArray[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
||||||
let encoded = "";
|
|
||||||
|
|
||||||
for (let i = 0; i < byteArray.length; i += 3) {
|
|
||||||
const a = byteArray[i];
|
|
||||||
const b = i + 1 < byteArray.length ? byteArray[i + 1] : 0;
|
|
||||||
const c = i + 2 < byteArray.length ? byteArray[i + 2] : 0;
|
|
||||||
|
|
||||||
encoded += alphabet[a >> 2];
|
|
||||||
encoded += alphabet[((a & 3) << 4) | (b >> 4)];
|
|
||||||
|
|
||||||
if (i + 1 < byteArray.length) {
|
|
||||||
encoded += alphabet[((b & 15) << 2) | (c >> 6)];
|
|
||||||
}
|
|
||||||
if (i + 2 < byteArray.length) {
|
|
||||||
encoded += alphabet[c & 63];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${encoded}${machineId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
buildHeaders(credentials) {
|
buildHeaders(credentials) {
|
||||||
const accessToken = credentials.accessToken;
|
const accessToken = credentials.accessToken;
|
||||||
const machineId = credentials.providerSpecificData?.machineId;
|
const machineId = credentials.providerSpecificData?.machineId;
|
||||||
@@ -132,34 +123,14 @@ export class CursorExecutor extends BaseExecutor {
|
|||||||
throw new Error("Machine ID is required for Cursor API");
|
throw new Error("Machine ID is required for Cursor API");
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanToken = accessToken.includes("::") ? accessToken.split("::")[1] : accessToken;
|
return buildCursorHeaders(accessToken, machineId, ghostMode);
|
||||||
|
|
||||||
return {
|
|
||||||
"authorization": `Bearer ${cleanToken}`,
|
|
||||||
"connect-accept-encoding": "gzip",
|
|
||||||
"connect-protocol-version": "1",
|
|
||||||
"content-type": "application/connect+proto",
|
|
||||||
"user-agent": "connect-es/1.6.1",
|
|
||||||
"x-amzn-trace-id": `Root=${crypto.randomUUID()}`,
|
|
||||||
"x-client-key": crypto.createHash("sha256").update(cleanToken).digest("hex"),
|
|
||||||
"x-cursor-checksum": this.generateChecksum(machineId),
|
|
||||||
"x-cursor-client-version": "2.3.41",
|
|
||||||
"x-cursor-client-type": "ide",
|
|
||||||
"x-cursor-client-os": process.platform === "win32" ? "windows" : process.platform === "darwin" ? "macos" : "linux",
|
|
||||||
"x-cursor-client-arch": process.arch === "arm64" ? "aarch64" : "x64",
|
|
||||||
"x-cursor-client-device-type": "desktop",
|
|
||||||
"x-cursor-config-version": crypto.randomUUID(),
|
|
||||||
"x-cursor-timezone": Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
|
||||||
"x-ghost-mode": ghostMode ? "true" : "false",
|
|
||||||
"x-request-id": crypto.randomUUID(),
|
|
||||||
"x-session-id": uuidv5(cleanToken, uuidv5.DNS),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
transformRequest(model, body, stream, credentials) {
|
transformRequest(model, body, stream, credentials) {
|
||||||
const translatedBody = buildCursorRequest(model, body, stream, credentials);
|
// Messages are already translated by chatCore (claude→openai→cursor)
|
||||||
const messages = translatedBody.messages || [];
|
// Do NOT call buildCursorRequest again — double-translation drops tool_results
|
||||||
const tools = translatedBody.tools || body.tools || [];
|
const messages = body.messages || [];
|
||||||
|
const tools = body.tools || [];
|
||||||
const reasoningEffort = body.reasoning_effort || null;
|
const reasoningEffort = body.reasoning_effort || null;
|
||||||
return generateCursorBody(messages, model, tools, reasoningEffort);
|
return generateCursorBody(messages, model, tools, reasoningEffort);
|
||||||
}
|
}
|
||||||
@@ -184,13 +155,30 @@ export class CursorExecutor extends BaseExecutor {
|
|||||||
throw new Error("http2 module not available");
|
throw new Error("http2 module not available");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HTTP2_TIMEOUT_MS = 60000; // 60s max — prevent hung sessions
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const urlObj = new URL(url);
|
const urlObj = new URL(url);
|
||||||
const client = http2.connect(`https://${urlObj.host}`);
|
const client = http2.connect(`https://${urlObj.host}`);
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
let responseHeaders = {};
|
let responseHeaders = {};
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
client.on("error", reject);
|
// 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({
|
const req = client.request({
|
||||||
":method": "POST",
|
":method": "POST",
|
||||||
@@ -202,25 +190,18 @@ export class CursorExecutor extends BaseExecutor {
|
|||||||
|
|
||||||
req.on("response", (hdrs) => { responseHeaders = hdrs; });
|
req.on("response", (hdrs) => { responseHeaders = hdrs; });
|
||||||
req.on("data", (chunk) => { chunks.push(chunk); });
|
req.on("data", (chunk) => { chunks.push(chunk); });
|
||||||
req.on("end", () => {
|
req.on("end", finish(() => {
|
||||||
client.close();
|
|
||||||
resolve({
|
resolve({
|
||||||
status: responseHeaders[":status"],
|
status: responseHeaders[":status"],
|
||||||
headers: responseHeaders,
|
headers: responseHeaders,
|
||||||
body: Buffer.concat(chunks)
|
body: Buffer.concat(chunks)
|
||||||
});
|
});
|
||||||
});
|
}));
|
||||||
req.on("error", (err) => {
|
req.on("error", finish(reject));
|
||||||
client.close();
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (signal) {
|
if (signal) {
|
||||||
signal.addEventListener("abort", () => {
|
const onAbort = finish(() => reject(new Error("Request aborted")));
|
||||||
req.close();
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
client.close();
|
|
||||||
reject(new Error("Request aborted"));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
req.write(body);
|
req.write(body);
|
||||||
@@ -282,48 +263,82 @@ export class CursorExecutor extends BaseExecutor {
|
|||||||
let totalContent = "";
|
let totalContent = "";
|
||||||
const toolCalls = [];
|
const toolCalls = [];
|
||||||
const toolCallsMap = new Map(); // Track streaming tool calls by ID
|
const toolCallsMap = new Map(); // Track streaming tool calls by ID
|
||||||
|
const finalizedIds = new Set();
|
||||||
let frameCount = 0;
|
let frameCount = 0;
|
||||||
|
|
||||||
|
debugLog(`[CURSOR BUFFER] Total length: ${buffer.length} bytes`);
|
||||||
|
|
||||||
while (offset < buffer.length) {
|
while (offset < buffer.length) {
|
||||||
if (offset + 5 > buffer.length) break;
|
if (offset + 5 > buffer.length) {
|
||||||
|
debugLog(
|
||||||
|
`[CURSOR BUFFER] Reached end, offset=${offset}, remaining=${buffer.length - offset}`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
const flags = buffer[offset];
|
const flags = buffer[offset];
|
||||||
const length = buffer.readUInt32BE(offset + 1);
|
const length = buffer.readUInt32BE(offset + 1);
|
||||||
|
|
||||||
if (offset + 5 + length > buffer.length) break;
|
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);
|
let payload = buffer.slice(offset + 5, offset + 5 + length);
|
||||||
offset += 5 + length;
|
offset += 5 + length;
|
||||||
frameCount++;
|
frameCount++;
|
||||||
|
|
||||||
// Stop at ConnectRPC trailer frame (end of response, anything after is a separate response)
|
payload = decompressPayload(payload, flags);
|
||||||
if (flags & COMPRESS_FLAG.TRAILER) {
|
if (!payload) {
|
||||||
break;
|
debugLog(`[CURSOR BUFFER] Frame ${frameCount}: decompression failed, skipping`);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
payload = decompressPayload(payload, flags);
|
// Check for JSON error frames (byte guard: skip toString on non-JSON frames)
|
||||||
if (!payload) continue;
|
if (payload.length > 0 && payload[0] === 0x7b) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const text = payload.toString("utf-8");
|
const text = payload.toString("utf-8");
|
||||||
if (text.startsWith("{") && text.includes('"error"')) {
|
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));
|
return createErrorResponse(JSON.parse(text));
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
const result = extractTextFromResponse(new Uint8Array(payload));
|
const result = extractTextFromResponse(new Uint8Array(payload));
|
||||||
|
debugLog(`[CURSOR DECODED] Frame ${frameCount}:`, result);
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
return new Response(JSON.stringify({
|
const hasContent = totalContent || toolCallsMap.size > 0;
|
||||||
|
debugLog(`[CURSOR BUFFER] Decoded error (hasContent=${hasContent}): ${result.error}`);
|
||||||
|
if (hasContent) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
error: {
|
error: {
|
||||||
message: result.error,
|
message: result.error,
|
||||||
type: "rate_limit_error",
|
type: "rate_limit_error",
|
||||||
code: "rate_limited"
|
code: "rate_limited"
|
||||||
}
|
}
|
||||||
}), {
|
}),
|
||||||
|
{
|
||||||
status: HTTP_STATUS.RATE_LIMITED,
|
status: HTTP_STATUS.RATE_LIMITED,
|
||||||
headers: { "Content-Type": "application/json" }
|
headers: { "Content-Type": "application/json" }
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.toolCall) {
|
if (result.toolCall) {
|
||||||
@@ -342,6 +357,7 @@ export class CursorExecutor extends BaseExecutor {
|
|||||||
// Push to final array when isLast is true
|
// Push to final array when isLast is true
|
||||||
if (tc.isLast) {
|
if (tc.isLast) {
|
||||||
const finalToolCall = toolCallsMap.get(tc.id);
|
const finalToolCall = toolCallsMap.get(tc.id);
|
||||||
|
finalizedIds.add(tc.id);
|
||||||
toolCalls.push({
|
toolCalls.push({
|
||||||
id: finalToolCall.id,
|
id: finalToolCall.id,
|
||||||
type: finalToolCall.type,
|
type: finalToolCall.type,
|
||||||
@@ -356,10 +372,15 @@ export class CursorExecutor extends BaseExecutor {
|
|||||||
if (result.text) totalContent += result.text;
|
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)
|
// Finalize all remaining tool calls in map (in case stream ended without isLast=true)
|
||||||
for (const [id, tc] of toolCallsMap.entries()) {
|
for (const [id, tc] of toolCallsMap.entries()) {
|
||||||
// Check if already in final array
|
// Check if already in final array
|
||||||
if (!toolCalls.find(t => t.id === id)) {
|
if (!finalizedIds.has(id)) {
|
||||||
|
debugLog(`[CURSOR BUFFER] Finalizing incomplete tool call: ${id}, isLast=${tc.isLast}`);
|
||||||
toolCalls.push({
|
toolCalls.push({
|
||||||
id: tc.id,
|
id: tc.id,
|
||||||
type: tc.type,
|
type: tc.type,
|
||||||
@@ -371,6 +392,8 @@ export class CursorExecutor extends BaseExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
debugLog(`[CURSOR BUFFER] Final toolCalls count: ${toolCalls.length}`);
|
||||||
|
|
||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
@@ -411,65 +434,104 @@ export class CursorExecutor extends BaseExecutor {
|
|||||||
let totalContent = "";
|
let totalContent = "";
|
||||||
const toolCalls = [];
|
const toolCalls = [];
|
||||||
const toolCallsMap = new Map(); // Track streaming tool calls by ID
|
const toolCallsMap = new Map(); // Track streaming tool calls by ID
|
||||||
|
const finalizedIds = new Set();
|
||||||
|
const emittedToolCallIds = new Set();
|
||||||
let frameCount = 0;
|
let frameCount = 0;
|
||||||
|
|
||||||
|
debugLog(`[CURSOR BUFFER SSE] Total length: ${buffer.length} bytes`);
|
||||||
|
|
||||||
while (offset < buffer.length) {
|
while (offset < buffer.length) {
|
||||||
if (offset + 5 > buffer.length) break;
|
if (offset + 5 > buffer.length) {
|
||||||
|
debugLog(
|
||||||
|
`[CURSOR BUFFER SSE] Reached end, offset=${offset}, remaining=${buffer.length - offset}`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
const flags = buffer[offset];
|
const flags = buffer[offset];
|
||||||
const length = buffer.readUInt32BE(offset + 1);
|
const length = buffer.readUInt32BE(offset + 1);
|
||||||
|
|
||||||
if (offset + 5 + length > buffer.length) break;
|
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);
|
let payload = buffer.slice(offset + 5, offset + 5 + length);
|
||||||
offset += 5 + length;
|
offset += 5 + length;
|
||||||
frameCount++;
|
frameCount++;
|
||||||
|
|
||||||
// Stop at ConnectRPC trailer frame (end of response, anything after is a separate response)
|
payload = decompressPayload(payload, flags);
|
||||||
if (flags & COMPRESS_FLAG.TRAILER) {
|
if (!payload) {
|
||||||
break;
|
debugLog(`[CURSOR BUFFER SSE] Frame ${frameCount}: decompression failed, skipping`);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
payload = decompressPayload(payload, flags);
|
// Check for JSON error frames (byte-guard: only decode if starts with '{')
|
||||||
if (!payload) continue;
|
if (payload[0] === 0x7b) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const text = payload.toString("utf-8");
|
const text = payload.toString("utf-8");
|
||||||
if (text.startsWith("{") && text.includes('"error"')) {
|
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));
|
return createErrorResponse(JSON.parse(text));
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
const result = extractTextFromResponse(new Uint8Array(payload));
|
const result = extractTextFromResponse(new Uint8Array(payload));
|
||||||
|
debugLog(`[CURSOR DECODED SSE] Frame ${frameCount}:`, result);
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
return new Response(JSON.stringify({
|
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: {
|
error: {
|
||||||
message: result.error,
|
message: result.error,
|
||||||
type: "rate_limit_error",
|
type: "rate_limit_error",
|
||||||
code: "rate_limited"
|
code: "rate_limited"
|
||||||
}
|
}
|
||||||
}), {
|
}),
|
||||||
|
{
|
||||||
status: HTTP_STATUS.RATE_LIMITED,
|
status: HTTP_STATUS.RATE_LIMITED,
|
||||||
headers: { "Content-Type": "application/json" }
|
headers: { "Content-Type": "application/json" }
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.toolCall) {
|
if (result.toolCall) {
|
||||||
const tc = result.toolCall;
|
const tc = result.toolCall;
|
||||||
|
|
||||||
if (chunks.length === 0) {
|
if (chunks.length === 0) {
|
||||||
chunks.push(`data: ${JSON.stringify({
|
chunks.push(
|
||||||
|
`data: ${JSON.stringify({
|
||||||
id: responseId,
|
id: responseId,
|
||||||
object: "chat.completion.chunk",
|
object: "chat.completion.chunk",
|
||||||
created,
|
created,
|
||||||
model,
|
model,
|
||||||
choices: [{
|
choices: [
|
||||||
|
{
|
||||||
index: 0,
|
index: 0,
|
||||||
delta: { role: "assistant", content: "" },
|
delta: { role: "assistant", content: "" },
|
||||||
finish_reason: null
|
finish_reason: null
|
||||||
}]
|
}
|
||||||
})}\n\n`);
|
]
|
||||||
|
})}\n\n`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toolCallsMap.has(tc.id)) {
|
if (toolCallsMap.has(tc.id)) {
|
||||||
@@ -481,15 +543,19 @@ export class CursorExecutor extends BaseExecutor {
|
|||||||
|
|
||||||
// Stream the delta arguments
|
// Stream the delta arguments
|
||||||
if (tc.function.arguments) {
|
if (tc.function.arguments) {
|
||||||
chunks.push(`data: ${JSON.stringify({
|
emittedToolCallIds.add(tc.id);
|
||||||
|
chunks.push(
|
||||||
|
`data: ${JSON.stringify({
|
||||||
id: responseId,
|
id: responseId,
|
||||||
object: "chat.completion.chunk",
|
object: "chat.completion.chunk",
|
||||||
created,
|
created,
|
||||||
model,
|
model,
|
||||||
choices: [{
|
choices: [
|
||||||
|
{
|
||||||
index: 0,
|
index: 0,
|
||||||
delta: {
|
delta: {
|
||||||
tool_calls: [{
|
tool_calls: [
|
||||||
|
{
|
||||||
index: existing.index,
|
index: existing.index,
|
||||||
id: tc.id,
|
id: tc.id,
|
||||||
type: "function",
|
type: "function",
|
||||||
@@ -497,28 +563,36 @@ export class CursorExecutor extends BaseExecutor {
|
|||||||
name: tc.function.name,
|
name: tc.function.name,
|
||||||
arguments: tc.function.arguments
|
arguments: tc.function.arguments
|
||||||
}
|
}
|
||||||
}]
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
finish_reason: null
|
finish_reason: null
|
||||||
}]
|
}
|
||||||
})}\n\n`);
|
]
|
||||||
|
})}\n\n`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// New tool call - assign index and add to map
|
// New tool call - assign index and add to map
|
||||||
const toolCallIndex = toolCalls.length;
|
const toolCallIndex = toolCalls.length;
|
||||||
|
finalizedIds.add(tc.id);
|
||||||
toolCalls.push({ ...tc, index: toolCallIndex });
|
toolCalls.push({ ...tc, index: toolCallIndex });
|
||||||
toolCallsMap.set(tc.id, { ...tc, index: toolCallIndex });
|
toolCallsMap.set(tc.id, { ...tc, index: toolCallIndex });
|
||||||
|
|
||||||
// Stream initial tool call with name
|
// Stream initial tool call with name
|
||||||
chunks.push(`data: ${JSON.stringify({
|
emittedToolCallIds.add(tc.id);
|
||||||
|
chunks.push(
|
||||||
|
`data: ${JSON.stringify({
|
||||||
id: responseId,
|
id: responseId,
|
||||||
object: "chat.completion.chunk",
|
object: "chat.completion.chunk",
|
||||||
created,
|
created,
|
||||||
model,
|
model,
|
||||||
choices: [{
|
choices: [
|
||||||
|
{
|
||||||
index: 0,
|
index: 0,
|
||||||
delta: {
|
delta: {
|
||||||
tool_calls: [{
|
tool_calls: [
|
||||||
|
{
|
||||||
index: toolCallIndex,
|
index: toolCallIndex,
|
||||||
id: tc.id,
|
id: tc.id,
|
||||||
type: "function",
|
type: "function",
|
||||||
@@ -526,61 +600,128 @@ export class CursorExecutor extends BaseExecutor {
|
|||||||
name: tc.function.name,
|
name: tc.function.name,
|
||||||
arguments: tc.function.arguments
|
arguments: tc.function.arguments
|
||||||
}
|
}
|
||||||
}]
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
finish_reason: null
|
finish_reason: null
|
||||||
}]
|
}
|
||||||
})}\n\n`);
|
]
|
||||||
|
})}\n\n`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.text) {
|
if (result.text) {
|
||||||
totalContent += result.text;
|
totalContent += result.text;
|
||||||
chunks.push(`data: ${JSON.stringify({
|
chunks.push(
|
||||||
|
`data: ${JSON.stringify({
|
||||||
id: responseId,
|
id: responseId,
|
||||||
object: "chat.completion.chunk",
|
object: "chat.completion.chunk",
|
||||||
created,
|
created,
|
||||||
model,
|
model,
|
||||||
choices: [{
|
choices: [
|
||||||
|
{
|
||||||
index: 0,
|
index: 0,
|
||||||
delta: chunks.length === 0 && toolCalls.length === 0
|
delta:
|
||||||
|
chunks.length === 0 && toolCalls.length === 0
|
||||||
? { role: "assistant", content: result.text }
|
? { role: "assistant", content: result.text }
|
||||||
: { content: result.text },
|
: { content: result.text },
|
||||||
finish_reason: null
|
finish_reason: null
|
||||||
}]
|
}
|
||||||
})}\n\n`);
|
]
|
||||||
|
})}\n\n`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
debugLog(
|
||||||
|
`[CURSOR BUFFER SSE] Parsed ${frameCount} frames, toolCallsMap size: ${toolCallsMap.size}, toolCalls array: ${toolCalls.length}`
|
||||||
|
);
|
||||||
|
|
||||||
if (chunks.length === 0 && toolCalls.length === 0) {
|
// Finalize all remaining tool calls in map (stream may have ended without isLast=true)
|
||||||
chunks.push(`data: ${JSON.stringify({
|
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,
|
id: responseId,
|
||||||
object: "chat.completion.chunk",
|
object: "chat.completion.chunk",
|
||||||
created,
|
created,
|
||||||
model,
|
model,
|
||||||
choices: [{
|
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,
|
index: 0,
|
||||||
delta: { role: "assistant", content: "" },
|
delta: { role: "assistant", content: "" },
|
||||||
finish_reason: null
|
finish_reason: null
|
||||||
}]
|
}
|
||||||
})}\n\n`);
|
]
|
||||||
|
})}\n\n`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const usage = estimateUsage(body, totalContent.length, FORMATS.OPENAI);
|
const usage = estimateUsage(body, totalContent.length, FORMATS.OPENAI);
|
||||||
|
|
||||||
chunks.push(`data: ${JSON.stringify({
|
chunks.push(
|
||||||
|
`data: ${JSON.stringify({
|
||||||
id: responseId,
|
id: responseId,
|
||||||
object: "chat.completion.chunk",
|
object: "chat.completion.chunk",
|
||||||
created,
|
created,
|
||||||
model,
|
model,
|
||||||
choices: [{
|
choices: [
|
||||||
|
{
|
||||||
index: 0,
|
index: 0,
|
||||||
delta: {},
|
delta: {},
|
||||||
finish_reason: toolCalls.length > 0 ? "tool_calls" : "stop"
|
finish_reason: toolCalls.length > 0 ? "tool_calls" : "stop"
|
||||||
}],
|
}
|
||||||
|
],
|
||||||
usage
|
usage
|
||||||
})}\n\n`);
|
})}\n\n`
|
||||||
|
);
|
||||||
chunks.push("data: [DONE]\n\n");
|
chunks.push("data: [DONE]\n\n");
|
||||||
|
|
||||||
return new Response(chunks.join(""), {
|
return new Response(chunks.join(""), {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { BaseExecutor } from "./base.js";
|
import { BaseExecutor } from "./base.js";
|
||||||
import { PROVIDERS, OAUTH_ENDPOINTS } from "../config/constants.js";
|
import { PROVIDERS } from "../config/providers.js";
|
||||||
|
import { OAUTH_ENDPOINTS, buildKimiHeaders } from "../config/appConstants.js";
|
||||||
import { buildClineHeaders } from "../../src/shared/utils/clineAuth.js";
|
import { buildClineHeaders } from "../../src/shared/utils/clineAuth.js";
|
||||||
|
|
||||||
export class DefaultExecutor extends BaseExecutor {
|
export class DefaultExecutor extends BaseExecutor {
|
||||||
@@ -23,10 +24,11 @@ export class DefaultExecutor extends BaseExecutor {
|
|||||||
case "claude":
|
case "claude":
|
||||||
case "glm":
|
case "glm":
|
||||||
case "kimi":
|
case "kimi":
|
||||||
case "kimi-coding":
|
|
||||||
case "minimax":
|
case "minimax":
|
||||||
case "minimax-cn":
|
case "minimax-cn":
|
||||||
return `${this.config.baseUrl}?beta=true`;
|
return `${this.config.baseUrl}?beta=true`;
|
||||||
|
case "kimi-coding":
|
||||||
|
return `${this.config.baseUrl}?beta=true`;
|
||||||
case "gemini":
|
case "gemini":
|
||||||
return `${this.config.baseUrl}/${model}:${stream ? "streamGenerateContent?alt=sse" : "generateContent"}`;
|
return `${this.config.baseUrl}/${model}:${stream ? "streamGenerateContent?alt=sse" : "generateContent"}`;
|
||||||
default:
|
default:
|
||||||
@@ -46,11 +48,14 @@ export class DefaultExecutor extends BaseExecutor {
|
|||||||
break;
|
break;
|
||||||
case "glm":
|
case "glm":
|
||||||
case "kimi":
|
case "kimi":
|
||||||
case "kimi-coding":
|
|
||||||
case "minimax":
|
case "minimax":
|
||||||
case "minimax-cn":
|
case "minimax-cn":
|
||||||
headers["x-api-key"] = credentials.apiKey || credentials.accessToken;
|
headers["x-api-key"] = credentials.apiKey || credentials.accessToken;
|
||||||
break;
|
break;
|
||||||
|
case "kimi-coding":
|
||||||
|
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
|
||||||
|
Object.assign(headers, buildKimiHeaders());
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
if (this.provider?.startsWith?.("anthropic-compatible-")) {
|
if (this.provider?.startsWith?.("anthropic-compatible-")) {
|
||||||
if (credentials.apiKey) {
|
if (credentials.apiKey) {
|
||||||
@@ -184,9 +189,14 @@ export class DefaultExecutor extends BaseExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async refreshKimiCoding(refreshToken) {
|
async refreshKimiCoding(refreshToken) {
|
||||||
|
const kimiHeaders = buildKimiHeaders();
|
||||||
const response = await fetch("https://auth.kimi.com/api/oauth/token", {
|
const response = await fetch("https://auth.kimi.com/api/oauth/token", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json" },
|
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" })
|
body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: "17e5f671-d194-4dfb-9706-5516cb48c098" })
|
||||||
});
|
});
|
||||||
if (!response.ok) return null;
|
if (!response.ok) return null;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { BaseExecutor } from "./base.js";
|
import { BaseExecutor } from "./base.js";
|
||||||
import { PROVIDERS, OAUTH_ENDPOINTS, GEMINI_CLI_API_CLIENT, geminiCLIUserAgent } from "../config/constants.js";
|
import { PROVIDERS } from "../config/providers.js";
|
||||||
|
import { OAUTH_ENDPOINTS, GEMINI_CLI_API_CLIENT, geminiCLIUserAgent } from "../config/appConstants.js";
|
||||||
|
|
||||||
export class GeminiCLIExecutor extends BaseExecutor {
|
export class GeminiCLIExecutor extends BaseExecutor {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { BaseExecutor } from "./base.js";
|
import { BaseExecutor } from "./base.js";
|
||||||
import { PROVIDERS, OAUTH_ENDPOINTS, HTTP_STATUS, GITHUB_COPILOT } from "../config/constants.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 { openaiToOpenAIResponsesRequest } from "../translator/request/openai-responses.js";
|
||||||
import { openaiResponsesToOpenAIResponse } from "../translator/response/openai-responses.js";
|
import { openaiResponsesToOpenAIResponse } from "../translator/response/openai-responses.js";
|
||||||
import { initState } from "../translator/index.js";
|
import { initState } from "../translator/index.js";
|
||||||
@@ -42,6 +44,36 @@ export class GithubExecutor extends BaseExecutor {
|
|||||||
if (!body?.messages) return body;
|
if (!body?.messages) return body;
|
||||||
|
|
||||||
const sanitized = { ...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 => {
|
sanitized.messages = body.messages.map(msg => {
|
||||||
// assistant messages with only tool_calls have content: null — leave as-is
|
// assistant messages with only tool_calls have content: null — leave as-is
|
||||||
if (!msg.content) return msg;
|
if (!msg.content) return msg;
|
||||||
@@ -141,7 +173,7 @@ export class GithubExecutor extends BaseExecutor {
|
|||||||
const parsed = parseSSELine(trimmed);
|
const parsed = parseSSELine(trimmed);
|
||||||
if (!parsed) continue;
|
if (!parsed) continue;
|
||||||
|
|
||||||
if (parsed.done) {
|
if (parsed.done && stream === true) {
|
||||||
controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n"));
|
controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n"));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -166,6 +198,9 @@ export class GithubExecutor extends BaseExecutor {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!response.body) {
|
||||||
|
return { response: new Response("", { status: response.status, headers: response.headers }), url, headers, transformedBody };
|
||||||
|
}
|
||||||
const convertedStream = response.body.pipeThrough(transformStream);
|
const convertedStream = response.body.pipeThrough(transformStream);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { BaseExecutor } from "./base.js";
|
import { BaseExecutor } from "./base.js";
|
||||||
import { PROVIDERS } from "../config/constants.js";
|
import { PROVIDERS } from "../config/providers.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IFlowExecutor - Executor for iFlow API with HMAC-SHA256 signature
|
* IFlowExecutor - Executor for iFlow API with HMAC-SHA256 signature
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { IFlowExecutor } from "./iflow.js";
|
|||||||
import { KiroExecutor } from "./kiro.js";
|
import { KiroExecutor } from "./kiro.js";
|
||||||
import { CodexExecutor } from "./codex.js";
|
import { CodexExecutor } from "./codex.js";
|
||||||
import { CursorExecutor } from "./cursor.js";
|
import { CursorExecutor } from "./cursor.js";
|
||||||
|
import { VertexExecutor } from "./vertex.js";
|
||||||
import { DefaultExecutor } from "./default.js";
|
import { DefaultExecutor } from "./default.js";
|
||||||
|
|
||||||
const executors = {
|
const executors = {
|
||||||
@@ -15,7 +16,9 @@ const executors = {
|
|||||||
kiro: new KiroExecutor(),
|
kiro: new KiroExecutor(),
|
||||||
codex: new CodexExecutor(),
|
codex: new CodexExecutor(),
|
||||||
cursor: new CursorExecutor(),
|
cursor: new CursorExecutor(),
|
||||||
cu: new CursorExecutor() // Alias for cursor
|
cu: new CursorExecutor(), // Alias for cursor
|
||||||
|
vertex: new VertexExecutor("vertex"),
|
||||||
|
"vertex-partner": new VertexExecutor("vertex-partner"),
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultCache = new Map();
|
const defaultCache = new Map();
|
||||||
@@ -38,4 +41,5 @@ export { IFlowExecutor } from "./iflow.js";
|
|||||||
export { KiroExecutor } from "./kiro.js";
|
export { KiroExecutor } from "./kiro.js";
|
||||||
export { CodexExecutor } from "./codex.js";
|
export { CodexExecutor } from "./codex.js";
|
||||||
export { CursorExecutor } from "./cursor.js";
|
export { CursorExecutor } from "./cursor.js";
|
||||||
|
export { VertexExecutor } from "./vertex.js";
|
||||||
export { DefaultExecutor } from "./default.js";
|
export { DefaultExecutor } from "./default.js";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { BaseExecutor } from "./base.js";
|
import { BaseExecutor } from "./base.js";
|
||||||
import { PROVIDERS } from "../config/constants.js";
|
import { PROVIDERS } from "../config/providers.js";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { refreshKiroToken } from "../services/tokenRefresh.js";
|
import { refreshKiroToken } from "../services/tokenRefresh.js";
|
||||||
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
||||||
@@ -345,6 +345,9 @@ export class KiroExecutor extends BaseExecutor {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Pipe response body through transform stream
|
// 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);
|
const transformedStream = response.body.pipeThrough(transformStream);
|
||||||
|
|
||||||
return new Response(transformedStream, {
|
return new Response(transformedStream, {
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
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;
|
||||||
@@ -7,7 +7,7 @@ import { refreshWithRetry } from "../services/tokenRefresh.js";
|
|||||||
import { createRequestLogger } from "../utils/requestLogger.js";
|
import { createRequestLogger } from "../utils/requestLogger.js";
|
||||||
import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js";
|
import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js";
|
||||||
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js";
|
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js";
|
||||||
import { HTTP_STATUS } from "../config/constants.js";
|
import { HTTP_STATUS } from "../config/runtimeConfig.js";
|
||||||
import { handleBypassRequest } from "../utils/bypassHandler.js";
|
import { handleBypassRequest } from "../utils/bypassHandler.js";
|
||||||
import { trackPendingRequest, appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js";
|
import { trackPendingRequest, appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js";
|
||||||
import { getExecutor } from "../executors/index.js";
|
import { getExecutor } from "../executors/index.js";
|
||||||
@@ -23,14 +23,14 @@ import { handleStreamingResponse, buildOnStreamComplete } from "./chatCore/strea
|
|||||||
* @param {object} options.credentials - Provider credentials
|
* @param {object} options.credentials - Provider credentials
|
||||||
* @param {string} options.sourceFormatOverride - Override detected source format (e.g. "openai-responses")
|
* @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, sourceFormatOverride }) {
|
export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, sourceFormatOverride }) {
|
||||||
const { provider, model } = modelInfo;
|
const { provider, model } = modelInfo;
|
||||||
const requestStartTime = Date.now();
|
const requestStartTime = Date.now();
|
||||||
|
|
||||||
const sourceFormat = sourceFormatOverride || detectFormat(body);
|
const sourceFormat = sourceFormatOverride || detectFormat(body);
|
||||||
|
|
||||||
// Check for bypass patterns (warmup, skip)
|
// Check for bypass patterns (warmup, skip, cc naming)
|
||||||
const bypassResponse = handleBypassRequest(body, model, userAgent);
|
const bypassResponse = handleBypassRequest(body, model, userAgent, ccFilterNaming);
|
||||||
if (bypassResponse) return bypassResponse;
|
if (bypassResponse) return bypassResponse;
|
||||||
|
|
||||||
const alias = PROVIDER_ID_TO_ALIAS[provider] || provider;
|
const alias = PROVIDER_ID_TO_ALIAS[provider] || provider;
|
||||||
@@ -39,7 +39,16 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|||||||
|
|
||||||
const clientRequestedStreaming = body.stream === true || sourceFormat === FORMATS.ANTIGRAVITY || sourceFormat === FORMATS.GEMINI || sourceFormat === FORMATS.GEMINI_CLI;
|
const clientRequestedStreaming = body.stream === true || sourceFormat === FORMATS.ANTIGRAVITY || sourceFormat === FORMATS.GEMINI || sourceFormat === FORMATS.GEMINI_CLI;
|
||||||
const providerRequiresStreaming = provider === "openai" || provider === "codex";
|
const providerRequiresStreaming = provider === "openai" || provider === "codex";
|
||||||
const stream = providerRequiresStreaming ? true : (body.stream !== false);
|
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);
|
const reqLogger = await createRequestLogger(sourceFormat, targetFormat, model);
|
||||||
if (clientRawRequest) reqLogger.logClientRawRequest(clientRawRequest.endpoint, clientRawRequest.body, clientRawRequest.headers);
|
if (clientRawRequest) reqLogger.logClientRawRequest(clientRawRequest.endpoint, clientRawRequest.body, clientRawRequest.headers);
|
||||||
@@ -47,6 +56,10 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|||||||
log?.debug?.("FORMAT", `${sourceFormat} → ${targetFormat} | stream=${stream}`);
|
log?.debug?.("FORMAT", `${sourceFormat} → ${targetFormat} | stream=${stream}`);
|
||||||
|
|
||||||
let translatedBody = translateRequest(sourceFormat, targetFormat, model, body, stream, credentials, provider, reqLogger);
|
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;
|
const toolNameMap = translatedBody._toolNameMap;
|
||||||
delete translatedBody._toolNameMap;
|
delete translatedBody._toolNameMap;
|
||||||
translatedBody.model = model;
|
translatedBody.model = model;
|
||||||
@@ -128,11 +141,14 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|||||||
|
|
||||||
// Handle 401/403 - try token refresh
|
// Handle 401/403 - try token refresh
|
||||||
if (providerResponse.status === HTTP_STATUS.UNAUTHORIZED || providerResponse.status === HTTP_STATUS.FORBIDDEN) {
|
if (providerResponse.status === HTTP_STATUS.UNAUTHORIZED || providerResponse.status === HTTP_STATUS.FORBIDDEN) {
|
||||||
|
try {
|
||||||
const newCredentials = await refreshWithRetry(() => executor.refreshCredentials(credentials, log), 3, log);
|
const newCredentials = await refreshWithRetry(() => executor.refreshCredentials(credentials, log), 3, log);
|
||||||
if (newCredentials?.accessToken || newCredentials?.copilotToken) {
|
if (newCredentials?.accessToken || newCredentials?.copilotToken) {
|
||||||
log?.info?.("TOKEN", `${provider.toUpperCase()} | refreshed`);
|
log?.info?.("TOKEN", `${provider.toUpperCase()} | refreshed`);
|
||||||
Object.assign(credentials, newCredentials);
|
Object.assign(credentials, newCredentials);
|
||||||
if (onCredentialsRefreshed) await onCredentialsRefreshed(newCredentials);
|
if (onCredentialsRefreshed) {
|
||||||
|
try { await onCredentialsRefreshed(newCredentials); } catch (e) { log?.warn?.("TOKEN", `onCredentialsRefreshed failed: ${e.message}`); }
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const retryResult = await executor.execute({ model, body: translatedBody, stream, credentials, signal: streamController.signal, log, proxyOptions });
|
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; }
|
if (retryResult.response.ok) { providerResponse = retryResult.response; providerUrl = retryResult.url; }
|
||||||
@@ -140,6 +156,9 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|||||||
} else {
|
} else {
|
||||||
log?.warn?.("TOKEN", `${provider.toUpperCase()} | refresh failed`);
|
log?.warn?.("TOKEN", `${provider.toUpperCase()} | refresh failed`);
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log?.warn?.("TOKEN", `${provider.toUpperCase()} | refresh threw: ${e.message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provider returned error
|
// Provider returned error
|
||||||
@@ -173,12 +192,14 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|||||||
// Provider forced streaming but client wants JSON
|
// Provider forced streaming but client wants JSON
|
||||||
if (!clientRequestedStreaming && providerRequiresStreaming) {
|
if (!clientRequestedStreaming && providerRequiresStreaming) {
|
||||||
const result = await handleForcedSSEToJson({ ...sharedCtx, providerResponse, sourceFormat, trackDone, appendLog });
|
const result = await handleForcedSSEToJson({ ...sharedCtx, providerResponse, sourceFormat, trackDone, appendLog });
|
||||||
if (result) return result;
|
if (result) { streamController.handleComplete(); return result; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// True non-streaming response
|
// True non-streaming response
|
||||||
if (!stream) {
|
if (!stream) {
|
||||||
return handleNonStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat, reqLogger, trackDone, appendLog });
|
const result = await handleNonStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat, reqLogger, trackDone, appendLog });
|
||||||
|
streamController.handleComplete();
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Streaming response
|
// Streaming response
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { FORMATS } from "../../translator/formats.js";
|
import { FORMATS } from "../../translator/formats.js";
|
||||||
import { needsTranslation } from "../../translator/index.js";
|
import { needsTranslation } from "../../translator/index.js";
|
||||||
|
import { ollamaBodyToOpenAI } from "../../translator/response/ollama-to-openai.js";
|
||||||
import { addBufferToUsage, filterUsageForFormat } from "../../utils/usageTracking.js";
|
import { addBufferToUsage, filterUsageForFormat } from "../../utils/usageTracking.js";
|
||||||
import { createErrorResult } from "../../utils/error.js";
|
import { createErrorResult } from "../../utils/error.js";
|
||||||
import { HTTP_STATUS } from "../../config/constants.js";
|
import { HTTP_STATUS } from "../../config/runtimeConfig.js";
|
||||||
import { parseSSEToOpenAIResponse } from "./sseToJsonHandler.js";
|
import { parseSSEToOpenAIResponse } from "./sseToJsonHandler.js";
|
||||||
import { buildRequestDetail, extractRequestConfig, extractUsageFromResponse, saveUsageStats } from "./requestDetail.js";
|
import { buildRequestDetail, extractRequestConfig, extractUsageFromResponse, saveUsageStats } from "./requestDetail.js";
|
||||||
import { appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js";
|
import { appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js";
|
||||||
@@ -76,8 +77,12 @@ export function translateNonStreamingResponse(responseBody, targetFormat, source
|
|||||||
const toolCalls = [];
|
const toolCalls = [];
|
||||||
|
|
||||||
for (const block of responseBody.content) {
|
for (const block of responseBody.content) {
|
||||||
if (block.type === "text") textContent += block.text;
|
if (block.type === "text") {
|
||||||
else if (block.type === "thinking") thinkingContent += block.thinking || "";
|
// 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") {
|
else if (block.type === "tool_use") {
|
||||||
toolCalls.push({ id: block.id, type: "function", function: { name: block.name, arguments: JSON.stringify(block.input || {}) } });
|
toolCalls.push({ id: block.id, type: "function", function: { name: block.name, arguments: JSON.stringify(block.input || {}) } });
|
||||||
}
|
}
|
||||||
@@ -111,6 +116,11 @@ export function translateNonStreamingResponse(responseBody, targetFormat, source
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ollama
|
||||||
|
if (targetFormat === FORMATS.OLLAMA) {
|
||||||
|
return ollamaBodyToOpenAI(responseBody);
|
||||||
|
}
|
||||||
|
|
||||||
return responseBody;
|
return responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { convertResponsesStreamToJson } from "../../transformer/streamToJsonConverter.js";
|
import { convertResponsesStreamToJson } from "../../transformer/streamToJsonConverter.js";
|
||||||
import { createErrorResult } from "../../utils/error.js";
|
import { createErrorResult } from "../../utils/error.js";
|
||||||
import { HTTP_STATUS } from "../../config/constants.js";
|
import { HTTP_STATUS } from "../../config/runtimeConfig.js";
|
||||||
import { FORMATS } from "../../translator/formats.js";
|
import { FORMATS } from "../../translator/formats.js";
|
||||||
import { buildRequestDetail, extractRequestConfig, saveUsageStats } from "./requestDetail.js";
|
import { buildRequestDetail, extractRequestConfig, saveUsageStats } from "./requestDetail.js";
|
||||||
import { saveRequestDetail, appendRequestLog } from "@/lib/usageDb.js";
|
import { saveRequestDetail, appendRequestLog } from "@/lib/usageDb.js";
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ export function buildOnStreamComplete({ provider, model, connectionId, apiKey, r
|
|||||||
ttft: ttftAt ? ttftAt - requestStartTime : Date.now() - requestStartTime,
|
ttft: ttftAt ? ttftAt - requestStartTime : Date.now() - requestStartTime,
|
||||||
total: Date.now() - requestStartTime
|
total: Date.now() - requestStartTime
|
||||||
};
|
};
|
||||||
|
const safeContent = contentObj?.content || "[Empty streaming response]";
|
||||||
|
const safeThinking = contentObj?.thinking || null;
|
||||||
|
|
||||||
saveRequestDetail(buildRequestDetail({
|
saveRequestDetail(buildRequestDetail({
|
||||||
provider, model, connectionId,
|
provider, model, connectionId,
|
||||||
@@ -83,8 +85,8 @@ export function buildOnStreamComplete({ provider, model, connectionId, apiKey, r
|
|||||||
tokens: usage || { prompt_tokens: 0, completion_tokens: 0 },
|
tokens: usage || { prompt_tokens: 0, completion_tokens: 0 },
|
||||||
request: extractRequestConfig(body, stream),
|
request: extractRequestConfig(body, stream),
|
||||||
providerRequest: finalBody || translatedBody || null,
|
providerRequest: finalBody || translatedBody || null,
|
||||||
providerResponse: contentObj.content || "[Empty streaming response]",
|
providerResponse: safeContent,
|
||||||
response: { content: contentObj.content || "[Empty streaming response]", thinking: contentObj.thinking || null, type: "streaming" },
|
response: { content: safeContent, thinking: safeThinking, type: "streaming" },
|
||||||
status: "success"
|
status: "success"
|
||||||
}, { id: streamDetailId })).catch(err => {
|
}, { id: streamDetailId })).catch(err => {
|
||||||
console.error("[RequestDetail] Failed to update streaming content:", err.message);
|
console.error("[RequestDetail] Failed to update streaming content:", err.message);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js";
|
import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js";
|
||||||
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js";
|
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js";
|
||||||
import { HTTP_STATUS } from "../config/constants.js";
|
import { HTTP_STATUS } from "../config/runtimeConfig.js";
|
||||||
import { getExecutor } from "../executors/index.js";
|
import { getExecutor } from "../executors/index.js";
|
||||||
import { refreshWithRetry } from "../services/tokenRefresh.js";
|
import { refreshWithRetry } from "../services/tokenRefresh.js";
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -2,7 +2,9 @@
|
|||||||
import "./utils/proxyFetch.js";
|
import "./utils/proxyFetch.js";
|
||||||
|
|
||||||
// Config
|
// Config
|
||||||
export { PROVIDERS, OAUTH_ENDPOINTS, CACHE_TTL, DEFAULT_MAX_TOKENS, CLAUDE_SYSTEM_PROMPT, COOLDOWN_MS, BACKOFF_CONFIG } from "./config/constants.js";
|
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 {
|
export {
|
||||||
PROVIDER_MODELS,
|
PROVIDER_MODELS,
|
||||||
getProviderModels,
|
getProviderModels,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { COOLDOWN_MS, BACKOFF_CONFIG, HTTP_STATUS } from "../config/constants.js";
|
import { COOLDOWN_MS, BACKOFF_CONFIG, HTTP_STATUS } from "../config/runtimeConfig.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate exponential backoff cooldown for rate limits (429)
|
* Calculate exponential backoff cooldown for rate limits (429)
|
||||||
|
|||||||
@@ -38,7 +38,14 @@ export async function handleComboChat({ body, models, handleSingleModel, log })
|
|||||||
const modelStr = models[i];
|
const modelStr = models[i];
|
||||||
log.info("COMBO", `Trying model ${i + 1}/${models.length}: ${modelStr}`);
|
log.info("COMBO", `Trying model ${i + 1}/${models.length}: ${modelStr}`);
|
||||||
|
|
||||||
const result = await handleSingleModel(body, 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
|
// Success or client error - return response
|
||||||
if (result.ok || result.status < 500) {
|
if (result.ok || result.status < 500) {
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ const ALIAS_TO_PROVIDER_ID = {
|
|||||||
ch: "chutes",
|
ch: "chutes",
|
||||||
chutes: "chutes",
|
chutes: "chutes",
|
||||||
cursor: "cursor",
|
cursor: "cursor",
|
||||||
|
vx: "vertex",
|
||||||
|
vertex: "vertex",
|
||||||
|
vxp: "vertex-partner",
|
||||||
|
"vertex-partner": "vertex-partner",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* This significantly reduces the risk of being flagged by Google's anti-abuse systems.
|
* 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/constants.js";
|
import { CLOUD_CODE_API, LOAD_CODE_ASSIST_HEADERS, LOAD_CODE_ASSIST_METADATA } from "../config/appConstants.js";
|
||||||
|
|
||||||
// ─── Cache ────────────────────────────────────────────────────────────────────
|
// ─── Cache ────────────────────────────────────────────────────────────────────
|
||||||
// connectionId -> { projectId: string, fetchedAt: number }
|
// connectionId -> { projectId: string, fetchedAt: number }
|
||||||
@@ -254,7 +254,9 @@ async function onboardUser(accessToken, tierID, externalSignal) {
|
|||||||
console.warn(`[ProjectId] onboardUser failed after ${MAX_ATTEMPTS} attempts: ${error.message}`);
|
console.warn(`[ProjectId] onboardUser failed after ${MAX_ATTEMPTS} attempts: ${error.message}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
throw error;
|
// 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 {
|
} finally {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
externalSignal?.removeEventListener("abort", forwardAbort);
|
externalSignal?.removeEventListener("abort", forwardAbort);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PROVIDERS } from "../config/constants.js";
|
import { PROVIDERS } from "../config/providers.js";
|
||||||
import { buildClineHeaders } from "../../src/shared/utils/clineAuth.js";
|
import { buildClineHeaders } from "../../src/shared/utils/clineAuth.js";
|
||||||
|
|
||||||
const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-";
|
const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-";
|
||||||
@@ -255,7 +255,7 @@ export function buildProviderHeaders(provider, credentials, stream = true, body
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "github":
|
case "github": {
|
||||||
// GitHub Copilot requires special headers to mimic VSCode
|
// GitHub Copilot requires special headers to mimic VSCode
|
||||||
// Prioritize copilotToken from providerSpecificData, fallback to accessToken
|
// Prioritize copilotToken from providerSpecificData, fallback to accessToken
|
||||||
const githubToken = credentials.copilotToken || credentials.accessToken;
|
const githubToken = credentials.copilotToken || credentials.accessToken;
|
||||||
@@ -279,6 +279,7 @@ export function buildProviderHeaders(provider, credentials, stream = true, body
|
|||||||
headers["X-Initiator"] = "user";
|
headers["X-Initiator"] = "user";
|
||||||
headers["Accept"] = "application/json";
|
headers["Accept"] = "application/json";
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "codex":
|
case "codex":
|
||||||
case "qwen":
|
case "qwen":
|
||||||
@@ -298,6 +299,12 @@ export function buildProviderHeaders(provider, credentials, stream = true, body
|
|||||||
headers["x-api-key"] = credentials.apiKey;
|
headers["x-api-key"] = credentials.apiKey;
|
||||||
break;
|
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:
|
default:
|
||||||
headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`;
|
headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`;
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { PROVIDERS, OAUTH_ENDPOINTS, GITHUB_COPILOT } from "../config/constants.js";
|
import { PROVIDERS } from "../config/providers.js";
|
||||||
|
import { OAUTH_ENDPOINTS, GITHUB_COPILOT } from "../config/appConstants.js";
|
||||||
|
|
||||||
// Token expiry buffer (refresh if expires within 5 minutes)
|
// Token expiry buffer (refresh if expires within 5 minutes)
|
||||||
export const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
|
export const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
|
||||||
@@ -68,6 +69,7 @@ export async function refreshAccessToken(provider, refreshToken, credentials, lo
|
|||||||
* Specialized refresh for Claude OAuth tokens
|
* Specialized refresh for Claude OAuth tokens
|
||||||
*/
|
*/
|
||||||
export async function refreshClaudeOAuthToken(refreshToken, log) {
|
export async function refreshClaudeOAuthToken(refreshToken, log) {
|
||||||
|
try {
|
||||||
const response = await fetch(OAUTH_ENDPOINTS.anthropic.token, {
|
const response = await fetch(OAUTH_ENDPOINTS.anthropic.token, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -83,32 +85,24 @@ export async function refreshClaudeOAuthToken(refreshToken, log) {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Claude OAuth token", {
|
log?.error?.("TOKEN_REFRESH", "Failed to refresh Claude OAuth token", { status: response.status, error: errorText });
|
||||||
status: response.status,
|
|
||||||
error: errorText,
|
|
||||||
});
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokens = await response.json();
|
const tokens = await response.json();
|
||||||
|
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Claude OAuth token", { hasNewAccessToken: !!tokens.access_token, expiresIn: tokens.expires_in });
|
||||||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Claude OAuth token", {
|
return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in };
|
||||||
hasNewAccessToken: !!tokens.access_token,
|
} catch (error) {
|
||||||
hasNewRefreshToken: !!tokens.refresh_token,
|
log?.error?.("TOKEN_REFRESH", `Network error refreshing Claude token: ${error.message}`);
|
||||||
expiresIn: tokens.expires_in,
|
return null;
|
||||||
});
|
}
|
||||||
|
|
||||||
return {
|
|
||||||
accessToken: tokens.access_token,
|
|
||||||
refreshToken: tokens.refresh_token || refreshToken,
|
|
||||||
expiresIn: tokens.expires_in,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Specialized refresh for Google providers (Gemini, Antigravity)
|
* Specialized refresh for Google providers (Gemini, Antigravity)
|
||||||
*/
|
*/
|
||||||
export async function refreshGoogleToken(refreshToken, clientId, clientSecret, log) {
|
export async function refreshGoogleToken(refreshToken, clientId, clientSecret, log) {
|
||||||
|
try {
|
||||||
const response = await fetch(OAUTH_ENDPOINTS.google.token, {
|
const response = await fetch(OAUTH_ENDPOINTS.google.token, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -125,26 +119,17 @@ export async function refreshGoogleToken(refreshToken, clientId, clientSecret, l
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Google token", {
|
log?.error?.("TOKEN_REFRESH", "Failed to refresh Google token", { status: response.status, error: errorText });
|
||||||
status: response.status,
|
|
||||||
error: errorText,
|
|
||||||
});
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokens = await response.json();
|
const tokens = await response.json();
|
||||||
|
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Google token", { hasNewAccessToken: !!tokens.access_token, expiresIn: tokens.expires_in });
|
||||||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Google token", {
|
return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in };
|
||||||
hasNewAccessToken: !!tokens.access_token,
|
} catch (error) {
|
||||||
hasNewRefreshToken: !!tokens.refresh_token,
|
log?.error?.("TOKEN_REFRESH", `Network error refreshing Google token: ${error.message}`);
|
||||||
expiresIn: tokens.expires_in,
|
return null;
|
||||||
});
|
}
|
||||||
|
|
||||||
return {
|
|
||||||
accessToken: tokens.access_token,
|
|
||||||
refreshToken: tokens.refresh_token || refreshToken,
|
|
||||||
expiresIn: tokens.expires_in,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -205,6 +190,7 @@ export async function refreshQwenToken(refreshToken, log) {
|
|||||||
* Specialized refresh for Codex (OpenAI) OAuth tokens
|
* Specialized refresh for Codex (OpenAI) OAuth tokens
|
||||||
*/
|
*/
|
||||||
export async function refreshCodexToken(refreshToken, log) {
|
export async function refreshCodexToken(refreshToken, log) {
|
||||||
|
try {
|
||||||
const response = await fetch(OAUTH_ENDPOINTS.openai.token, {
|
const response = await fetch(OAUTH_ENDPOINTS.openai.token, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -241,6 +227,10 @@ export async function refreshCodexToken(refreshToken, log) {
|
|||||||
refreshToken: tokens.refresh_token || refreshToken,
|
refreshToken: tokens.refresh_token || refreshToken,
|
||||||
expiresIn: tokens.expires_in,
|
expiresIn: tokens.expires_in,
|
||||||
};
|
};
|
||||||
|
} catch (error) {
|
||||||
|
log?.error?.("TOKEN_REFRESH", `Network error refreshing Codex token: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -507,6 +497,13 @@ export async function getAccessToken(provider, credentials, log) {
|
|||||||
log
|
log
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case "vertex":
|
||||||
|
case "vertex-partner": {
|
||||||
|
const saJson = parseVertexSaJson(credentials.apiKey);
|
||||||
|
if (!saJson) return null;
|
||||||
|
return await refreshVertexToken(saJson, log);
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
log?.warn?.("TOKEN_REFRESH", `Unsupported provider for token refresh: ${provider}`);
|
log?.warn?.("TOKEN_REFRESH", `Unsupported provider for token refresh: ${provider}`);
|
||||||
return null;
|
return null;
|
||||||
@@ -544,6 +541,12 @@ export async function refreshTokenByProvider(provider, credentials, log) {
|
|||||||
credentials.providerSpecificData,
|
credentials.providerSpecificData,
|
||||||
log
|
log
|
||||||
);
|
);
|
||||||
|
case "vertex":
|
||||||
|
case "vertex-partner": {
|
||||||
|
const saJson = parseVertexSaJson(credentials.apiKey);
|
||||||
|
if (!saJson) return null;
|
||||||
|
return refreshVertexToken(saJson, log);
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return refreshAccessToken(provider, credentials.refreshToken, credentials, log);
|
return refreshAccessToken(provider, credentials.refreshToken, credentials, log);
|
||||||
}
|
}
|
||||||
@@ -623,6 +626,81 @@ export async function getAllAccessTokens(userInfo, log) {
|
|||||||
return results;
|
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
|
* Refresh token with retry and exponential backoff
|
||||||
* Retries on failure with increasing delay: 1s, 2s, 3s...
|
* Retries on failure with increasing delay: 1s, 2s, 3s...
|
||||||
|
|||||||
+86
-23
@@ -2,7 +2,7 @@
|
|||||||
* Usage Fetcher - Get usage data from provider APIs
|
* Usage Fetcher - Get usage data from provider APIs
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { CLIENT_METADATA, getPlatformUserAgent } from "../config/constants.js";
|
import { CLIENT_METADATA, getPlatformUserAgent } from "../config/appConstants.js";
|
||||||
|
|
||||||
// GitHub API config
|
// GitHub API config
|
||||||
const GITHUB_CONFIG = {
|
const GITHUB_CONFIG = {
|
||||||
@@ -27,8 +27,10 @@ const CODEX_CONFIG = {
|
|||||||
|
|
||||||
// Claude API config
|
// Claude API config
|
||||||
const CLAUDE_CONFIG = {
|
const CLAUDE_CONFIG = {
|
||||||
|
oauthUsageUrl: "https://api.anthropic.com/api/oauth/usage",
|
||||||
usageUrl: "https://api.anthropic.com/v1/organizations/{org_id}/usage",
|
usageUrl: "https://api.anthropic.com/v1/organizations/{org_id}/usage",
|
||||||
settingsUrl: "https://api.anthropic.com/v1/settings",
|
settingsUrl: "https://api.anthropic.com/v1/settings",
|
||||||
|
apiVersion: "2023-06-01",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -211,14 +213,17 @@ async function getGeminiUsage(accessToken) {
|
|||||||
*/
|
*/
|
||||||
async function getAntigravityUsage(accessToken, providerSpecificData) {
|
async function getAntigravityUsage(accessToken, providerSpecificData) {
|
||||||
try {
|
try {
|
||||||
// First get project ID from subscription info
|
// Fetch subscription info once — reuse for both projectId and plan
|
||||||
const projectId = await getAntigravityProjectId(accessToken);
|
const subscriptionInfo = await getAntigravitySubscriptionInfo(accessToken);
|
||||||
|
const projectId = subscriptionInfo?.cloudaicompanionProject || null;
|
||||||
|
|
||||||
// Fetch quota data with timeout
|
// Fetch quota data with timeout
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
|
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
|
||||||
|
|
||||||
const response = await fetch(ANTIGRAVITY_CONFIG.quotaApiUrl, {
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetch(ANTIGRAVITY_CONFIG.quotaApiUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Authorization": `Bearer ${accessToken}`,
|
"Authorization": `Bearer ${accessToken}`,
|
||||||
@@ -233,8 +238,9 @@ async function getAntigravityUsage(accessToken, providerSpecificData) {
|
|||||||
}),
|
}),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
if (response.status === 403) {
|
if (response.status === 403) {
|
||||||
return {
|
return {
|
||||||
@@ -300,9 +306,6 @@ async function getAntigravityUsage(accessToken, providerSpecificData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get subscription info for plan type
|
|
||||||
const subscriptionInfo = await getAntigravitySubscriptionInfo(accessToken);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plan: subscriptionInfo?.currentTier?.name || "Unknown",
|
plan: subscriptionInfo?.currentTier?.name || "Unknown",
|
||||||
quotas,
|
quotas,
|
||||||
@@ -330,10 +333,9 @@ async function getAntigravityProjectId(accessToken) {
|
|||||||
* Get Antigravity subscription info
|
* Get Antigravity subscription info
|
||||||
*/
|
*/
|
||||||
async function getAntigravitySubscriptionInfo(accessToken) {
|
async function getAntigravitySubscriptionInfo(accessToken) {
|
||||||
try {
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
|
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
|
||||||
|
try {
|
||||||
const response = await fetch(ANTIGRAVITY_CONFIG.loadProjectApiUrl, {
|
const response = await fetch(ANTIGRAVITY_CONFIG.loadProjectApiUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -346,45 +348,107 @@ async function getAntigravitySubscriptionInfo(accessToken) {
|
|||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
|
|
||||||
if (!response.ok) return null;
|
if (!response.ok) return null;
|
||||||
|
|
||||||
return await response.json();
|
return await response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Antigravity Subscription] Error:", error.message);
|
console.error("[Antigravity Subscription] Error:", error.message);
|
||||||
return null;
|
return null;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Claude Usage - Try to fetch from Anthropic API
|
* Claude Usage - Primary: OAuth endpoint, Fallback: legacy settings/org endpoint
|
||||||
*/
|
*/
|
||||||
async function getClaudeUsage(accessToken) {
|
async function getClaudeUsage(accessToken) {
|
||||||
try {
|
try {
|
||||||
// Try to get organization/account settings first
|
// Primary: OAuth usage endpoint (Claude Code consumer OAuth tokens)
|
||||||
const settingsResponse = await fetch("https://api.anthropic.com/v1/settings", {
|
const oauthResponse = await fetch(CLAUDE_CONFIG.oauthUsageUrl, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
"Authorization": `Bearer ${accessToken}`,
|
"Authorization": `Bearer ${accessToken}`,
|
||||||
"Content-Type": "application/json",
|
"anthropic-beta": "oauth-2025-04-20",
|
||||||
"anthropic-version": "2023-06-01",
|
"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) {
|
if (settingsResponse.ok) {
|
||||||
const settings = await settingsResponse.json();
|
const settings = await settingsResponse.json();
|
||||||
|
|
||||||
// Try usage endpoint if we have org info
|
|
||||||
if (settings.organization_id) {
|
if (settings.organization_id) {
|
||||||
const usageResponse = await fetch(
|
const usageResponse = await fetch(
|
||||||
`https://api.anthropic.com/v1/organizations/${settings.organization_id}/usage`,
|
CLAUDE_CONFIG.usageUrl.replace("{org_id}", settings.organization_id),
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
"Authorization": `Bearer ${accessToken}`,
|
"Authorization": `Bearer ${accessToken}`,
|
||||||
"Content-Type": "application/json",
|
"anthropic-version": CLAUDE_CONFIG.apiVersion,
|
||||||
"anthropic-version": "2023-06-01",
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -406,7 +470,6 @@ async function getClaudeUsage(accessToken) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// If settings API fails, OAuth token may not have required scope
|
|
||||||
return { message: "Claude connected. Usage API requires admin permissions." };
|
return { message: "Claude connected. Usage API requires admin permissions." };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { message: `Claude connected. Unable to fetch usage: ${error.message}` };
|
return { message: `Claude connected. Unable to fetch usage: ${error.message}` };
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ export const FORMATS = {
|
|||||||
CODEX: "codex",
|
CODEX: "codex",
|
||||||
ANTIGRAVITY: "antigravity",
|
ANTIGRAVITY: "antigravity",
|
||||||
KIRO: "kiro",
|
KIRO: "kiro",
|
||||||
CURSOR: "cursor"
|
CURSOR: "cursor",
|
||||||
|
OLLAMA: "ollama"
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DEFAULT_MAX_TOKENS, DEFAULT_MIN_TOKENS } from "../../config/constants.js";
|
import { DEFAULT_MAX_TOKENS, DEFAULT_MIN_TOKENS } from "../../config/runtimeConfig.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adjust max_tokens based on request context
|
* Adjust max_tokens based on request context
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ function ensureInitialized() {
|
|||||||
require("./request/openai-responses.js");
|
require("./request/openai-responses.js");
|
||||||
require("./request/openai-to-kiro.js");
|
require("./request/openai-to-kiro.js");
|
||||||
require("./request/openai-to-cursor.js");
|
require("./request/openai-to-cursor.js");
|
||||||
|
require("./request/openai-to-ollama.js");
|
||||||
|
|
||||||
// Response translators
|
// Response translators
|
||||||
require("./response/claude-to-openai.js");
|
require("./response/claude-to-openai.js");
|
||||||
@@ -45,6 +46,7 @@ function ensureInitialized() {
|
|||||||
require("./response/openai-responses.js");
|
require("./response/openai-responses.js");
|
||||||
require("./response/kiro-to-openai.js");
|
require("./response/kiro-to-openai.js");
|
||||||
require("./response/cursor-to-openai.js");
|
require("./response/cursor-to-openai.js");
|
||||||
|
require("./response/ollama-to-openai.js");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Translate request: source -> openai -> target
|
// Translate request: source -> openai -> target
|
||||||
|
|||||||
@@ -228,12 +228,17 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert tool results
|
// Convert tool results - output must be a string for Responses API
|
||||||
if (msg.role === "tool") {
|
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({
|
result.input.push({
|
||||||
type: "function_call_output",
|
type: "function_call_output",
|
||||||
call_id: msg.tool_call_id,
|
call_id: msg.tool_call_id,
|
||||||
output: msg.content
|
output
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { register } from "../index.js";
|
import { register } from "../index.js";
|
||||||
import { FORMATS } from "../formats.js";
|
import { FORMATS } from "../formats.js";
|
||||||
import { CLAUDE_SYSTEM_PROMPT } from "../../config/constants.js";
|
import { CLAUDE_SYSTEM_PROMPT } from "../../config/appConstants.js";
|
||||||
import { adjustMaxTokens } from "../helpers/maxTokensHelper.js";
|
import { adjustMaxTokens } from "../helpers/maxTokensHelper.js";
|
||||||
|
|
||||||
// Empty prefix matches real Claude Code behavior (no tool name prefix).
|
// Empty prefix matches real Claude Code behavior (no tool name prefix).
|
||||||
@@ -100,6 +100,21 @@ export function openaiToClaudeRequest(model, body, stream) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// System with Claude Code prompt and cache_control
|
||||||
const claudeCodePrompt = { type: "text", text: CLAUDE_SYSTEM_PROMPT };
|
const claudeCodePrompt = { type: "text", text: CLAUDE_SYSTEM_PROMPT };
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* OpenAI to Cursor Request Translator
|
* OpenAI to Cursor Request Translator
|
||||||
* - assistant tool_calls → kept as-is (Cursor generates tool calls)
|
* Converts OpenAI messages to Cursor ask/agent format.
|
||||||
* - Claude tool_use blocks → converted to OpenAI tool_calls format
|
*
|
||||||
* - tool results → converted to user message string
|
* 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 { register } from "../index.js";
|
||||||
import { FORMATS } from "../formats.js";
|
import { FORMATS } from "../formats.js";
|
||||||
@@ -10,97 +12,155 @@ import { FORMATS } from "../formats.js";
|
|||||||
function extractContent(content) {
|
function extractContent(content) {
|
||||||
if (typeof content === "string") return content;
|
if (typeof content === "string") return content;
|
||||||
if (Array.isArray(content)) {
|
if (Array.isArray(content)) {
|
||||||
return content.filter(p => p.type === "text").map(p => p.text).join("");
|
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 "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a map of tool_use_id → tool_name from the previous assistant message
|
function sanitizeToolResultText(text) {
|
||||||
function getToolNameMap(prevMsg) {
|
// Strip non-printable control chars that can produce backend request errors
|
||||||
const map = {};
|
return text.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "");
|
||||||
if (!prevMsg?.tool_calls) return map;
|
|
||||||
for (const tc of prevMsg.tool_calls) {
|
|
||||||
if (tc.id && tc.function?.name) map[tc.id] = tc.function.name;
|
|
||||||
}
|
}
|
||||||
return map;
|
|
||||||
|
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) {
|
function convertMessages(messages) {
|
||||||
const result = [];
|
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++) {
|
for (let i = 0; i < messages.length; i++) {
|
||||||
const msg = messages[i];
|
const msg = messages[i];
|
||||||
|
|
||||||
if (msg.role === "system") {
|
if (msg.role === "system") {
|
||||||
result.push({ role: "user", content: `[System Instructions]\n${msg.content}` });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.role === "user") {
|
|
||||||
if (Array.isArray(msg.content)) {
|
|
||||||
const parts = [];
|
|
||||||
const prevMsg = result[result.length - 1];
|
|
||||||
const nameMap = getToolNameMap(prevMsg);
|
|
||||||
for (const block of msg.content) {
|
|
||||||
if (block.type === "text") {
|
|
||||||
parts.push(block.text);
|
|
||||||
} else if (block.type === "tool_result") {
|
|
||||||
// Claude format: user message with tool_result blocks
|
|
||||||
const toolResultText = extractContent(block.content) || "";
|
|
||||||
const toolCallId = block.tool_use_id || "";
|
|
||||||
const toolName = nameMap[toolCallId] || "";
|
|
||||||
parts.push(`<tool_result>\n<tool_name>${toolName}</tool_name>\n<tool_call_id>${toolCallId}</tool_call_id>\n<result>${toolResultText}</result>\n</tool_result>`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result.push({ role: "user", content: parts.join("\n") || "" });
|
|
||||||
} else {
|
|
||||||
result.push({ role: "user", content: extractContent(msg.content) || "" });
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.role === "tool") {
|
|
||||||
// Strip system-reminder tags injected by Claude Code
|
|
||||||
const raw = extractContent(msg.content) || "";
|
|
||||||
const toolContent = raw.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, "").trim();
|
|
||||||
const prevMsg = result[result.length - 1];
|
|
||||||
const nameMap = getToolNameMap(prevMsg);
|
|
||||||
const toolCallId = msg.tool_call_id || "";
|
|
||||||
const toolName = nameMap[toolCallId] || "";
|
|
||||||
result.push({
|
result.push({
|
||||||
role: "user",
|
role: "user",
|
||||||
content: `<tool_result>\n<tool_name>${toolName}</tool_name>\n<tool_call_id>${toolCallId}</tool_call_id>\n<result>${toolContent}</result>\n</tool_result>`
|
content: `[System Instructions]\n${extractContent(msg.content)}`
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.role === "assistant") {
|
if (msg.role === "tool") {
|
||||||
let content = extractContent(msg.content) || "";
|
const toolContent = extractContent(msg.content);
|
||||||
let tool_calls = null;
|
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.tool_calls && msg.tool_calls.length > 0) {
|
if (msg.role === "user" || msg.role === "assistant") {
|
||||||
// OpenAI format: strip `index` field
|
if (msg.role === "user" && Array.isArray(msg.content)) {
|
||||||
tool_calls = msg.tool_calls.map(({ index, ...tc }) => tc);
|
const parts = [];
|
||||||
} else if (Array.isArray(msg.content)) {
|
for (const block of msg.content) {
|
||||||
// Claude format: extract tool_use blocks from content array
|
if (!block || typeof block !== "object") continue;
|
||||||
const extracted = msg.content
|
if (block.type === "text") {
|
||||||
.filter(b => b.type === "tool_use")
|
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 => ({
|
.map(b => ({
|
||||||
id: b.id,
|
id: b.id || "",
|
||||||
type: "function",
|
type: "function",
|
||||||
function: {
|
function: {
|
||||||
name: b.name,
|
name: b.name || "tool",
|
||||||
arguments: JSON.stringify(b.input || {})
|
arguments: JSON.stringify(b.input || {})
|
||||||
}
|
}
|
||||||
}));
|
}))
|
||||||
if (extracted.length > 0) tool_calls = extracted;
|
.filter(tc => tc.id);
|
||||||
}
|
|
||||||
|
|
||||||
if (tool_calls) {
|
if (extractedToolCalls.length > 0) {
|
||||||
result.push({ role: "assistant", content, tool_calls });
|
result.push({
|
||||||
|
role: "assistant",
|
||||||
|
content: content || "",
|
||||||
|
tool_calls: extractedToolCalls
|
||||||
|
});
|
||||||
} else if (content) {
|
} else if (content) {
|
||||||
result.push({ role: "assistant", content });
|
result.push({ role: "assistant", content });
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
if (content) {
|
||||||
|
result.push({ role: msg.role, content });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { register } from "../index.js";
|
import { register } from "../index.js";
|
||||||
import { FORMATS } from "../formats.js";
|
import { FORMATS } from "../formats.js";
|
||||||
import { DEFAULT_THINKING_GEMINI_SIGNATURE } from "../../config/defaultThinkingSignature.js";
|
import { DEFAULT_THINKING_GEMINI_SIGNATURE } from "../../config/defaultThinkingSignature.js";
|
||||||
import { ANTIGRAVITY_DEFAULT_SYSTEM } from "../../config/constants.js";
|
import { ANTIGRAVITY_DEFAULT_SYSTEM } from "../../config/appConstants.js";
|
||||||
import { openaiToClaudeRequestForAntigravity } from "./openai-to-claude.js";
|
import { openaiToClaudeRequestForAntigravity } from "./openai-to-claude.js";
|
||||||
|
|
||||||
function generateUUID() {
|
function generateUUID() {
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { register } from "../index.js";
|
||||||
|
import { FORMATS } from "../formats.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Ollama NDJSON response to OpenAI SSE format
|
||||||
|
*
|
||||||
|
* Ollama response format:
|
||||||
|
* {"model": "...", "message": {"role": "assistant", "content": "..."}, "done": false}
|
||||||
|
* {"model": "...", "done": true, "prompt_eval_count": 123, "eval_count": 456}
|
||||||
|
*
|
||||||
|
* OpenAI format:
|
||||||
|
* {"id": "...", "object": "chat.completion.chunk", "created": 123, "model": "...",
|
||||||
|
* "choices": [{"index": 0, "delta": {"content": "..."}, "finish_reason": null}]}
|
||||||
|
*/
|
||||||
|
export function ollamaToOpenAI(chunk, state) {
|
||||||
|
if (!chunk || typeof chunk !== "object") return null;
|
||||||
|
|
||||||
|
// Initialize state on first chunk
|
||||||
|
if (!state.ollama) {
|
||||||
|
state.ollama = {
|
||||||
|
id: `chatcmpl-${Date.now()}`,
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
model: chunk.model || state.model
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, created, model } = state.ollama;
|
||||||
|
|
||||||
|
// Final chunk with done=true
|
||||||
|
if (chunk.done) {
|
||||||
|
const usage = extractUsage(chunk);
|
||||||
|
|
||||||
|
// Determine finish_reason based on done_reason and previous tool_calls
|
||||||
|
let finishReason = "stop";
|
||||||
|
if (chunk.done_reason === "tool_calls" || state.hadToolCalls) {
|
||||||
|
finishReason = "tool_calls";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
object: "chat.completion.chunk",
|
||||||
|
created: created,
|
||||||
|
model: model,
|
||||||
|
choices: [{
|
||||||
|
index: 0,
|
||||||
|
delta: {},
|
||||||
|
finish_reason: finishReason
|
||||||
|
}],
|
||||||
|
usage: usage
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content chunk
|
||||||
|
const message = chunk.message;
|
||||||
|
if (!message) return null;
|
||||||
|
|
||||||
|
const content = typeof message.content === "string" ? message.content : "";
|
||||||
|
const thinking = typeof message.thinking === "string" ? message.thinking : "";
|
||||||
|
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : null;
|
||||||
|
|
||||||
|
// Skip empty chunks
|
||||||
|
if (!content && !thinking && !toolCalls) return null;
|
||||||
|
|
||||||
|
// Accumulate content in state
|
||||||
|
if (content) {
|
||||||
|
state.accumulatedContent = (state.accumulatedContent || "") + content;
|
||||||
|
}
|
||||||
|
if (thinking) {
|
||||||
|
state.accumulatedThinking = (state.accumulatedThinking || "") + thinking;
|
||||||
|
}
|
||||||
|
|
||||||
|
const delta = {};
|
||||||
|
if (content) delta.content = content;
|
||||||
|
if (thinking) delta.reasoning_content = thinking;
|
||||||
|
|
||||||
|
// Convert Ollama tool_calls to OpenAI format
|
||||||
|
if (toolCalls) {
|
||||||
|
state.hadToolCalls = true;
|
||||||
|
delta.tool_calls = convertToolCalls(toolCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
object: "chat.completion.chunk",
|
||||||
|
created: created,
|
||||||
|
model: model,
|
||||||
|
choices: [{
|
||||||
|
index: 0,
|
||||||
|
delta: delta,
|
||||||
|
finish_reason: null
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract usage stats from Ollama response
|
||||||
|
*/
|
||||||
|
function extractUsage(ollamaChunk) {
|
||||||
|
return {
|
||||||
|
prompt_tokens: ollamaChunk.prompt_eval_count || 0,
|
||||||
|
completion_tokens: ollamaChunk.eval_count || 0,
|
||||||
|
total_tokens: (ollamaChunk.prompt_eval_count || 0) + (ollamaChunk.eval_count || 0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert tool_calls from Ollama format to OpenAI format
|
||||||
|
*/
|
||||||
|
function convertToolCalls(toolCalls) {
|
||||||
|
return toolCalls.map((tc, i) => ({
|
||||||
|
index: tc.function?.index ?? i,
|
||||||
|
id: tc.id || `call_${i}_${Date.now()}`,
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: tc.function?.name || "",
|
||||||
|
arguments: typeof tc.function?.arguments === "string"
|
||||||
|
? tc.function.arguments
|
||||||
|
: JSON.stringify(tc.function?.arguments || {})
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Ollama non-streaming response body to OpenAI chat.completion format
|
||||||
|
*/
|
||||||
|
export function ollamaBodyToOpenAI(body) {
|
||||||
|
const msg = body.message || {};
|
||||||
|
const content = msg.content || "";
|
||||||
|
const thinking = msg.thinking || "";
|
||||||
|
const toolCalls = Array.isArray(msg.tool_calls) ? msg.tool_calls : [];
|
||||||
|
|
||||||
|
const message = { role: "assistant" };
|
||||||
|
if (content) message.content = content;
|
||||||
|
if (thinking) message.reasoning_content = thinking;
|
||||||
|
if (toolCalls.length > 0) message.tool_calls = convertToolCalls(toolCalls);
|
||||||
|
if (!message.content && !message.tool_calls) message.content = "";
|
||||||
|
|
||||||
|
let finishReason = body.done_reason || "stop";
|
||||||
|
if (toolCalls.length > 0) finishReason = "tool_calls";
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `chatcmpl-${Date.now()}`,
|
||||||
|
object: "chat.completion",
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
model: body.model || "ollama",
|
||||||
|
choices: [{ index: 0, message, finish_reason: finishReason }],
|
||||||
|
usage: extractUsage(body)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register translator
|
||||||
|
register(FORMATS.OLLAMA, FORMATS.OPENAI, null, ollamaToOpenAI);
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import { detectFormat } from "../services/provider.js";
|
import { detectFormat } from "../services/provider.js";
|
||||||
import { translateResponse, initState } from "../translator/index.js";
|
import { translateResponse, initState } from "../translator/index.js";
|
||||||
import { FORMATS } from "../translator/formats.js";
|
import { FORMATS } from "../translator/formats.js";
|
||||||
import { SKIP_PATTERNS } from "../config/constants.js";
|
import { SKIP_PATTERNS } from "../config/runtimeConfig.js";
|
||||||
import { formatSSE } from "./stream.js";
|
import { formatSSE } from "./stream.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check for bypass patterns - return fake response without calling provider
|
* Check for bypass patterns - return fake response without calling provider
|
||||||
* Only works for Claude CLI requests
|
* Only works for Claude CLI requests
|
||||||
*/
|
*/
|
||||||
export function handleBypassRequest(body, model, userAgent = "") {
|
export function handleBypassRequest(body, model, userAgent = "", ccFilterNaming = false) {
|
||||||
if (!userAgent.includes("claude-cli")) return null;
|
if (!userAgent.includes("claude-cli")) return null;
|
||||||
if (!body.messages?.length) return null;
|
if (!body.messages?.length) return null;
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@ export function handleBypassRequest(body, model, userAgent = "") {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let shouldBypass = false;
|
let shouldBypass = false;
|
||||||
|
let namingBypass = false;
|
||||||
|
|
||||||
// Pattern 1: Title extraction (assistant message = "{")
|
// Pattern 1: Title extraction (assistant message = "{")
|
||||||
const lastMsg = messages[messages.length - 1];
|
const lastMsg = messages[messages.length - 1];
|
||||||
@@ -54,23 +55,50 @@ export function handleBypassRequest(body, model, userAgent = "") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pattern 5: CC naming request (topic title extraction by Claude Code CLI)
|
||||||
|
// Claude format: system is top-level body.system field, not inside messages
|
||||||
|
if (!shouldBypass && ccFilterNaming) {
|
||||||
|
const systemMsg = messages.find(m => m.role === "system");
|
||||||
|
const systemFromMessages = getText(systemMsg?.content);
|
||||||
|
const systemFromBody = Array.isArray(body.system)
|
||||||
|
? body.system.filter(s => s.type === "text").map(s => s.text).join(" ")
|
||||||
|
: (typeof body.system === "string" ? body.system : "");
|
||||||
|
const systemText = systemFromMessages || systemFromBody;
|
||||||
|
if (systemText.includes("isNewTopic")) {
|
||||||
|
shouldBypass = true;
|
||||||
|
namingBypass = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!shouldBypass) return null;
|
if (!shouldBypass) return null;
|
||||||
|
|
||||||
const sourceFormat = detectFormat(body);
|
const sourceFormat = detectFormat(body);
|
||||||
const stream = body.stream !== false;
|
const stream = body.stream !== false;
|
||||||
|
|
||||||
|
// For naming bypass, generate title from user message
|
||||||
|
if (namingBypass) {
|
||||||
|
const userMsg = messages.find(m => m.role === "user");
|
||||||
|
const userText = getText(userMsg?.content);
|
||||||
|
const title = userText.trim().split(/\s+/).slice(0, 3).join(" ");
|
||||||
|
const namingText = JSON.stringify({ isNewTopic: true, title });
|
||||||
|
return stream
|
||||||
|
? createStreamingResponse(sourceFormat, model, namingText)
|
||||||
|
: createNonStreamingResponse(sourceFormat, model, namingText);
|
||||||
|
}
|
||||||
|
|
||||||
return stream
|
return stream
|
||||||
? createStreamingResponse(sourceFormat, model)
|
? createStreamingResponse(sourceFormat, model)
|
||||||
: createNonStreamingResponse(sourceFormat, model);
|
: createNonStreamingResponse(sourceFormat, model);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_BYPASS_TEXT = "CLI Command Execution: Clear Terminal";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create OpenAI standard format response
|
* Create OpenAI standard format response
|
||||||
*/
|
*/
|
||||||
function createOpenAIResponse(model) {
|
function createOpenAIResponse(model, text = DEFAULT_BYPASS_TEXT) {
|
||||||
const id = `chatcmpl-${Date.now()}`;
|
const id = `chatcmpl-${Date.now()}`;
|
||||||
const created = Math.floor(Date.now() / 1000);
|
const created = Math.floor(Date.now() / 1000);
|
||||||
const text = "CLI Command Execution: Clear Terminal";
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -97,8 +125,8 @@ function createOpenAIResponse(model) {
|
|||||||
* Create non-streaming response with translation
|
* Create non-streaming response with translation
|
||||||
* Use translator to convert OpenAI → sourceFormat
|
* Use translator to convert OpenAI → sourceFormat
|
||||||
*/
|
*/
|
||||||
function createNonStreamingResponse(sourceFormat, model) {
|
function createNonStreamingResponse(sourceFormat, model, text) {
|
||||||
const openaiResponse = createOpenAIResponse(model);
|
const openaiResponse = createOpenAIResponse(model, text);
|
||||||
|
|
||||||
// If sourceFormat is OpenAI, return directly
|
// If sourceFormat is OpenAI, return directly
|
||||||
if (sourceFormat === FORMATS.OPENAI) {
|
if (sourceFormat === FORMATS.OPENAI) {
|
||||||
@@ -151,8 +179,8 @@ function createNonStreamingResponse(sourceFormat, model) {
|
|||||||
* Create streaming response with translation
|
* Create streaming response with translation
|
||||||
* Use translator to convert OpenAI chunks → sourceFormat
|
* Use translator to convert OpenAI chunks → sourceFormat
|
||||||
*/
|
*/
|
||||||
function createStreamingResponse(sourceFormat, model) {
|
function createStreamingResponse(sourceFormat, model, text) {
|
||||||
const openaiResponse = createOpenAIResponse(model);
|
const openaiResponse = createOpenAIResponse(model, text);
|
||||||
const state = initState(sourceFormat);
|
const state = initState(sourceFormat);
|
||||||
state.model = model;
|
state.model = model;
|
||||||
|
|
||||||
|
|||||||
@@ -106,22 +106,38 @@ export function buildCursorHeaders(accessToken, machineId = null, ghostMode = tr
|
|||||||
const clientKey = generateHashed64Hex(cleanToken);
|
const clientKey = generateHashed64Hex(cleanToken);
|
||||||
const checksum = generateCursorChecksum(effectiveMachineId);
|
const checksum = generateCursorChecksum(effectiveMachineId);
|
||||||
|
|
||||||
|
// Detect OS
|
||||||
|
let os = "linux";
|
||||||
|
if (typeof process !== "undefined") {
|
||||||
|
if (process.platform === "win32") os = "windows";
|
||||||
|
else if (process.platform === "darwin") os = "macos";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect architecture
|
||||||
|
let arch = "x64";
|
||||||
|
if (typeof process !== "undefined") {
|
||||||
|
if (process.arch === "arm64") arch = "aarch64";
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"Authorization": `Bearer ${cleanToken}`,
|
"authorization": `Bearer ${cleanToken}`,
|
||||||
"connect-accept-encoding": "gzip",
|
"connect-accept-encoding": "gzip",
|
||||||
"connect-protocol-version": "1",
|
"connect-protocol-version": "1",
|
||||||
"Content-Type": "application/connect+proto",
|
"content-type": "application/connect+proto",
|
||||||
"User-Agent": "connect-es/1.6.1",
|
"user-agent": "connect-es/1.6.1",
|
||||||
"x-amzn-trace-id": `Root=${crypto.randomUUID()}`,
|
"x-amzn-trace-id": `Root=${crypto.randomUUID()}`,
|
||||||
"x-client-key": clientKey,
|
"x-client-key": clientKey,
|
||||||
"x-cursor-checksum": checksum,
|
"x-cursor-checksum": checksum,
|
||||||
"x-cursor-client-version": "1.1.3",
|
"x-cursor-client-version": "2.3.41",
|
||||||
|
"x-cursor-client-type": "ide",
|
||||||
|
"x-cursor-client-os": os,
|
||||||
|
"x-cursor-client-arch": arch,
|
||||||
|
"x-cursor-client-device-type": "desktop",
|
||||||
"x-cursor-config-version": crypto.randomUUID(),
|
"x-cursor-config-version": crypto.randomUUID(),
|
||||||
"x-cursor-timezone": Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
"x-cursor-timezone": Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
||||||
"x-ghost-mode": ghostMode ? "true" : "false",
|
"x-ghost-mode": ghostMode ? "true" : "false",
|
||||||
"x-request-id": crypto.randomUUID(),
|
"x-request-id": crypto.randomUUID(),
|
||||||
"x-session-id": sessionId,
|
"x-session-id": sessionId
|
||||||
"Host": "api2.cursor.sh"
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,11 @@
|
|||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import zlib from "zlib";
|
import zlib from "zlib";
|
||||||
|
|
||||||
const DEBUG = true;
|
const DEBUG = process.env.CURSOR_PROTOBUF_DEBUG === "1";
|
||||||
const log = (tag, ...args) => DEBUG && console.log(`[PROTOBUF:${tag}]`, ...args);
|
const log = (tag, ...args) => DEBUG && console.log(`[PROTOBUF:${tag}]`, ...args);
|
||||||
|
const textDecoder = new TextDecoder();
|
||||||
|
|
||||||
|
const PROTOBUF_SCHEMA_VERSION = "1.1.3";
|
||||||
|
|
||||||
// ==================== SCHEMAS ====================
|
// ==================== SCHEMAS ====================
|
||||||
|
|
||||||
@@ -18,6 +21,8 @@ const ROLE = { USER: 1, ASSISTANT: 2 };
|
|||||||
const UNIFIED_MODE = { CHAT: 1, AGENT: 2 };
|
const UNIFIED_MODE = { CHAT: 1, AGENT: 2 };
|
||||||
|
|
||||||
const THINKING_LEVEL = { UNSPECIFIED: 0, MEDIUM: 1, HIGH: 2 };
|
const THINKING_LEVEL = { UNSPECIFIED: 0, MEDIUM: 1, HIGH: 2 };
|
||||||
|
const CLIENT_SIDE_TOOL_V2 = { MCP: 19 };
|
||||||
|
const CLIENT_SIDE_TOOL_V2_MCP = 19;
|
||||||
|
|
||||||
const FIELD = {
|
const FIELD = {
|
||||||
// StreamUnifiedChatRequestWithTools (top level)
|
// StreamUnifiedChatRequestWithTools (top level)
|
||||||
@@ -55,6 +60,7 @@ const FIELD = {
|
|||||||
MSG_ID: 13,
|
MSG_ID: 13,
|
||||||
MSG_TOOL_RESULTS: 18,
|
MSG_TOOL_RESULTS: 18,
|
||||||
MSG_IS_AGENTIC: 29,
|
MSG_IS_AGENTIC: 29,
|
||||||
|
MSG_SERVER_BUBBLE_ID: 32,
|
||||||
MSG_UNIFIED_MODE: 47,
|
MSG_UNIFIED_MODE: 47,
|
||||||
MSG_SUPPORTED_TOOLS: 51,
|
MSG_SUPPORTED_TOOLS: 51,
|
||||||
|
|
||||||
@@ -67,18 +73,35 @@ const FIELD = {
|
|||||||
TOOL_RESULT_TOOL_CALL: 11,
|
TOOL_RESULT_TOOL_CALL: 11,
|
||||||
TOOL_RESULT_MODEL_CALL_ID: 12,
|
TOOL_RESULT_MODEL_CALL_ID: 12,
|
||||||
|
|
||||||
// ClientSideToolV2Result
|
// ClientSideToolV2Result (nested inside ToolResult.result)
|
||||||
|
CLIENT_RESULT_TOOL: 1,
|
||||||
|
CLIENT_RESULT_MCP_RESULT: 28,
|
||||||
|
CLIENT_RESULT_TOOL_CALL_ID: 35,
|
||||||
|
CLIENT_RESULT_MODEL_CALL_ID: 48,
|
||||||
|
CLIENT_RESULT_TOOL_INDEX: 49,
|
||||||
|
// Aliases used by encodeClientSideToolV2Result
|
||||||
CV2R_TOOL: 1,
|
CV2R_TOOL: 1,
|
||||||
CV2R_MCP_RESULT: 28,
|
CV2R_MCP_RESULT: 28,
|
||||||
CV2R_CALL_ID: 35,
|
CV2R_CALL_ID: 35,
|
||||||
CV2R_MODEL_CALL_ID: 48,
|
CV2R_MODEL_CALL_ID: 48,
|
||||||
CV2R_TOOL_INDEX: 49,
|
CV2R_TOOL_INDEX: 49,
|
||||||
|
|
||||||
// MCPResult
|
// MCPResult (nested inside ClientSideToolV2Result.mcp_result)
|
||||||
|
MCP_RESULT_SELECTED_TOOL: 1,
|
||||||
|
MCP_RESULT_RESULT: 2,
|
||||||
|
// Aliases used by encodeMcpResult
|
||||||
MCPR_SELECTED_TOOL: 1,
|
MCPR_SELECTED_TOOL: 1,
|
||||||
MCPR_RESULT: 2,
|
MCPR_RESULT: 2,
|
||||||
|
|
||||||
// ClientSideToolV2Call
|
// ClientSideToolV2Call (nested inside ToolResult.tool_call)
|
||||||
|
CLIENT_CALL_TOOL: 1,
|
||||||
|
CLIENT_CALL_MCP_PARAMS: 27,
|
||||||
|
CLIENT_CALL_TOOL_CALL_ID: 3,
|
||||||
|
CLIENT_CALL_NAME: 9,
|
||||||
|
CLIENT_CALL_RAW_ARGS: 10,
|
||||||
|
CLIENT_CALL_TOOL_INDEX: 48,
|
||||||
|
CLIENT_CALL_MODEL_CALL_ID: 49,
|
||||||
|
// Aliases used by encodeClientSideToolV2Call
|
||||||
CV2C_TOOL: 1,
|
CV2C_TOOL: 1,
|
||||||
CV2C_MCP_PARAMS: 27,
|
CV2C_MCP_PARAMS: 27,
|
||||||
CV2C_CALL_ID: 3,
|
CV2C_CALL_ID: 3,
|
||||||
@@ -87,9 +110,6 @@ const FIELD = {
|
|||||||
CV2C_TOOL_INDEX: 48,
|
CV2C_TOOL_INDEX: 48,
|
||||||
CV2C_MODEL_CALL_ID: 49,
|
CV2C_MODEL_CALL_ID: 49,
|
||||||
|
|
||||||
// ConversationMessage extra fields
|
|
||||||
MSG_SERVER_BUBBLE_ID: 32,
|
|
||||||
|
|
||||||
// Model
|
// Model
|
||||||
MODEL_NAME: 1,
|
MODEL_NAME: 1,
|
||||||
MODEL_EMPTY: 4,
|
MODEL_EMPTY: 4,
|
||||||
@@ -135,6 +155,7 @@ const FIELD = {
|
|||||||
TOOL_NAME: 9,
|
TOOL_NAME: 9,
|
||||||
TOOL_RAW_ARGS: 10,
|
TOOL_RAW_ARGS: 10,
|
||||||
TOOL_IS_LAST: 11,
|
TOOL_IS_LAST: 11,
|
||||||
|
TOOL_IS_LAST_ALT: 15,
|
||||||
TOOL_MCP_PARAMS: 27,
|
TOOL_MCP_PARAMS: 27,
|
||||||
|
|
||||||
// MCPParams
|
// MCPParams
|
||||||
@@ -152,6 +173,19 @@ const FIELD = {
|
|||||||
THINKING_TEXT: 1
|
THINKING_TEXT: 1
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Known response field numbers — used to detect unknown fields from protocol updates
|
||||||
|
const KNOWN_RESPONSE_FIELDS = new Set([
|
||||||
|
FIELD.TOOL_CALL,
|
||||||
|
FIELD.RESPONSE,
|
||||||
|
FIELD.TOOL_ID,
|
||||||
|
FIELD.TOOL_NAME,
|
||||||
|
FIELD.TOOL_RAW_ARGS,
|
||||||
|
FIELD.TOOL_IS_LAST,
|
||||||
|
FIELD.TOOL_MCP_PARAMS,
|
||||||
|
FIELD.RESPONSE_TEXT,
|
||||||
|
FIELD.THINKING
|
||||||
|
]);
|
||||||
|
|
||||||
// ==================== PRIMITIVE ENCODING ====================
|
// ==================== PRIMITIVE ENCODING ====================
|
||||||
|
|
||||||
export function encodeVarint(value) {
|
export function encodeVarint(value) {
|
||||||
@@ -200,15 +234,46 @@ function concatArrays(...arrays) {
|
|||||||
|
|
||||||
// ==================== MESSAGE ENCODING ====================
|
// ==================== MESSAGE ENCODING ====================
|
||||||
|
|
||||||
// ClientSideToolV2 enum: MCP = 19
|
|
||||||
const CLIENT_SIDE_TOOL_V2_MCP = 19;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format tool name: "toolName" → "mcp_custom_toolName"
|
* Format tool name: "toolName" → "mcp_custom_toolName"
|
||||||
|
* Also handles: "mcp__server__tool" → "mcp_server_tool"
|
||||||
*/
|
*/
|
||||||
function formatToolName(name) {
|
function formatToolName(name) {
|
||||||
if (name.startsWith("mcp_")) return name;
|
const base = typeof name === "string" && name.length > 0 ? name : "tool";
|
||||||
return `mcp_custom_${name}`;
|
|
||||||
|
if (base.startsWith("mcp__")) {
|
||||||
|
const rest = base.slice("mcp__".length);
|
||||||
|
const splitIdx = rest.indexOf("__");
|
||||||
|
if (splitIdx >= 0) {
|
||||||
|
const server = rest.slice(0, splitIdx) || "custom";
|
||||||
|
const toolName = rest.slice(splitIdx + 2) || "tool";
|
||||||
|
return `mcp_${server}_${toolName}`;
|
||||||
|
}
|
||||||
|
return `mcp_custom_${rest || "tool"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (base.startsWith("mcp_")) return base;
|
||||||
|
return `mcp_custom_${base}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse formatted tool name: "mcp_server_tool" → { serverName, selectedTool }
|
||||||
|
*/
|
||||||
|
function parseToolName(formattedName) {
|
||||||
|
if (typeof formattedName !== "string" || !formattedName.startsWith("mcp_")) {
|
||||||
|
return { serverName: "custom", selectedTool: formattedName || "tool" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tail = formattedName.slice("mcp_".length);
|
||||||
|
const splitIdx = tail.indexOf("_");
|
||||||
|
if (splitIdx < 0) {
|
||||||
|
return { serverName: "custom", selectedTool: tail || "tool" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
serverName: tail.slice(0, splitIdx) || "custom",
|
||||||
|
selectedTool: tail.slice(splitIdx + 1) || "tool"
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -235,15 +300,16 @@ function encodeMcpResult(selectedTool, resultContent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encode ClientSideToolV2Result proto
|
* Encode ClientSideToolV2Result proto: { tool, mcp_result, call_id, model_call_id, tool_index }
|
||||||
|
* Represents the result of executing a tool
|
||||||
*/
|
*/
|
||||||
function encodeClientSideToolV2Result(toolCallId, modelCallId, selectedTool, resultContent) {
|
function encodeClientSideToolV2Result(toolCallId, modelCallId, selectedTool, resultContent, toolIndex = 1) {
|
||||||
return concatArrays(
|
return concatArrays(
|
||||||
encodeField(FIELD.CV2R_TOOL, WIRE_TYPE.VARINT, CLIENT_SIDE_TOOL_V2_MCP),
|
encodeField(FIELD.CV2R_TOOL, WIRE_TYPE.VARINT, CLIENT_SIDE_TOOL_V2_MCP),
|
||||||
encodeField(FIELD.CV2R_MCP_RESULT, WIRE_TYPE.LEN, encodeMcpResult(selectedTool, resultContent)),
|
encodeField(FIELD.CV2R_MCP_RESULT, WIRE_TYPE.LEN, encodeMcpResult(selectedTool, resultContent)),
|
||||||
encodeField(FIELD.CV2R_CALL_ID, WIRE_TYPE.LEN, toolCallId),
|
encodeField(FIELD.CV2R_CALL_ID, WIRE_TYPE.LEN, toolCallId),
|
||||||
...(modelCallId ? [encodeField(FIELD.CV2R_MODEL_CALL_ID, WIRE_TYPE.LEN, modelCallId)] : []),
|
...(modelCallId ? [encodeField(FIELD.CV2R_MODEL_CALL_ID, WIRE_TYPE.LEN, modelCallId)] : []),
|
||||||
encodeField(FIELD.CV2R_TOOL_INDEX, WIRE_TYPE.VARINT, 1)
|
encodeField(FIELD.CV2R_TOOL_INDEX, WIRE_TYPE.VARINT, toolIndex > 0 ? toolIndex : 1)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,16 +326,17 @@ function encodeMcpParamsForCall(toolName, rawArgs, serverName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encode ClientSideToolV2Call proto
|
* Encode ClientSideToolV2Call proto: { tool, mcp_params, call_id, name, raw_args, tool_index, model_call_id }
|
||||||
|
* Represents a tool call definition
|
||||||
*/
|
*/
|
||||||
function encodeClientSideToolV2Call(toolCallId, toolName, mcpToolName, rawArgs, modelCallId) {
|
function encodeClientSideToolV2Call(toolCallId, toolName, selectedTool, serverName, rawArgs, modelCallId, toolIndex = 1) {
|
||||||
return concatArrays(
|
return concatArrays(
|
||||||
encodeField(FIELD.CV2C_TOOL, WIRE_TYPE.VARINT, CLIENT_SIDE_TOOL_V2_MCP),
|
encodeField(FIELD.CV2C_TOOL, WIRE_TYPE.VARINT, CLIENT_SIDE_TOOL_V2_MCP),
|
||||||
encodeField(FIELD.CV2C_MCP_PARAMS, WIRE_TYPE.LEN, encodeMcpParamsForCall(mcpToolName, rawArgs, "custom")),
|
encodeField(FIELD.CV2C_MCP_PARAMS, WIRE_TYPE.LEN, encodeMcpParamsForCall(selectedTool, rawArgs, serverName)),
|
||||||
encodeField(FIELD.CV2C_CALL_ID, WIRE_TYPE.LEN, toolCallId),
|
encodeField(FIELD.CV2C_CALL_ID, WIRE_TYPE.LEN, toolCallId),
|
||||||
encodeField(FIELD.CV2C_NAME, WIRE_TYPE.LEN, toolName),
|
encodeField(FIELD.CV2C_NAME, WIRE_TYPE.LEN, toolName),
|
||||||
encodeField(FIELD.CV2C_RAW_ARGS, WIRE_TYPE.LEN, rawArgs),
|
encodeField(FIELD.CV2C_RAW_ARGS, WIRE_TYPE.LEN, rawArgs),
|
||||||
encodeField(FIELD.CV2C_TOOL_INDEX, WIRE_TYPE.VARINT, 1),
|
encodeField(FIELD.CV2C_TOOL_INDEX, WIRE_TYPE.VARINT, toolIndex > 0 ? toolIndex : 1),
|
||||||
...(modelCallId ? [encodeField(FIELD.CV2C_MODEL_CALL_ID, WIRE_TYPE.LEN, modelCallId)] : [])
|
...(modelCallId ? [encodeField(FIELD.CV2C_MODEL_CALL_ID, WIRE_TYPE.LEN, modelCallId)] : [])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -282,23 +349,24 @@ export function encodeToolResult(toolResult) {
|
|||||||
const originalName = toolResult.tool_name || toolResult.name || "";
|
const originalName = toolResult.tool_name || toolResult.name || "";
|
||||||
const toolName = formatToolName(originalName);
|
const toolName = formatToolName(originalName);
|
||||||
const rawArgs = toolResult.raw_args || "{}";
|
const rawArgs = toolResult.raw_args || "{}";
|
||||||
const resultContent = toolResult.result_content || "";
|
const resultContent = toolResult.result_content || toolResult.result || "";
|
||||||
const { toolCallId, modelCallId } = parseToolId(toolResult.tool_call_id || "");
|
const { toolCallId, modelCallId } = parseToolId(toolResult.tool_call_id || "");
|
||||||
|
const toolIndex = toolResult.tool_index || toolResult.index || 1;
|
||||||
|
|
||||||
// Derive mcpToolName: strip "mcp_" prefix → "custom_toolName"
|
// Parse tool name to extract server and selected tool
|
||||||
const mcpToolName = toolName.startsWith("mcp_") ? toolName.slice(4) : originalName;
|
const { serverName, selectedTool } = parseToolName(toolName);
|
||||||
|
|
||||||
return concatArrays(
|
return concatArrays(
|
||||||
encodeField(FIELD.TOOL_RESULT_CALL_ID, WIRE_TYPE.LEN, toolCallId),
|
encodeField(FIELD.TOOL_RESULT_CALL_ID, WIRE_TYPE.LEN, toolCallId),
|
||||||
encodeField(FIELD.TOOL_RESULT_NAME, WIRE_TYPE.LEN, toolName),
|
encodeField(FIELD.TOOL_RESULT_NAME, WIRE_TYPE.LEN, toolName),
|
||||||
encodeField(FIELD.TOOL_RESULT_INDEX, WIRE_TYPE.VARINT, toolResult.tool_index || 1),
|
encodeField(FIELD.TOOL_RESULT_INDEX, WIRE_TYPE.VARINT, toolIndex > 0 ? toolIndex : 1),
|
||||||
...(modelCallId ? [encodeField(FIELD.TOOL_RESULT_MODEL_CALL_ID, WIRE_TYPE.LEN, modelCallId)] : []),
|
...(modelCallId ? [encodeField(FIELD.TOOL_RESULT_MODEL_CALL_ID, WIRE_TYPE.LEN, modelCallId)] : []),
|
||||||
encodeField(FIELD.TOOL_RESULT_RAW_ARGS, WIRE_TYPE.LEN, rawArgs),
|
encodeField(FIELD.TOOL_RESULT_RAW_ARGS, WIRE_TYPE.LEN, rawArgs),
|
||||||
encodeField(FIELD.TOOL_RESULT_RESULT, WIRE_TYPE.LEN,
|
encodeField(FIELD.TOOL_RESULT_RESULT, WIRE_TYPE.LEN,
|
||||||
encodeClientSideToolV2Result(toolCallId, modelCallId, mcpToolName, resultContent)
|
encodeClientSideToolV2Result(toolCallId, modelCallId, selectedTool, resultContent, toolIndex)
|
||||||
),
|
),
|
||||||
encodeField(FIELD.TOOL_RESULT_TOOL_CALL, WIRE_TYPE.LEN,
|
encodeField(FIELD.TOOL_RESULT_TOOL_CALL, WIRE_TYPE.LEN,
|
||||||
encodeClientSideToolV2Call(toolCallId, toolName, mcpToolName, rawArgs, modelCallId)
|
encodeClientSideToolV2Call(toolCallId, toolName, selectedTool, serverName, rawArgs, modelCallId, toolIndex)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -384,13 +452,71 @@ export function encodeRequest(messages, modelName, tools = [], reasoningEffort =
|
|||||||
const isAgentic = hasTools;
|
const isAgentic = hasTools;
|
||||||
const formattedMessages = [];
|
const formattedMessages = [];
|
||||||
const messageIds = [];
|
const messageIds = [];
|
||||||
|
const normalizedMessages = [];
|
||||||
|
|
||||||
// Prepare messages
|
// Guardrail: split mixed assistant payload into separate assistant messages
|
||||||
|
// This prevents protobuf encoding errors when tool calls and results are in same message
|
||||||
for (let i = 0; i < messages.length; i++) {
|
for (let i = 0; i < messages.length; i++) {
|
||||||
const msg = messages[i];
|
const msg = messages[i];
|
||||||
|
const hasToolCalls = Array.isArray(msg?.tool_calls) && msg.tool_calls.length > 0;
|
||||||
|
const hasToolResults = Array.isArray(msg?.tool_results) && msg.tool_results.length > 0;
|
||||||
|
|
||||||
|
if (msg?.role === "assistant" && hasToolCalls && hasToolResults) {
|
||||||
|
log(
|
||||||
|
"ENCODE",
|
||||||
|
`normalizing mixed assistant tool payload at msg[${i}] (calls=${msg.tool_calls.length}, results=${msg.tool_results.length})`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keep assistant tool call message without embedded results
|
||||||
|
normalizedMessages.push({
|
||||||
|
...msg,
|
||||||
|
tool_results: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Avoid inserting duplicate assistant tool-result message if next one already matches
|
||||||
|
const nextMsg = messages[i + 1];
|
||||||
|
const nextHasToolResults =
|
||||||
|
nextMsg?.role === "assistant" &&
|
||||||
|
Array.isArray(nextMsg?.tool_results) &&
|
||||||
|
nextMsg.tool_results.length > 0;
|
||||||
|
const currentIds = new Set(
|
||||||
|
msg.tool_results.map(tr => tr?.tool_call_id).filter(id => typeof id === "string")
|
||||||
|
);
|
||||||
|
const nextIds = new Set(
|
||||||
|
(nextMsg?.tool_results || [])
|
||||||
|
.map(tr => tr?.tool_call_id)
|
||||||
|
.filter(id => typeof id === "string")
|
||||||
|
);
|
||||||
|
let sameIds = currentIds.size > 0 && currentIds.size === nextIds.size;
|
||||||
|
if (sameIds) {
|
||||||
|
for (const id of currentIds) {
|
||||||
|
if (!nextIds.has(id)) {
|
||||||
|
sameIds = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(nextHasToolResults && sameIds)) {
|
||||||
|
normalizedMessages.push({
|
||||||
|
role: "assistant",
|
||||||
|
content: "",
|
||||||
|
tool_results: msg.tool_results
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedMessages.push(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare messages
|
||||||
|
for (let i = 0; i < normalizedMessages.length; i++) {
|
||||||
|
const msg = normalizedMessages[i];
|
||||||
const role = msg.role === "user" ? ROLE.USER : ROLE.ASSISTANT;
|
const role = msg.role === "user" ? ROLE.USER : ROLE.ASSISTANT;
|
||||||
const msgId = uuidv4();
|
const msgId = uuidv4();
|
||||||
const isLast = i === messages.length - 1;
|
const isLast = i === normalizedMessages.length - 1;
|
||||||
|
|
||||||
formattedMessages.push({
|
formattedMessages.push({
|
||||||
content: msg.content,
|
content: msg.content,
|
||||||
@@ -719,6 +845,16 @@ export function extractTextFromResponse(payload) {
|
|||||||
try {
|
try {
|
||||||
const fields = decodeMessage(payload);
|
const fields = decodeMessage(payload);
|
||||||
|
|
||||||
|
// Warn about unknown field numbers — may indicate a Cursor protocol update
|
||||||
|
for (const fieldNum of fields.keys()) {
|
||||||
|
if (!KNOWN_RESPONSE_FIELDS.has(fieldNum)) {
|
||||||
|
log(
|
||||||
|
"SCHEMA",
|
||||||
|
`Unknown response field #${fieldNum} detected. Schema v${PROTOBUF_SCHEMA_VERSION} may be outdated.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Field 1: ClientSideToolV2Call
|
// Field 1: ClientSideToolV2Call
|
||||||
if (fields.has(FIELD.TOOL_CALL)) {
|
if (fields.has(FIELD.TOOL_CALL)) {
|
||||||
const toolCall = extractToolCall(fields.get(FIELD.TOOL_CALL)[0].value);
|
const toolCall = extractToolCall(fields.get(FIELD.TOOL_CALL)[0].value);
|
||||||
@@ -739,8 +875,15 @@ export function extractTextFromResponse(payload) {
|
|||||||
|
|
||||||
return { text: null, error: null, toolCall: null, thinking: null };
|
return { text: null, error: null, toolCall: null, thinking: null };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log("EXTRACT", `Error: ${err.message}`);
|
log("EXTRACT", `Decode failed (schema v${PROTOBUF_SCHEMA_VERSION}): ${err.message}`);
|
||||||
return { text: null, error: null, toolCall: null, thinking: null };
|
return {
|
||||||
|
text: null,
|
||||||
|
error: null,
|
||||||
|
toolCall: null,
|
||||||
|
thinking: null,
|
||||||
|
raw: Buffer.from(payload).toString("base64"),
|
||||||
|
decodeError: err.message
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ERROR_TYPES, DEFAULT_ERROR_MESSAGES } from "../config/constants.js";
|
import { ERROR_TYPES, DEFAULT_ERROR_MESSAGES } from "../config/runtimeConfig.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build OpenAI-compatible error response body
|
* Build OpenAI-compatible error response body
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function transformToOllama(response, model) {
|
|||||||
const formattedCalls = toolCallsArr.map(tc => ({
|
const formattedCalls = toolCallsArr.map(tc => ({
|
||||||
function: {
|
function: {
|
||||||
name: tc.function.name,
|
name: tc.function.name,
|
||||||
arguments: JSON.parse(tc.function.arguments || "{}")
|
arguments: (() => { try { return JSON.parse(tc.function.arguments || "{}"); } catch { return {}; } })()
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
const ollama = JSON.stringify({
|
const ollama = JSON.stringify({
|
||||||
@@ -75,6 +75,9 @@ export function transformToOllama(response, model) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!response.body) {
|
||||||
|
return new Response("", { status: response.status, headers: { "Content-Type": "application/x-ndjson" } });
|
||||||
|
}
|
||||||
return new Response(response.body.pipeThrough(transform), {
|
return new Response(response.body.pipeThrough(transform), {
|
||||||
headers: { "Content-Type": "application/x-ndjson", "Access-Control-Allow-Origin": "*" }
|
headers: { "Content-Type": "application/x-ndjson", "Access-Control-Allow-Origin": "*" }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
|
import { Readable } from "stream";
|
||||||
|
import { MEMORY_CONFIG } from "../config/runtimeConfig.js";
|
||||||
|
|
||||||
const isCloud = typeof caches !== "undefined" && typeof caches === "object";
|
const isCloud = typeof caches !== "undefined" && typeof caches === "object";
|
||||||
|
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
const proxyDispatchers = new Map();
|
const proxyDispatchers = new Map();
|
||||||
|
|
||||||
// Constants
|
// DNS cache — use Map to avoid prototype pollution via malformed hostnames
|
||||||
const DNS_CACHE = {};
|
const DNS_CACHE = new Map();
|
||||||
const MITM_BYPASS_HOSTS = ["cloudcode-pa.googleapis.com", "daily-cloudcode-pa.googleapis.com", "googleapis.com"];
|
const MITM_BYPASS_HOSTS = ["cloudcode-pa.googleapis.com", "daily-cloudcode-pa.googleapis.com", "googleapis.com"];
|
||||||
const MITM_BYPASS_HEADER = "x-request-source";
|
const MITM_BYPASS_HEADER = "x-request-source";
|
||||||
const MITM_BYPASS_VALUE = "local";
|
const MITM_BYPASS_VALUE = "local";
|
||||||
@@ -22,7 +25,8 @@ function normalizeString(value) {
|
|||||||
* Resolve real IP using Google DNS (bypass system DNS)
|
* Resolve real IP using Google DNS (bypass system DNS)
|
||||||
*/
|
*/
|
||||||
async function resolveRealIP(hostname) {
|
async function resolveRealIP(hostname) {
|
||||||
if (DNS_CACHE[hostname]) return DNS_CACHE[hostname];
|
const cached = DNS_CACHE.get(hostname);
|
||||||
|
if (cached && Date.now() < cached.expiry) return cached.ip;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const dns = await import("dns");
|
const dns = await import("dns");
|
||||||
@@ -31,7 +35,7 @@ async function resolveRealIP(hostname) {
|
|||||||
resolver.setServers(GOOGLE_DNS_SERVERS);
|
resolver.setServers(GOOGLE_DNS_SERVERS);
|
||||||
const resolve4 = promisify(resolver.resolve4.bind(resolver));
|
const resolve4 = promisify(resolver.resolve4.bind(resolver));
|
||||||
const addresses = await resolve4(hostname);
|
const addresses = await resolve4(hostname);
|
||||||
DNS_CACHE[hostname] = addresses[0];
|
DNS_CACHE.set(hostname, { ip: addresses[0], expiry: Date.now() + MEMORY_CONFIG.dnsCacheTtlMs });
|
||||||
return addresses[0];
|
return addresses[0];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`[ProxyFetch] DNS resolve failed for ${hostname}:`, error.message);
|
console.warn(`[ProxyFetch] DNS resolve failed for ${hostname}:`, error.message);
|
||||||
@@ -50,23 +54,27 @@ function shouldBypassMitmDns(url, options) {
|
|||||||
headers[MITM_BYPASS_HEADER.charAt(0).toUpperCase() + MITM_BYPASS_HEADER.slice(1)] === MITM_BYPASS_VALUE;
|
headers[MITM_BYPASS_HEADER.charAt(0).toUpperCase() + MITM_BYPASS_HEADER.slice(1)] === MITM_BYPASS_VALUE;
|
||||||
|
|
||||||
if (!hasLocalMarker) {
|
if (!hasLocalMarker) {
|
||||||
// Debug: log when bypass is not triggered
|
try {
|
||||||
const hostname = new URL(url).hostname;
|
const hostname = new URL(url).hostname;
|
||||||
if (MITM_BYPASS_HOSTS.some(host => hostname.includes(host))) {
|
if (MITM_BYPASS_HOSTS.some(host => hostname.includes(host))) {
|
||||||
console.warn(`[ProxyFetch] MITM bypass NOT triggered for ${hostname} - missing header`);
|
console.warn(`[ProxyFetch] MITM bypass NOT triggered for ${hostname} - missing header`);
|
||||||
}
|
}
|
||||||
|
} catch { /* invalid URL — skip debug log */ }
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const hostname = new URL(url).hostname;
|
const hostname = new URL(url).hostname;
|
||||||
return MITM_BYPASS_HOSTS.some(host => hostname.includes(host));
|
return MITM_BYPASS_HOSTS.some(host => hostname.includes(host));
|
||||||
|
} catch { return false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldBypassByNoProxy(targetUrl, noProxyValue) {
|
function shouldBypassByNoProxy(targetUrl, noProxyValue) {
|
||||||
const noProxy = normalizeString(noProxyValue);
|
const noProxy = normalizeString(noProxyValue);
|
||||||
if (!noProxy) return false;
|
if (!noProxy) return false;
|
||||||
|
|
||||||
const hostname = new URL(targetUrl).hostname.toLowerCase();
|
let hostname;
|
||||||
|
try { hostname = new URL(targetUrl).hostname.toLowerCase(); } catch { return false; }
|
||||||
const patterns = noProxy.split(",").map((p) => p.trim().toLowerCase()).filter(Boolean);
|
const patterns = noProxy.split(",").map((p) => p.trim().toLowerCase()).filter(Boolean);
|
||||||
|
|
||||||
return patterns.some((pattern) => {
|
return patterns.some((pattern) => {
|
||||||
@@ -83,7 +91,8 @@ function getEnvProxyUrl(targetUrl) {
|
|||||||
const noProxy = process.env.NO_PROXY || process.env.no_proxy;
|
const noProxy = process.env.NO_PROXY || process.env.no_proxy;
|
||||||
if (shouldBypassByNoProxy(targetUrl, noProxy)) return null;
|
if (shouldBypassByNoProxy(targetUrl, noProxy)) return null;
|
||||||
|
|
||||||
const protocol = new URL(targetUrl).protocol;
|
let protocol;
|
||||||
|
try { protocol = new URL(targetUrl).protocol; } catch { return null; }
|
||||||
|
|
||||||
if (protocol === "https:") {
|
if (protocol === "https:") {
|
||||||
return process.env.HTTPS_PROXY || process.env.https_proxy ||
|
return process.env.HTTPS_PROXY || process.env.https_proxy ||
|
||||||
@@ -132,6 +141,10 @@ async function getDispatcher(proxyUrl) {
|
|||||||
if (!normalized) return null;
|
if (!normalized) return null;
|
||||||
|
|
||||||
if (!proxyDispatchers.has(normalized)) {
|
if (!proxyDispatchers.has(normalized)) {
|
||||||
|
// Evict oldest entry if max size reached
|
||||||
|
if (proxyDispatchers.size >= MEMORY_CONFIG.proxyDispatchersMaxSize) {
|
||||||
|
proxyDispatchers.delete(proxyDispatchers.keys().next().value);
|
||||||
|
}
|
||||||
const { ProxyAgent } = await import("undici");
|
const { ProxyAgent } = await import("undici");
|
||||||
proxyDispatchers.set(normalized, new ProxyAgent({ uri: normalized }));
|
proxyDispatchers.set(normalized, new ProxyAgent({ uri: normalized }));
|
||||||
}
|
}
|
||||||
@@ -145,7 +158,7 @@ async function getDispatcher(proxyUrl) {
|
|||||||
async function createBypassRequest(parsedUrl, realIP, options) {
|
async function createBypassRequest(parsedUrl, realIP, options) {
|
||||||
const https = await import("https");
|
const https = await import("https");
|
||||||
const net = await import("net");
|
const net = await import("net");
|
||||||
const { Readable } = require("stream");
|
const { Readable } = await import("stream");
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const socket = new net.Socket();
|
const socket = new net.Socket();
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ async function createLogSession(sourceFormat, targetFormat, model) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const timestamp = formatTimestamp();
|
const timestamp = formatTimestamp();
|
||||||
const safeModel = model.replace(/[/:]/g, "-");
|
const safeModel = (model || "unknown").replace(/[/:]/g, "-");
|
||||||
const folderName = `${sourceFormat}_${targetFormat}_${safeModel}_${timestamp}`;
|
const folderName = `${sourceFormat}_${targetFormat}_${safeModel}_${timestamp}`;
|
||||||
const sessionPath = path.join(LOGS_DIR, folderName);
|
const sessionPath = path.join(LOGS_DIR, folderName);
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,24 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
|
import { MEMORY_CONFIG } from "../config/runtimeConfig.js";
|
||||||
|
|
||||||
// Runtime storage for session IDs (per connection/account)
|
// Runtime storage: Key = connectionId, Value = { sessionId, lastUsed }
|
||||||
// Key: connectionId (email or identifier), Value: sessionId
|
|
||||||
const runtimeSessionStore = new Map();
|
const runtimeSessionStore = new Map();
|
||||||
|
|
||||||
|
// Periodically evict entries that haven't been used within TTL
|
||||||
|
const cleanupInterval = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, entry] of runtimeSessionStore) {
|
||||||
|
if (now - entry.lastUsed > MEMORY_CONFIG.sessionTtlMs) {
|
||||||
|
runtimeSessionStore.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, MEMORY_CONFIG.sessionCleanupIntervalMs);
|
||||||
|
|
||||||
|
// Allow Node.js to exit even if interval is still active
|
||||||
|
if (cleanupInterval.unref) cleanupInterval.unref();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get or create a session ID for the given connection.
|
* Get or create a session ID for the given connection.
|
||||||
*
|
*
|
||||||
@@ -30,22 +43,25 @@ const runtimeSessionStore = new Map();
|
|||||||
*/
|
*/
|
||||||
export function deriveSessionId(connectionId) {
|
export function deriveSessionId(connectionId) {
|
||||||
if (!connectionId) {
|
if (!connectionId) {
|
||||||
// Fallback for requests without a connection identifier
|
|
||||||
return generateBinaryStyleId();
|
return generateBinaryStyleId();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we already have a session ID for this connection in this process run
|
const existing = runtimeSessionStore.get(connectionId);
|
||||||
if (runtimeSessionStore.has(connectionId)) {
|
if (existing) {
|
||||||
return runtimeSessionStore.get(connectionId);
|
existing.lastUsed = Date.now();
|
||||||
|
return existing.sessionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a new ID using the binary's exact logic
|
// Evict oldest entry if store exceeds max size (safety cap between cleanup cycles)
|
||||||
const newSessionId = generateBinaryStyleId();
|
const MAX_SESSIONS = 1000;
|
||||||
|
if (runtimeSessionStore.size >= MAX_SESSIONS) {
|
||||||
|
const oldest = runtimeSessionStore.keys().next().value;
|
||||||
|
runtimeSessionStore.delete(oldest);
|
||||||
|
}
|
||||||
|
|
||||||
// Store it for future requests from this connection
|
const sessionId = generateBinaryStyleId();
|
||||||
runtimeSessionStore.set(connectionId, newSessionId);
|
runtimeSessionStore.set(connectionId, { sessionId, lastUsed: Date.now() });
|
||||||
|
return sessionId;
|
||||||
return newSessionId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { parseSSELine, hasValuableContent, fixInvalidId, formatSSE } from "./str
|
|||||||
|
|
||||||
export { COLORS, formatSSE };
|
export { COLORS, formatSSE };
|
||||||
|
|
||||||
const sharedDecoder = new TextDecoder();
|
// sharedEncoder is stateless — safe to share across streams
|
||||||
const sharedEncoder = new TextEncoder();
|
const sharedEncoder = new TextEncoder();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,6 +49,9 @@ export function createSSEStream(options = {}) {
|
|||||||
let buffer = "";
|
let buffer = "";
|
||||||
let usage = null;
|
let usage = null;
|
||||||
|
|
||||||
|
// Per-stream decoder with stream:true to correctly handle multi-byte chars split across chunks
|
||||||
|
const decoder = new TextDecoder("utf-8", { fatal: false });
|
||||||
|
|
||||||
const state = mode === STREAM_MODE.TRANSLATE ? { ...initState(sourceFormat), provider, toolNameMap, model } : null;
|
const state = mode === STREAM_MODE.TRANSLATE ? { ...initState(sourceFormat), provider, toolNameMap, model } : null;
|
||||||
|
|
||||||
let totalContentLength = 0;
|
let totalContentLength = 0;
|
||||||
@@ -61,7 +64,7 @@ export function createSSEStream(options = {}) {
|
|||||||
if (!ttftAt) {
|
if (!ttftAt) {
|
||||||
ttftAt = Date.now();
|
ttftAt = Date.now();
|
||||||
}
|
}
|
||||||
const text = sharedDecoder.decode(chunk, { stream: true });
|
const text = decoder.decode(chunk, { stream: true });
|
||||||
buffer += text;
|
buffer += text;
|
||||||
reqLogger?.appendProviderChunk?.(text);
|
reqLogger?.appendProviderChunk?.(text);
|
||||||
|
|
||||||
@@ -159,10 +162,12 @@ export function createSSEStream(options = {}) {
|
|||||||
// Translate mode
|
// Translate mode
|
||||||
if (!trimmed) continue;
|
if (!trimmed) continue;
|
||||||
|
|
||||||
const parsed = parseSSELine(trimmed);
|
const parsed = parseSSELine(trimmed, targetFormat);
|
||||||
if (!parsed) continue;
|
if (!parsed) continue;
|
||||||
|
|
||||||
if (parsed && parsed.done) {
|
// For Ollama: done=true is the final chunk with finish_reason/usage, must translate
|
||||||
|
// For other formats: done=true is the [DONE] sentinel, skip
|
||||||
|
if (parsed && parsed.done && targetFormat !== FORMATS.OLLAMA) {
|
||||||
const output = "data: [DONE]\n\n";
|
const output = "data: [DONE]\n\n";
|
||||||
reqLogger?.appendConvertedChunk?.(output);
|
reqLogger?.appendConvertedChunk?.(output);
|
||||||
controller.enqueue(sharedEncoder.encode(output));
|
controller.enqueue(sharedEncoder.encode(output));
|
||||||
@@ -251,7 +256,7 @@ export function createSSEStream(options = {}) {
|
|||||||
flush(controller) {
|
flush(controller) {
|
||||||
trackPendingRequest(model, provider, connectionId, false);
|
trackPendingRequest(model, provider, connectionId, false);
|
||||||
try {
|
try {
|
||||||
const remaining = sharedDecoder.decode();
|
const remaining = decoder.decode();
|
||||||
if (remaining) buffer += remaining;
|
if (remaining) buffer += remaining;
|
||||||
|
|
||||||
if (mode === STREAM_MODE.PASSTHROUGH) {
|
if (mode === STREAM_MODE.PASSTHROUGH) {
|
||||||
|
|||||||
@@ -107,6 +107,9 @@ export function createDisconnectAwareStream(transformStream, streamController) {
|
|||||||
controller.enqueue(value);
|
controller.enqueue(value);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
streamController.handleError(error);
|
streamController.handleError(error);
|
||||||
|
// Cleanup reader/writer to avoid orphaned streams
|
||||||
|
reader.cancel().catch(() => {});
|
||||||
|
writer.abort().catch(() => {});
|
||||||
controller.error(error);
|
controller.error(error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -128,7 +131,7 @@ export function createDisconnectAwareStream(transformStream, streamController) {
|
|||||||
export function pipeWithDisconnect(providerResponse, transformStream, streamController) {
|
export function pipeWithDisconnect(providerResponse, transformStream, streamController) {
|
||||||
const transformedBody = providerResponse.body.pipeThrough(transformStream);
|
const transformedBody = providerResponse.body.pipeThrough(transformStream);
|
||||||
return createDisconnectAwareStream(
|
return createDisconnectAwareStream(
|
||||||
{ readable: transformedBody, writable: { getWriter: () => ({ abort: () => {} }) } },
|
{ readable: transformedBody, writable: { getWriter: () => ({ abort: () => Promise.resolve() }) } },
|
||||||
streamController
|
streamController
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,24 @@
|
|||||||
import { FORMATS } from "../translator/formats.js";
|
import { FORMATS } from "../translator/formats.js";
|
||||||
|
|
||||||
// Parse SSE data line
|
// Parse SSE data line
|
||||||
export function parseSSELine(line) {
|
export function parseSSELine(line, format = null) {
|
||||||
if (!line || line.charCodeAt(0) !== 100) return null; // 'd' = 100
|
if (!line) return null;
|
||||||
|
|
||||||
|
// NDJSON format (Ollama): raw JSON lines without "data:" prefix
|
||||||
|
if (format === FORMATS.OLLAMA) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed.startsWith("{")) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed);
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard SSE format: "data: {...}"
|
||||||
|
if (line.charCodeAt(0) !== 100) return null; // 'd' = 100
|
||||||
|
|
||||||
const data = line.slice(5).trim();
|
const data = line.slice(5).trim();
|
||||||
if (data === "[DONE]") return { done: true };
|
if (data === "[DONE]") return { done: true };
|
||||||
|
|||||||
+4
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "9router-app",
|
"name": "9router-app",
|
||||||
"version": "0.3.35",
|
"version": "0.3.51",
|
||||||
"description": "9Router web dashboard",
|
"description": "9Router web dashboard",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -15,6 +15,8 @@
|
|||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@xyflow/react": "^12.10.1",
|
"@xyflow/react": "^12.10.1",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
"better-sqlite3": "^12.6.2",
|
||||||
|
"confbox": "^0.2.4",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"fs": "^0.0.1-security",
|
"fs": "^0.0.1-security",
|
||||||
"http-proxy-middleware": "^3.0.5",
|
"http-proxy-middleware": "^3.0.5",
|
||||||
@@ -28,6 +30,7 @@
|
|||||||
"ora": "^9.1.0",
|
"ora": "^9.1.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"react-is": "^16.13.1",
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.7.0",
|
||||||
"selfsigned": "^5.5.0",
|
"selfsigned": "^5.5.0",
|
||||||
"socks-proxy-agent": "^8.0.5",
|
"socks-proxy-agent": "^8.0.5",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.8 KiB |
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
import { Card, Button, ModelSelectModal, ManualConfigModal, Tooltip } from "@/shared/components";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
|
||||||
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||||
@@ -31,6 +31,7 @@ export default function ClaudeToolCard({
|
|||||||
const [modelAliases, setModelAliases] = useState({});
|
const [modelAliases, setModelAliases] = useState({});
|
||||||
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
|
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
|
||||||
const [customBaseUrl, setCustomBaseUrl] = useState("");
|
const [customBaseUrl, setCustomBaseUrl] = useState("");
|
||||||
|
const [ccFilterNaming, setCcFilterNaming] = useState(false);
|
||||||
const hasInitializedModels = useRef(false);
|
const hasInitializedModels = useRef(false);
|
||||||
|
|
||||||
const getConfigStatus = () => {
|
const getConfigStatus = () => {
|
||||||
@@ -64,6 +65,22 @@ export default function ClaudeToolCard({
|
|||||||
if (isExpanded) fetchModelAliases();
|
if (isExpanded) fetchModelAliases();
|
||||||
}, [isExpanded]);
|
}, [isExpanded]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/settings").then(r => r.json()).then(data => {
|
||||||
|
setCcFilterNaming(!!data.ccFilterNaming);
|
||||||
|
}).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCcFilterNamingToggle = async (e) => {
|
||||||
|
const value = e.target.checked;
|
||||||
|
setCcFilterNaming(value);
|
||||||
|
await fetch("/api/settings", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ccFilterNaming: value }),
|
||||||
|
}).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
const fetchModelAliases = async () => {
|
const fetchModelAliases = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/models/alias");
|
const res = await fetch("/api/models/alias");
|
||||||
@@ -319,6 +336,19 @@ export default function ClaudeToolCard({
|
|||||||
{modelMappings[model.alias] && <button onClick={() => onModelMappingChange(model.alias, "")} className="p-1 text-text-muted hover:text-red-500 rounded transition-colors" title="Clear"><span className="material-symbols-outlined text-[14px]">close</span></button>}
|
{modelMappings[model.alias] && <button onClick={() => onModelMappingChange(model.alias, "")} className="p-1 text-text-muted hover:text-red-500 rounded transition-colors" title="Clear"><span className="material-symbols-outlined text-[14px]">close</span></button>}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* CC Filter Naming */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">Filter naming</span>
|
||||||
|
<span className="material-symbols-outlined text-text-muted text-[14px]">arrow_forward</span>
|
||||||
|
<label className="flex items-center gap-1.5 cursor-pointer select-none">
|
||||||
|
<input type="checkbox" checked={ccFilterNaming} onChange={handleCcFilterNamingToggle} className="w-3.5 h-3.5 accent-primary cursor-pointer" />
|
||||||
|
<span className="text-xs text-text-muted">Filter naming requests</span>
|
||||||
|
</label>
|
||||||
|
<Tooltip text="Intercepts Claude Code's topic-naming requests and returns a fake response locally, saving API tokens.">
|
||||||
|
<span className="material-symbols-outlined text-text-muted text-[14px] cursor-help">info</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{message && (
|
{message && (
|
||||||
|
|||||||
@@ -314,7 +314,7 @@ export default function ProfilePage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const observabilityEnabled = settings.observabilityEnabled !== false;
|
const observabilityEnabled = settings.observabilityEnabled === true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto">
|
<div className="max-w-2xl mx-auto">
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export default function ProviderDetailPage() {
|
|||||||
const [selectedConnectionIds, setSelectedConnectionIds] = useState([]);
|
const [selectedConnectionIds, setSelectedConnectionIds] = useState([]);
|
||||||
const [bulkProxyPoolId, setBulkProxyPoolId] = useState("__none__");
|
const [bulkProxyPoolId, setBulkProxyPoolId] = useState("__none__");
|
||||||
const [bulkUpdatingProxy, setBulkUpdatingProxy] = useState(false);
|
const [bulkUpdatingProxy, setBulkUpdatingProxy] = useState(false);
|
||||||
|
const [providerStrategy, setProviderStrategy] = useState(null); // null = use global, "round-robin" = override
|
||||||
|
const [providerStickyLimit, setProviderStickyLimit] = useState("");
|
||||||
const { copied, copy } = useCopyToClipboard();
|
const { copied, copy } = useCopyToClipboard();
|
||||||
|
|
||||||
const providerInfo = providerNode
|
const providerInfo = providerNode
|
||||||
@@ -75,14 +77,16 @@ export default function ProviderDetailPage() {
|
|||||||
|
|
||||||
const fetchConnections = useCallback(async () => {
|
const fetchConnections = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [connectionsRes, nodesRes, proxyPoolsRes] = await Promise.all([
|
const [connectionsRes, nodesRes, proxyPoolsRes, settingsRes] = await Promise.all([
|
||||||
fetch("/api/providers", { cache: "no-store" }),
|
fetch("/api/providers", { cache: "no-store" }),
|
||||||
fetch("/api/provider-nodes", { cache: "no-store" }),
|
fetch("/api/provider-nodes", { cache: "no-store" }),
|
||||||
fetch("/api/proxy-pools?isActive=true", { cache: "no-store" }),
|
fetch("/api/proxy-pools?isActive=true", { cache: "no-store" }),
|
||||||
|
fetch("/api/settings", { cache: "no-store" }),
|
||||||
]);
|
]);
|
||||||
const connectionsData = await connectionsRes.json();
|
const connectionsData = await connectionsRes.json();
|
||||||
const nodesData = await nodesRes.json();
|
const nodesData = await nodesRes.json();
|
||||||
const proxyPoolsData = await proxyPoolsRes.json();
|
const proxyPoolsData = await proxyPoolsRes.json();
|
||||||
|
const settingsData = settingsRes.ok ? await settingsRes.json() : {};
|
||||||
if (connectionsRes.ok) {
|
if (connectionsRes.ok) {
|
||||||
const filtered = (connectionsData.connections || []).filter(c => c.provider === providerId);
|
const filtered = (connectionsData.connections || []).filter(c => c.provider === providerId);
|
||||||
setConnections(filtered);
|
setConnections(filtered);
|
||||||
@@ -90,6 +94,10 @@ export default function ProviderDetailPage() {
|
|||||||
if (proxyPoolsRes.ok) {
|
if (proxyPoolsRes.ok) {
|
||||||
setProxyPools(proxyPoolsData.proxyPools || []);
|
setProxyPools(proxyPoolsData.proxyPools || []);
|
||||||
}
|
}
|
||||||
|
// Load per-provider strategy override
|
||||||
|
const override = (settingsData.providerStrategies || {})[providerId] || {};
|
||||||
|
setProviderStrategy(override.fallbackStrategy || null);
|
||||||
|
setProviderStickyLimit(override.stickyRoundRobinLimit != null ? String(override.stickyRoundRobinLimit) : "1");
|
||||||
if (nodesRes.ok) {
|
if (nodesRes.ok) {
|
||||||
let node = (nodesData.nodes || []).find((entry) => entry.id === providerId) || null;
|
let node = (nodesData.nodes || []).find((entry) => entry.id === providerId) || null;
|
||||||
|
|
||||||
@@ -133,6 +141,49 @@ export default function ProviderDetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveProviderStrategy = async (strategy, stickyLimit) => {
|
||||||
|
try {
|
||||||
|
const settingsRes = await fetch("/api/settings", { cache: "no-store" });
|
||||||
|
const settingsData = settingsRes.ok ? await settingsRes.json() : {};
|
||||||
|
const current = settingsData.providerStrategies || {};
|
||||||
|
|
||||||
|
// Build override: null strategy means remove override, use global
|
||||||
|
const override = {};
|
||||||
|
if (strategy) override.fallbackStrategy = strategy;
|
||||||
|
if (strategy === "round-robin" && stickyLimit !== "") {
|
||||||
|
override.stickyRoundRobinLimit = Number(stickyLimit) || 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = { ...current };
|
||||||
|
if (Object.keys(override).length === 0) {
|
||||||
|
delete updated[providerId];
|
||||||
|
} else {
|
||||||
|
updated[providerId] = override;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetch("/api/settings", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ providerStrategies: updated }),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Error saving provider strategy:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRoundRobinToggle = (enabled) => {
|
||||||
|
const strategy = enabled ? "round-robin" : null;
|
||||||
|
const sticky = enabled ? (providerStickyLimit || "1") : providerStickyLimit;
|
||||||
|
if (enabled && !providerStickyLimit) setProviderStickyLimit("1");
|
||||||
|
setProviderStrategy(strategy);
|
||||||
|
saveProviderStrategy(strategy, sticky);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStickyLimitChange = (value) => {
|
||||||
|
setProviderStickyLimit(value);
|
||||||
|
saveProviderStrategy("round-robin", value);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchConnections();
|
fetchConnections();
|
||||||
fetchAliases();
|
fetchAliases();
|
||||||
@@ -703,29 +754,28 @@ export default function ProviderDetailPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-lg font-semibold">Connections</h2>
|
<h2 className="text-lg font-semibold">Connections</h2>
|
||||||
{!isCompatible && (
|
{/* Round Robin toggle */}
|
||||||
<div className="flex gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{providerId === "iflow" && (
|
<span className="text-xs text-text-muted font-medium">Round Robin</span>
|
||||||
<Button
|
<Toggle
|
||||||
size="sm"
|
checked={providerStrategy === "round-robin"}
|
||||||
icon="cookie"
|
onChange={handleRoundRobinToggle}
|
||||||
variant="secondary"
|
/>
|
||||||
onClick={() => setShowIFlowCookieModal(true)}
|
{providerStrategy === "round-robin" && (
|
||||||
title="Add connection using browser cookie"
|
<div className="flex items-center gap-1.5">
|
||||||
>
|
<span className="text-xs text-text-muted">Sticky:</span>
|
||||||
Cookie
|
<input
|
||||||
</Button>
|
type="number"
|
||||||
)}
|
min={1}
|
||||||
<Button
|
value={providerStickyLimit}
|
||||||
size="sm"
|
onChange={(e) => handleStickyLimitChange(e.target.value)}
|
||||||
icon="add"
|
placeholder="1"
|
||||||
onClick={() => isOAuth ? setShowOAuthModal(true) : setShowAddApiKeyModal(true)}
|
className="w-14 px-2 py-1 text-xs border border-border rounded-md bg-background focus:outline-none focus:border-primary"
|
||||||
>
|
/>
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{connections.length === 0 ? (
|
{connections.length === 0 ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
@@ -750,6 +800,28 @@ export default function ProviderDetailPage() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{connectionsList}
|
{connectionsList}
|
||||||
|
{!isCompatible && (
|
||||||
|
<div className="flex gap-2 mt-4">
|
||||||
|
{providerId === "iflow" && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
icon="cookie"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setShowIFlowCookieModal(true)}
|
||||||
|
title="Add connection using browser cookie"
|
||||||
|
>
|
||||||
|
Cookie
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
icon="add"
|
||||||
|
onClick={() => isOAuth ? setShowOAuthModal(true) : setShowAddApiKeyModal(true)}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
@@ -1613,6 +1685,7 @@ function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthro
|
|||||||
priority: formData.priority,
|
priority: formData.priority,
|
||||||
proxyPoolId: formData.proxyPoolId === NONE_PROXY_POOL_VALUE ? null : formData.proxyPoolId,
|
proxyPoolId: formData.proxyPoolId === NONE_PROXY_POOL_VALUE ? null : formData.proxyPoolId,
|
||||||
testStatus: isValid ? "active" : "unknown",
|
testStatus: isValid ? "active" : "unknown",
|
||||||
|
providerSpecificData: undefined
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
|
|||||||
@@ -214,8 +214,7 @@ export default function ProvidersPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleBatchTest("oauth")}
|
onClick={() => handleBatchTest("oauth")}
|
||||||
disabled={!!testingMode}
|
disabled={!!testingMode}
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${testingMode === "oauth"
|
||||||
testingMode === "oauth"
|
|
||||||
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
|
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
|
||||||
: "bg-bg border-border text-text-muted hover:text-text-main hover:border-primary/40"
|
: "bg-bg border-border text-text-muted hover:text-text-main hover:border-primary/40"
|
||||||
}`}
|
}`}
|
||||||
@@ -252,8 +251,7 @@ export default function ProvidersPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleBatchTest("free")}
|
onClick={() => handleBatchTest("free")}
|
||||||
disabled={!!testingMode}
|
disabled={!!testingMode}
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${testingMode === "free"
|
||||||
testingMode === "free"
|
|
||||||
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
|
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
|
||||||
: "bg-bg border-border text-text-muted hover:text-text-main hover:border-primary/40"
|
: "bg-bg border-border text-text-muted hover:text-text-main hover:border-primary/40"
|
||||||
}`}
|
}`}
|
||||||
@@ -289,8 +287,7 @@ export default function ProvidersPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleBatchTest("apikey")}
|
onClick={() => handleBatchTest("apikey")}
|
||||||
disabled={!!testingMode}
|
disabled={!!testingMode}
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${testingMode === "apikey"
|
||||||
testingMode === "apikey"
|
|
||||||
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
|
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
|
||||||
: "bg-bg border-border text-text-muted hover:text-text-main hover:border-primary/40"
|
: "bg-bg border-border text-text-muted hover:text-text-main hover:border-primary/40"
|
||||||
}`}
|
}`}
|
||||||
@@ -328,8 +325,7 @@ export default function ProvidersPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleBatchTest("compatible")}
|
onClick={() => handleBatchTest("compatible")}
|
||||||
disabled={!!testingMode}
|
disabled={!!testingMode}
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${testingMode === "compatible"
|
||||||
testingMode === "compatible"
|
|
||||||
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
|
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
|
||||||
: "bg-bg border-border text-text-muted hover:text-text-main hover:border-primary/40"
|
: "bg-bg border-border text-text-muted hover:text-text-main hover:border-primary/40"
|
||||||
}`}
|
}`}
|
||||||
@@ -449,7 +445,7 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div
|
<div
|
||||||
className="size-8 rounded-lg flex items-center justify-center"
|
className="size-8 rounded-lg flex items-center justify-center"
|
||||||
style={{ backgroundColor: `${provider.color}15` }}
|
style={{ backgroundColor: `${provider.color?.length > 7 ? provider.color : provider.color + "15"}` }}
|
||||||
>
|
>
|
||||||
{imgError ? (
|
{imgError ? (
|
||||||
<span className="text-xs font-bold" style={{ color: provider.color }}>
|
<span className="text-xs font-bold" style={{ color: provider.color }}>
|
||||||
@@ -561,7 +557,7 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div
|
<div
|
||||||
className="size-8 rounded-lg flex items-center justify-center"
|
className="size-8 rounded-lg flex items-center justify-center"
|
||||||
style={{ backgroundColor: `${provider.color}15` }}
|
style={{ backgroundColor: `${provider.color?.length > 7 ? provider.color : provider.color + "15"}` }}
|
||||||
>
|
>
|
||||||
{imgError ? (
|
{imgError ? (
|
||||||
<span className="text-xs font-bold" style={{ color: provider.color }}>
|
<span className="text-xs font-bold" style={{ color: provider.color }}>
|
||||||
@@ -955,8 +951,7 @@ function ProviderTestResultsView({ results }) {
|
|||||||
<span className="text-text-muted font-mono tabular-nums">{r.latencyMs}ms</span>
|
<span className="text-text-muted font-mono tabular-nums">{r.latencyMs}ms</span>
|
||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
className={`text-[10px] uppercase font-bold px-1.5 py-0.5 rounded ${
|
className={`text-[10px] uppercase font-bold px-1.5 py-0.5 rounded ${r.valid ? "bg-emerald-500/15 text-emerald-400" : "bg-red-500/15 text-red-400"
|
||||||
r.valid ? "bg-emerald-500/15 text-emerald-400" : "bg-red-500/15 text-red-400"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{r.valid ? "OK" : r.diagnosis?.type || "ERROR"}
|
{r.valid ? "OK" : r.diagnosis?.type || "ERROR"}
|
||||||
|
|||||||
@@ -180,8 +180,11 @@ export default function TranslatorPage() {
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ file: "5_res_provider.txt", content: full })
|
body: JSON.stringify({ file: "5_res_provider.txt", content: full })
|
||||||
});
|
});
|
||||||
} catch (e) { alert(e.message); }
|
} catch (e) {
|
||||||
|
alert(e.message);
|
||||||
|
} finally {
|
||||||
setLoad("send", false);
|
setLoad("send", false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopy = async (id) => {
|
const handleCopy = async (id) => {
|
||||||
|
|||||||
@@ -247,22 +247,11 @@ export default function ProviderLimits() {
|
|||||||
USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) && conn.authType === "oauth"
|
USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) && conn.authType === "oauth"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Sort providers: antigravity first, then kiro, then others alphabetically
|
// Sort providers by USAGE_SUPPORTED_PROVIDERS order, then alphabetically
|
||||||
const sortedConnections = [...filteredConnections].sort((a, b) => {
|
const sortedConnections = [...filteredConnections].sort((a, b) => {
|
||||||
const getProviderPriority = (provider) => {
|
const orderA = USAGE_SUPPORTED_PROVIDERS.indexOf(a.provider);
|
||||||
if (provider === "antigravity") return 1;
|
const orderB = USAGE_SUPPORTED_PROVIDERS.indexOf(b.provider);
|
||||||
if (provider === "kiro") return 2;
|
if (orderA !== orderB) return orderA - orderB;
|
||||||
return 3;
|
|
||||||
};
|
|
||||||
|
|
||||||
const priorityA = getProviderPriority(a.provider);
|
|
||||||
const priorityB = getProviderPriority(b.provider);
|
|
||||||
|
|
||||||
if (priorityA !== priorityB) {
|
|
||||||
return priorityA - priorityB;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same priority: sort alphabetically
|
|
||||||
return a.provider.localeCompare(b.provider);
|
return a.provider.localeCompare(b.provider);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { promisify } from "util";
|
|||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import os from "os";
|
import os from "os";
|
||||||
|
import { parseTOML, stringifyTOML } from "confbox";
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
@@ -13,62 +14,31 @@ const getCodexDir = () => path.join(os.homedir(), ".codex");
|
|||||||
const getCodexConfigPath = () => path.join(getCodexDir(), "config.toml");
|
const getCodexConfigPath = () => path.join(getCodexDir(), "config.toml");
|
||||||
const getCodexAuthPath = () => path.join(getCodexDir(), "auth.json");
|
const getCodexAuthPath = () => path.join(getCodexDir(), "auth.json");
|
||||||
|
|
||||||
// Parse TOML config to object (simple parser for codex config)
|
// Flatten confbox-parsed TOML into a writable object, preserving nested tables
|
||||||
const parseToml = (content) => {
|
const parsedToWritable = (obj) => obj ?? {};
|
||||||
const result = { _root: {}, _sections: {} };
|
|
||||||
let currentSection = "_root";
|
|
||||||
|
|
||||||
content.split("\n").forEach((line) => {
|
// Set a nested key from a flat dotted path, creating intermediate objects as needed
|
||||||
const trimmed = line.trim();
|
const setNestedSection = (obj, dottedKey, value) => {
|
||||||
if (!trimmed || trimmed.startsWith("#")) return;
|
const keys = dottedKey.split(".");
|
||||||
|
let cur = obj;
|
||||||
// Section header like [model_providers.9router]
|
for (let i = 0; i < keys.length - 1; i++) {
|
||||||
const sectionMatch = trimmed.match(/^\[(.+)\]$/);
|
if (cur[keys[i]] == null || typeof cur[keys[i]] !== "object") {
|
||||||
if (sectionMatch) {
|
cur[keys[i]] = {};
|
||||||
currentSection = sectionMatch[1];
|
|
||||||
result._sections[currentSection] = {};
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
cur = cur[keys[i]];
|
||||||
// Key = value
|
|
||||||
const kvMatch = trimmed.match(/^([^=]+)\s*=\s*(.+)$/);
|
|
||||||
if (kvMatch) {
|
|
||||||
const key = kvMatch[1].trim();
|
|
||||||
let value = kvMatch[2].trim();
|
|
||||||
// Remove quotes
|
|
||||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
||||||
value = value.slice(1, -1);
|
|
||||||
}
|
}
|
||||||
if (currentSection === "_root") {
|
cur[keys[keys.length - 1]] = value;
|
||||||
result._root[key] = value;
|
|
||||||
} else {
|
|
||||||
result._sections[currentSection][key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Convert parsed object back to TOML string
|
// Delete a nested key from a flat dotted path
|
||||||
const toToml = (parsed) => {
|
const deleteNestedSection = (obj, dottedKey) => {
|
||||||
let lines = [];
|
const keys = dottedKey.split(".");
|
||||||
|
let cur = obj;
|
||||||
// Root level keys
|
for (let i = 0; i < keys.length - 1; i++) {
|
||||||
Object.entries(parsed._root).forEach(([key, value]) => {
|
cur = cur?.[keys[i]];
|
||||||
lines.push(`${key} = "${value}"`);
|
if (cur == null) return;
|
||||||
});
|
}
|
||||||
|
delete cur[keys[keys.length - 1]];
|
||||||
// Sections
|
|
||||||
Object.entries(parsed._sections).forEach(([section, values]) => {
|
|
||||||
lines.push("");
|
|
||||||
lines.push(`[${section}]`);
|
|
||||||
Object.entries(values).forEach(([key, value]) => {
|
|
||||||
lines.push(`${key} = "${value}"`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return lines.join("\n") + "\n";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if codex CLI is installed
|
// Check if codex CLI is installed
|
||||||
@@ -144,27 +114,27 @@ export async function POST(request) {
|
|||||||
await fs.mkdir(codexDir, { recursive: true });
|
await fs.mkdir(codexDir, { recursive: true });
|
||||||
|
|
||||||
// Read and parse existing config
|
// Read and parse existing config
|
||||||
let parsed = { _root: {}, _sections: {} };
|
let parsed = {};
|
||||||
try {
|
try {
|
||||||
const existingConfig = await fs.readFile(configPath, "utf-8");
|
const existingConfig = await fs.readFile(configPath, "utf-8");
|
||||||
parsed = parseToml(existingConfig);
|
parsed = parsedToWritable(parseTOML(existingConfig));
|
||||||
} catch { /* No existing config */ }
|
} catch { /* No existing config */ }
|
||||||
|
|
||||||
// Update only 9Router related fields (api_key goes to auth.json, not config.toml)
|
// Update only 9Router related fields (api_key goes to auth.json, not config.toml)
|
||||||
parsed._root.model = model;
|
parsed.model = model;
|
||||||
parsed._root.model_provider = "9router";
|
parsed.model_provider = "9router";
|
||||||
|
|
||||||
// Update or create 9router provider section (no api_key - Codex reads from auth.json)
|
// Update or create 9router provider section (no api_key - Codex reads from auth.json)
|
||||||
// Ensure /v1 suffix is added only once
|
// Ensure /v1 suffix is added only once
|
||||||
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
|
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
|
||||||
parsed._sections["model_providers.9router"] = {
|
setNestedSection(parsed, "model_providers.9router", {
|
||||||
name: "9Router",
|
name: "9Router",
|
||||||
base_url: normalizedBaseUrl,
|
base_url: normalizedBaseUrl,
|
||||||
wire_api: "responses",
|
wire_api: "responses",
|
||||||
};
|
});
|
||||||
|
|
||||||
// Write merged config
|
// Write merged config
|
||||||
const configContent = toToml(parsed);
|
const configContent = stringifyTOML(parsed);
|
||||||
await fs.writeFile(configPath, configContent);
|
await fs.writeFile(configPath, configContent);
|
||||||
|
|
||||||
// Update auth.json with OPENAI_API_KEY (Codex reads this first)
|
// Update auth.json with OPENAI_API_KEY (Codex reads this first)
|
||||||
@@ -195,10 +165,10 @@ export async function DELETE() {
|
|||||||
const configPath = getCodexConfigPath();
|
const configPath = getCodexConfigPath();
|
||||||
|
|
||||||
// Read and parse existing config
|
// Read and parse existing config
|
||||||
let parsed = { _root: {}, _sections: {} };
|
let parsed = {};
|
||||||
try {
|
try {
|
||||||
const existingConfig = await fs.readFile(configPath, "utf-8");
|
const existingConfig = await fs.readFile(configPath, "utf-8");
|
||||||
parsed = parseToml(existingConfig);
|
parsed = parsedToWritable(parseTOML(existingConfig));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code === "ENOENT") {
|
if (error.code === "ENOENT") {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -210,16 +180,16 @@ export async function DELETE() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Remove 9Router related root fields only if they point to 9router
|
// Remove 9Router related root fields only if they point to 9router
|
||||||
if (parsed._root.model_provider === "9router") {
|
if (parsed.model_provider === "9router") {
|
||||||
delete parsed._root.model;
|
delete parsed.model;
|
||||||
delete parsed._root.model_provider;
|
delete parsed.model_provider;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove 9router provider section
|
// Remove 9router provider section
|
||||||
delete parsed._sections["model_providers.9router"];
|
deleteNestedSection(parsed, "model_providers.9router");
|
||||||
|
|
||||||
// Write updated config
|
// Write updated config
|
||||||
const configContent = toToml(parsed);
|
const configContent = stringifyTOML(parsed);
|
||||||
await fs.writeFile(configPath, configContent);
|
await fs.writeFile(configPath, configContent);
|
||||||
|
|
||||||
// Remove OPENAI_API_KEY from auth.json
|
// Remove OPENAI_API_KEY from auth.json
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { getProviderConnectionById } from "@/models";
|
|||||||
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
|
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
|
||||||
import { KiroService } from "@/lib/oauth/services/kiro";
|
import { KiroService } from "@/lib/oauth/services/kiro";
|
||||||
import { GEMINI_CONFIG } from "@/lib/oauth/constants/oauth";
|
import { GEMINI_CONFIG } from "@/lib/oauth/constants/oauth";
|
||||||
import { refreshGoogleToken, updateProviderCredentials } from "@/sse/services/tokenRefresh";
|
import { refreshGoogleToken, updateProviderCredentials, refreshKiroToken } from "@/sse/services/tokenRefresh";
|
||||||
|
|
||||||
const GEMINI_CLI_MODELS_URL = "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels";
|
const GEMINI_CLI_MODELS_URL = "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels";
|
||||||
|
|
||||||
@@ -162,6 +162,8 @@ const PROVIDER_MODELS_CONFIG = {
|
|||||||
nebius: createOpenAIModelsConfig("https://api.studio.nebius.ai/v1/models"),
|
nebius: createOpenAIModelsConfig("https://api.studio.nebius.ai/v1/models"),
|
||||||
siliconflow: createOpenAIModelsConfig("https://api.siliconflow.cn/v1/models"),
|
siliconflow: createOpenAIModelsConfig("https://api.siliconflow.cn/v1/models"),
|
||||||
hyperbolic: createOpenAIModelsConfig("https://api.hyperbolic.xyz/v1/models"),
|
hyperbolic: createOpenAIModelsConfig("https://api.hyperbolic.xyz/v1/models"),
|
||||||
|
ollama: createOpenAIModelsConfig("https://ollama.com/api/tags"),
|
||||||
|
"ollama-local": createOpenAIModelsConfig("http://localhost:11434/api/tags"),
|
||||||
nanobanana: createOpenAIModelsConfig("https://api.nanobananaapi.ai/v1/models"),
|
nanobanana: createOpenAIModelsConfig("https://api.nanobananaapi.ai/v1/models"),
|
||||||
chutes: createOpenAIModelsConfig("https://llm.chutes.ai/v1/models"),
|
chutes: createOpenAIModelsConfig("https://llm.chutes.ai/v1/models"),
|
||||||
nvidia: createOpenAIModelsConfig("https://integrate.api.nvidia.com/v1/models"),
|
nvidia: createOpenAIModelsConfig("https://integrate.api.nvidia.com/v1/models"),
|
||||||
@@ -256,22 +258,56 @@ export async function GET(request, { params }) {
|
|||||||
|
|
||||||
// Kiro: Try dynamic model fetching first
|
// Kiro: Try dynamic model fetching first
|
||||||
if (connection.provider === "kiro") {
|
if (connection.provider === "kiro") {
|
||||||
|
let warning;
|
||||||
try {
|
try {
|
||||||
const kiroService = new KiroService();
|
const kiroService = new KiroService();
|
||||||
const profileArn = connection.providerSpecificData?.profileArn;
|
const profileArn = connection.providerSpecificData?.profileArn;
|
||||||
const accessToken = connection.accessToken;
|
const accessToken = connection.accessToken;
|
||||||
|
const refreshToken = connection.refreshToken;
|
||||||
|
|
||||||
if (accessToken && profileArn) {
|
if (accessToken && profileArn) {
|
||||||
|
try {
|
||||||
const models = await kiroService.listAvailableModels(accessToken, profileArn);
|
const models = await kiroService.listAvailableModels(accessToken, profileArn);
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
provider: connection.provider,
|
provider: connection.provider,
|
||||||
connectionId: connection.id,
|
connectionId: connection.id,
|
||||||
models
|
models
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes("AccessDeniedException") && refreshToken) {
|
||||||
|
console.log("Kiro token invalid/expired. Attempting refresh...");
|
||||||
|
const refreshed = await refreshKiroToken(refreshToken, connection.providerSpecificData);
|
||||||
|
|
||||||
|
if (refreshed?.accessToken) {
|
||||||
|
await updateProviderCredentials(connection.id, {
|
||||||
|
accessToken: refreshed.accessToken,
|
||||||
|
refreshToken: refreshed.refreshToken || refreshToken,
|
||||||
|
expiresIn: refreshed.expiresIn,
|
||||||
|
});
|
||||||
|
|
||||||
|
const models = await kiroService.listAvailableModels(refreshed.accessToken, profileArn);
|
||||||
|
return NextResponse.json({
|
||||||
|
provider: connection.provider,
|
||||||
|
connectionId: connection.id,
|
||||||
|
models
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error; // Let outer catch handle it
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
warning = `Failed to fetch Kiro models: ${error.message}`;
|
||||||
console.log("Failed to fetch Kiro models dynamically, falling back to static:", error.message);
|
console.log("Failed to fetch Kiro models dynamically, falling back to static:", error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Return empty dynamic list so UI falls back to static provider models.
|
||||||
|
return NextResponse.json({
|
||||||
|
provider: connection.provider,
|
||||||
|
connectionId: connection.id,
|
||||||
|
models: [],
|
||||||
|
warning,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (connection.provider === "gemini-cli") {
|
if (connection.provider === "gemini-cli") {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ const OAUTH_TEST_CONFIG = {
|
|||||||
},
|
},
|
||||||
qwen: { checkExpiry: true, refreshable: true },
|
qwen: { checkExpiry: true, refreshable: true },
|
||||||
kiro: { checkExpiry: true, refreshable: true },
|
kiro: { checkExpiry: true, refreshable: true },
|
||||||
|
"kimi-coding": { checkExpiry: true, refreshable: false },
|
||||||
cursor: { tokenExists: true },
|
cursor: { tokenExists: true },
|
||||||
kilocode: {
|
kilocode: {
|
||||||
url: `${KILOCODE_CONFIG.apiBaseUrl}/api/profile`,
|
url: `${KILOCODE_CONFIG.apiBaseUrl}/api/profile`,
|
||||||
@@ -459,6 +460,15 @@ async function testApiKeyConnection(connection, effectiveProxy = null) {
|
|||||||
const res = await fetchWithConnectionProxy("https://api.hyperbolic.xyz/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
|
const res = await fetchWithConnectionProxy("https://api.hyperbolic.xyz/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
|
||||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||||
}
|
}
|
||||||
|
case "ollama": {
|
||||||
|
const res = await fetch("https://ollama.com/api/tags", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
|
||||||
|
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||||
|
}
|
||||||
|
case "ollama-local": {
|
||||||
|
// No auth required for local Ollama
|
||||||
|
const res = await fetch("http://localhost:11434/api/tags");
|
||||||
|
return { valid: res.ok, error: res.ok ? null : "Ollama not running on localhost:11434" };
|
||||||
|
}
|
||||||
case "deepgram": {
|
case "deepgram": {
|
||||||
const res = await fetchWithConnectionProxy("https://api.deepgram.com/v1/projects", { headers: { Authorization: `Token ${connection.apiKey}` } }, effectiveProxy);
|
const res = await fetchWithConnectionProxy("https://api.deepgram.com/v1/projects", { headers: { Authorization: `Token ${connection.apiKey}` } }, effectiveProxy);
|
||||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||||
|
|||||||
@@ -163,6 +163,8 @@ export async function POST(request) {
|
|||||||
case "nebius":
|
case "nebius":
|
||||||
case "siliconflow":
|
case "siliconflow":
|
||||||
case "hyperbolic":
|
case "hyperbolic":
|
||||||
|
case "ollama":
|
||||||
|
case "ollama-local":
|
||||||
case "assemblyai":
|
case "assemblyai":
|
||||||
case "nanobanana":
|
case "nanobanana":
|
||||||
case "chutes":
|
case "chutes":
|
||||||
@@ -180,6 +182,8 @@ export async function POST(request) {
|
|||||||
nebius: "https://api.studio.nebius.ai/v1/models",
|
nebius: "https://api.studio.nebius.ai/v1/models",
|
||||||
siliconflow: "https://api.siliconflow.cn/v1/models",
|
siliconflow: "https://api.siliconflow.cn/v1/models",
|
||||||
hyperbolic: "https://api.hyperbolic.xyz/v1/models",
|
hyperbolic: "https://api.hyperbolic.xyz/v1/models",
|
||||||
|
ollama: "https://ollama.com/api/tags",
|
||||||
|
"ollama-local": "http://localhost:11434/api/tags",
|
||||||
assemblyai: "https://api.assemblyai.com/v1/account",
|
assemblyai: "https://api.assemblyai.com/v1/account",
|
||||||
nanobanana: "https://api.nanobananaapi.ai/v1/models",
|
nanobanana: "https://api.nanobananaapi.ai/v1/models",
|
||||||
chutes: "https://llm.chutes.ai/v1/models",
|
chutes: "https://llm.chutes.ai/v1/models",
|
||||||
@@ -200,6 +204,38 @@ export async function POST(request) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "vertex": {
|
||||||
|
// Raw key: probe global endpoint (always 404 for unknown model, never 401)
|
||||||
|
// SA JSON: attempt token mint via JWT assertion
|
||||||
|
const saJson = (() => { try { const p = JSON.parse(apiKey); return p.type === "service_account" ? p : null; } catch { return null; } })();
|
||||||
|
if (saJson) {
|
||||||
|
// Validate SA JSON has required fields
|
||||||
|
isValid = !!(saJson.client_email && saJson.private_key && saJson.project_id);
|
||||||
|
} else {
|
||||||
|
// Raw key: probe Vertex — 404 means key is valid (model just doesn't exist), 401 means invalid key
|
||||||
|
const probeRes = await fetch(
|
||||||
|
`https://aiplatform.googleapis.com/v1/publishers/google/models/__probe__:generateContent?key=${apiKey}`,
|
||||||
|
{ method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }
|
||||||
|
);
|
||||||
|
isValid = probeRes.status !== 401 && probeRes.status !== 403;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "vertex-partner": {
|
||||||
|
const saJson = (() => { try { const p = JSON.parse(apiKey); return p.type === "service_account" ? p : null; } catch { return null; } })();
|
||||||
|
if (saJson) {
|
||||||
|
isValid = !!(saJson.client_email && saJson.private_key && saJson.project_id);
|
||||||
|
} else {
|
||||||
|
const probeRes = await fetch(
|
||||||
|
`https://aiplatform.googleapis.com/v1/publishers/google/models/__probe__:generateContent?key=${apiKey}`,
|
||||||
|
{ method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }
|
||||||
|
);
|
||||||
|
isValid = probeRes.status !== 401 && probeRes.status !== 403;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return NextResponse.json({ error: "Provider validation not supported" }, { status: 400 });
|
return NextResponse.json({ error: "Provider validation not supported" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { getUsageForProvider } from "open-sse/services/usage.js";
|
|||||||
import { getExecutor } from "open-sse/executors/index.js";
|
import { getExecutor } from "open-sse/executors/index.js";
|
||||||
/**
|
/**
|
||||||
* Refresh credentials using executor and update database
|
* Refresh credentials using executor and update database
|
||||||
* @returns {{ connection, refreshed: boolean }}
|
* @returns Promise<{ connection, refreshed: boolean }>
|
||||||
*/
|
*/
|
||||||
async function refreshAndUpdateCredentials(connection) {
|
async function refreshAndUpdateCredentials(connection) {
|
||||||
const executor = getExecutor(connection.provider);
|
const executor = getExecutor(connection.provider);
|
||||||
@@ -91,11 +91,12 @@ async function refreshAndUpdateCredentials(connection) {
|
|||||||
* GET /api/usage/[connectionId] - Get usage data for a specific connection
|
* GET /api/usage/[connectionId] - Get usage data for a specific connection
|
||||||
*/
|
*/
|
||||||
export async function GET(request, { params }) {
|
export async function GET(request, { params }) {
|
||||||
|
let connection;
|
||||||
try {
|
try {
|
||||||
const { connectionId } = await params;
|
const { connectionId } = await params;
|
||||||
|
|
||||||
// Get connection from database
|
// Get connection from database
|
||||||
let connection = await getProviderConnectionById(connectionId);
|
connection = await getProviderConnectionById(connectionId);
|
||||||
if (!connection) {
|
if (!connection) {
|
||||||
return Response.json({ error: "Connection not found" }, { status: 404 });
|
return Response.json({ error: "Connection not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
@@ -120,8 +121,7 @@ export async function GET(request, { params }) {
|
|||||||
const usage = await getUsageForProvider(connection);
|
const usage = await getUsageForProvider(connection);
|
||||||
return Response.json(usage);
|
return Response.json(usage);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Usage API] Error fetching usage:", error);
|
console.warn(`[Usage] ${connection?.provider}: ${error.message}`);
|
||||||
console.error("[Usage API] Error stack:", error.stack);
|
|
||||||
return Response.json({ error: error.message }, { status: 500 });
|
return Response.json({ error: error.message }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ export async function GET() {
|
|||||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(stats)}\n\n`));
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(stats)}\n\n`));
|
||||||
} catch {
|
} catch {
|
||||||
state.closed = true;
|
state.closed = true;
|
||||||
|
statsEmitter.off("update", state.send);
|
||||||
statsEmitter.off("pending", state.sendPending);
|
statsEmitter.off("pending", state.sendPending);
|
||||||
|
clearInterval(state.keepalive);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ const defaultData = {
|
|||||||
tunnelEnabled: false,
|
tunnelEnabled: false,
|
||||||
tunnelUrl: "",
|
tunnelUrl: "",
|
||||||
stickyRoundRobinLimit: 3,
|
stickyRoundRobinLimit: 3,
|
||||||
|
providerStrategies: {},
|
||||||
requireLogin: true,
|
requireLogin: true,
|
||||||
observabilityEnabled: true,
|
observabilityEnabled: true,
|
||||||
observabilityMaxRecords: 1000,
|
observabilityMaxRecords: 1000,
|
||||||
@@ -80,6 +81,7 @@ function cloneDefaultData() {
|
|||||||
tunnelEnabled: false,
|
tunnelEnabled: false,
|
||||||
tunnelUrl: "",
|
tunnelUrl: "",
|
||||||
stickyRoundRobinLimit: 3,
|
stickyRoundRobinLimit: 3,
|
||||||
|
providerStrategies: {},
|
||||||
requireLogin: true,
|
requireLogin: true,
|
||||||
observabilityEnabled: true,
|
observabilityEnabled: true,
|
||||||
observabilityMaxRecords: 1000,
|
observabilityMaxRecords: 1000,
|
||||||
|
|||||||
+14
-12
@@ -246,23 +246,25 @@ export async function getRequestDetailById(id) {
|
|||||||
return db.data.records.find(r => r.id === id) || null;
|
return db.data.records.find(r => r.id === id) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown — use named handler so we can remove it on re-registration
|
||||||
let shutdownHandlerRegistered = false;
|
const _shutdownHandler = async () => {
|
||||||
|
|
||||||
function ensureShutdownHandler() {
|
|
||||||
if (shutdownHandlerRegistered || isCloud) return;
|
|
||||||
|
|
||||||
const handler = async () => {
|
|
||||||
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
|
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
|
||||||
if (writeBuffer.length > 0) await flushToDatabase();
|
if (writeBuffer.length > 0) await flushToDatabase();
|
||||||
};
|
};
|
||||||
|
|
||||||
process.on("beforeExit", handler);
|
function ensureShutdownHandler() {
|
||||||
process.on("SIGINT", handler);
|
if (isCloud) return;
|
||||||
process.on("SIGTERM", handler);
|
|
||||||
process.on("exit", handler);
|
|
||||||
|
|
||||||
shutdownHandlerRegistered = true;
|
// Remove any previously registered listeners from this module (hot-reload safety)
|
||||||
|
process.off("beforeExit", _shutdownHandler);
|
||||||
|
process.off("SIGINT", _shutdownHandler);
|
||||||
|
process.off("SIGTERM", _shutdownHandler);
|
||||||
|
process.off("exit", _shutdownHandler);
|
||||||
|
|
||||||
|
process.on("beforeExit", _shutdownHandler);
|
||||||
|
process.on("SIGINT", _shutdownHandler);
|
||||||
|
process.on("SIGTERM", _shutdownHandler);
|
||||||
|
process.on("exit", _shutdownHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureShutdownHandler();
|
ensureShutdownHandler();
|
||||||
|
|||||||
@@ -141,8 +141,10 @@ export async function spawnCloudflared(tunnelToken) {
|
|||||||
|
|
||||||
const handleLog = (data) => {
|
const handleLog = (data) => {
|
||||||
const msg = data.toString();
|
const msg = data.toString();
|
||||||
if (msg.includes("Registered tunnel connection")) {
|
// Count exact occurrences in this chunk (each chunk may contain multiple lines)
|
||||||
connectionCount++;
|
const matches = msg.match(/Registered tunnel connection/g);
|
||||||
|
if (matches) {
|
||||||
|
connectionCount += matches.length;
|
||||||
if (connectionCount >= 4 && !resolved) {
|
if (connectionCount >= 4 && !resolved) {
|
||||||
resolved = true;
|
resolved = true;
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
@@ -165,6 +167,7 @@ export async function spawnCloudflared(tunnelToken) {
|
|||||||
child.on("exit", (code) => {
|
child.on("exit", (code) => {
|
||||||
cloudflaredProcess = null;
|
cloudflaredProcess = null;
|
||||||
clearPid();
|
clearPid();
|
||||||
|
const wasConnected = resolved; // true = already connected successfully
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
resolved = true;
|
resolved = true;
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
@@ -173,8 +176,8 @@ export async function spawnCloudflared(tunnelToken) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Notify reconnect handler if tunnel died after successful connection
|
// Only notify on unexpected exit AFTER successful connection
|
||||||
if (unexpectedExitHandler) {
|
if (wasConnected && unexpectedExitHandler) {
|
||||||
unexpectedExitHandler();
|
unexpectedExitHandler();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ const MACHINE_ID_SALT = "9router-tunnel-salt";
|
|||||||
const API_KEY_SECRET = "9router-tunnel-api-key-secret";
|
const API_KEY_SECRET = "9router-tunnel-api-key-secret";
|
||||||
const SHORT_ID_LENGTH = 6;
|
const SHORT_ID_LENGTH = 6;
|
||||||
const SHORT_ID_CHARS = "abcdefghijklmnpqrstuvwxyz23456789";
|
const SHORT_ID_CHARS = "abcdefghijklmnpqrstuvwxyz23456789";
|
||||||
const RECONNECT_DELAYS_MS = [5000, 15000, 30000];
|
const RECONNECT_DELAYS_MS = [5000, 10000, 20000, 30000, 60000];
|
||||||
|
const MAX_RECONNECT_ATTEMPTS = RECONNECT_DELAYS_MS.length;
|
||||||
|
|
||||||
let isReconnecting = false;
|
let isReconnecting = false;
|
||||||
|
|
||||||
@@ -83,8 +84,10 @@ export async function enableTunnel() {
|
|||||||
|
|
||||||
await updateSettings({ tunnelEnabled: true, tunnelUrl: hostname });
|
await updateSettings({ tunnelEnabled: true, tunnelUrl: hostname });
|
||||||
|
|
||||||
// Register exit handler for auto-reconnect on unexpected crash/sleep-wake
|
// Re-register exit handler each time tunnel starts (handles reconnect scenario too)
|
||||||
setUnexpectedExitHandler(() => scheduleReconnect(0));
|
setUnexpectedExitHandler(() => {
|
||||||
|
if (!isReconnecting) scheduleReconnect(0);
|
||||||
|
});
|
||||||
|
|
||||||
return { success: true, tunnelUrl: hostname, shortId };
|
return { success: true, tunnelUrl: hostname, shortId };
|
||||||
}
|
}
|
||||||
@@ -112,7 +115,7 @@ async function scheduleReconnect(attempt) {
|
|||||||
console.log(`[Tunnel] Reconnect attempt ${attempt + 1} failed:`, err.message);
|
console.log(`[Tunnel] Reconnect attempt ${attempt + 1} failed:`, err.message);
|
||||||
isReconnecting = false;
|
isReconnecting = false;
|
||||||
const nextAttempt = attempt + 1;
|
const nextAttempt = attempt + 1;
|
||||||
if (nextAttempt < RECONNECT_DELAYS_MS.length) {
|
if (nextAttempt < MAX_RECONNECT_ATTEMPTS) {
|
||||||
scheduleReconnect(nextAttempt);
|
scheduleReconnect(nextAttempt);
|
||||||
} else {
|
} else {
|
||||||
console.log("[Tunnel] All reconnect attempts exhausted");
|
console.log("[Tunnel] All reconnect attempts exhausted");
|
||||||
|
|||||||
+5
-2
@@ -245,8 +245,11 @@ export async function saveRequestUsage(entry) {
|
|||||||
entry.cost = entryCost;
|
entry.cost = entryCost;
|
||||||
db.data.history.push(entry);
|
db.data.history.push(entry);
|
||||||
|
|
||||||
// Optional: Limit history size if needed in future
|
// Cap history to prevent unbounded memory/disk growth
|
||||||
// if (db.data.history.length > 10000) db.data.history.shift();
|
const MAX_HISTORY = 10000;
|
||||||
|
if (db.data.history.length > MAX_HISTORY) {
|
||||||
|
db.data.history.splice(0, db.data.history.length - MAX_HISTORY);
|
||||||
|
}
|
||||||
|
|
||||||
await db.write();
|
await db.write();
|
||||||
statsEmitter.emit("update");
|
statsEmitter.emit("update");
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ const MITM_PORT = 443;
|
|||||||
const MITM_WIN_NODE_PORT = 8443;
|
const MITM_WIN_NODE_PORT = 8443;
|
||||||
const PID_FILE = path.join(MITM_DIR, ".mitm.pid");
|
const PID_FILE = path.join(MITM_DIR, ".mitm.pid");
|
||||||
|
|
||||||
|
const MITM_MAX_RESTARTS = 5;
|
||||||
|
const MITM_RESTART_DELAYS_MS = [5000, 10000, 20000, 30000, 60000];
|
||||||
|
const MITM_RESTART_RESET_MS = 60000;
|
||||||
|
|
||||||
|
let mitmRestartCount = 0;
|
||||||
|
let mitmLastStartTime = 0;
|
||||||
|
let mitmIsRestarting = false;
|
||||||
|
|
||||||
function resolveServerPath() {
|
function resolveServerPath() {
|
||||||
if (process.env.MITM_SERVER_PATH) return process.env.MITM_SERVER_PATH;
|
if (process.env.MITM_SERVER_PATH) return process.env.MITM_SERVER_PATH;
|
||||||
const sibling = path.join(__dirname, "server.js");
|
const sibling = path.join(__dirname, "server.js");
|
||||||
@@ -273,6 +281,50 @@ async function getMitmStatus() {
|
|||||||
return { running, pid, certExists, dnsStatus };
|
return { running, pid, certExists, dnsStatus };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function scheduleMitmRestart(apiKey) {
|
||||||
|
if (mitmIsRestarting) return;
|
||||||
|
|
||||||
|
const aliveMs = Date.now() - mitmLastStartTime;
|
||||||
|
if (aliveMs >= MITM_RESTART_RESET_MS) mitmRestartCount = 0;
|
||||||
|
|
||||||
|
if (mitmRestartCount >= MITM_MAX_RESTARTS) {
|
||||||
|
console.error("[MITM] Max restart attempts reached. Giving up.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attempt = mitmRestartCount;
|
||||||
|
const delay = MITM_RESTART_DELAYS_MS[Math.min(attempt, MITM_RESTART_DELAYS_MS.length - 1)];
|
||||||
|
mitmRestartCount++;
|
||||||
|
mitmIsRestarting = true;
|
||||||
|
|
||||||
|
console.log(`[MITM] Restarting in ${delay / 1000}s... (${mitmRestartCount}/${MITM_MAX_RESTARTS})`);
|
||||||
|
await new Promise((r) => setTimeout(r, delay));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const settings = _getSettings ? await _getSettings() : null;
|
||||||
|
if (settings && !settings.mitmEnabled) {
|
||||||
|
console.log("[MITM] MITM disabled, skipping restart");
|
||||||
|
mitmIsRestarting = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const password = getCachedPassword() || await loadEncryptedPassword();
|
||||||
|
if (!password && !IS_WIN) {
|
||||||
|
console.error("[MITM] No cached password, cannot auto-restart");
|
||||||
|
mitmIsRestarting = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await startServer(apiKey, password);
|
||||||
|
console.log("[MITM] Restarted successfully");
|
||||||
|
mitmRestartCount = 0;
|
||||||
|
mitmIsRestarting = false;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[MITM] Restart attempt ${mitmRestartCount}/${MITM_MAX_RESTARTS} failed:`, err.message);
|
||||||
|
mitmIsRestarting = false;
|
||||||
|
// Schedule next retry
|
||||||
|
scheduleMitmRestart(apiKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start MITM server only (cert + server, no DNS)
|
* Start MITM server only (cert + server, no DNS)
|
||||||
*/
|
*/
|
||||||
@@ -378,6 +430,7 @@ async function startServer(apiKey, sudoPassword) {
|
|||||||
if (!IS_WIN && serverProcess) {
|
if (!IS_WIN && serverProcess) {
|
||||||
serverPid = serverProcess.pid;
|
serverPid = serverProcess.pid;
|
||||||
fs.writeFileSync(PID_FILE, String(serverPid));
|
fs.writeFileSync(PID_FILE, String(serverPid));
|
||||||
|
mitmLastStartTime = Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
let startError = null;
|
let startError = null;
|
||||||
@@ -397,6 +450,8 @@ async function startServer(apiKey, sudoPassword) {
|
|||||||
serverProcess = null;
|
serverProcess = null;
|
||||||
serverPid = null;
|
serverPid = null;
|
||||||
try { fs.unlinkSync(PID_FILE); } catch { /* ignore */ }
|
try { fs.unlinkSync(PID_FILE); } catch { /* ignore */ }
|
||||||
|
// Auto-restart on unexpected exit
|
||||||
|
if (code !== 0 && !mitmIsRestarting) scheduleMitmRestart(apiKey);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,6 +480,9 @@ async function startServer(apiKey, sudoPassword) {
|
|||||||
* Stop MITM server — removes ALL tool DNS entries first, then kills server
|
* Stop MITM server — removes ALL tool DNS entries first, then kills server
|
||||||
*/
|
*/
|
||||||
async function stopServer(sudoPassword) {
|
async function stopServer(sudoPassword) {
|
||||||
|
// Prevent auto-restart from triggering on intentional stop
|
||||||
|
mitmIsRestarting = true;
|
||||||
|
mitmRestartCount = 0;
|
||||||
console.log("[MITM] Stopping server...");
|
console.log("[MITM] Stopping server...");
|
||||||
|
|
||||||
// Kill server process
|
// Kill server process
|
||||||
@@ -476,6 +534,7 @@ async function stopServer(sudoPassword) {
|
|||||||
|
|
||||||
try { fs.unlinkSync(PID_FILE); } catch { /* ignore */ }
|
try { fs.unlinkSync(PID_FILE); } catch { /* ignore */ }
|
||||||
await saveMitmSettings(false, null);
|
await saveMitmSettings(false, null);
|
||||||
|
mitmIsRestarting = false;
|
||||||
|
|
||||||
return { running: false, pid: null };
|
return { running: false, pid: null };
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -85,7 +85,7 @@ const ANTIGRAVITY_URL_PATTERNS = [":generateContent", ":streamGenerateContent"];
|
|||||||
// Copilot: OpenAI-compatible + Anthropic endpoints
|
// Copilot: OpenAI-compatible + Anthropic endpoints
|
||||||
const COPILOT_URL_PATTERNS = ["/chat/completions", "/v1/messages", "/responses"];
|
const COPILOT_URL_PATTERNS = ["/chat/completions", "/v1/messages", "/responses"];
|
||||||
|
|
||||||
const LOG_DIR = path.join(__dirname, "../../logs/mitm");
|
const LOG_DIR = path.join(DATA_DIR, "logs", "mitm");
|
||||||
if (ENABLE_FILE_LOG && !fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true });
|
if (ENABLE_FILE_LOG && !fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true });
|
||||||
|
|
||||||
function saveRequestLog(url, bodyBuffer) {
|
function saveRequestLog(url, bodyBuffer) {
|
||||||
|
|||||||
@@ -139,17 +139,32 @@ export default function ModelSelectModal({
|
|||||||
hasModels: nodeModels.length > 0,
|
hasModels: nodeModels.length > 0,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const models = getModelsByProviderId(providerId);
|
const hardcodedModels = getModelsByProviderId(providerId);
|
||||||
if (models.length > 0) {
|
const hardcodedIds = new Set(hardcodedModels.map((m) => m.id));
|
||||||
|
|
||||||
|
// Custom models user added via "Add Model" button (alias === modelId pattern)
|
||||||
|
const customModels = Object.entries(modelAliases)
|
||||||
|
.filter(([aliasName, fullModel]) =>
|
||||||
|
fullModel.startsWith(`${alias}/`) &&
|
||||||
|
aliasName === fullModel.replace(`${alias}/`, "") &&
|
||||||
|
!hardcodedIds.has(fullModel.replace(`${alias}/`, ""))
|
||||||
|
)
|
||||||
|
.map(([, fullModel]) => {
|
||||||
|
const modelId = fullModel.replace(`${alias}/`, "");
|
||||||
|
return { id: modelId, name: modelId, value: fullModel, isCustom: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
const allModels = [
|
||||||
|
...hardcodedModels.map((m) => ({ id: m.id, name: m.name, value: `${alias}/${m.id}` })),
|
||||||
|
...customModels,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (allModels.length > 0) {
|
||||||
groups[providerId] = {
|
groups[providerId] = {
|
||||||
name: providerInfo.name,
|
name: providerInfo.name,
|
||||||
alias: alias,
|
alias: alias,
|
||||||
color: providerInfo.color,
|
color: providerInfo.color,
|
||||||
models: models.map((m) => ({
|
models: allModels,
|
||||||
id: m.id,
|
|
||||||
name: m.name,
|
|
||||||
value: `${alias}/${m.id}`,
|
|
||||||
})),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,6 +314,11 @@ export default function ModelSelectModal({
|
|||||||
<span className="material-symbols-outlined text-[11px]">edit</span>
|
<span className="material-symbols-outlined text-[11px]">edit</span>
|
||||||
{model.name}
|
{model.name}
|
||||||
</span>
|
</span>
|
||||||
|
) : model.isCustom ? (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
{model.name}
|
||||||
|
<span className="text-[9px] opacity-60 font-normal">custom</span>
|
||||||
|
</span>
|
||||||
) : model.name}
|
) : model.name}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
export default function Tooltip({ text, children, position = "top" }) {
|
||||||
|
const posClass = {
|
||||||
|
top: "bottom-full left-1/2 -translate-x-1/2 mb-1.5",
|
||||||
|
bottom: "top-full left-1/2 -translate-x-1/2 mt-1.5",
|
||||||
|
left: "right-full top-1/2 -translate-y-1/2 mr-1.5",
|
||||||
|
right: "left-full top-1/2 -translate-y-1/2 ml-1.5",
|
||||||
|
}[position];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative inline-flex group">
|
||||||
|
{children}
|
||||||
|
<div className={`pointer-events-none absolute ${posClass} z-50 w-max max-w-56 rounded px-2 py-1 text-[11px] leading-snug bg-gray-900 text-white opacity-0 group-hover:opacity-100 transition-opacity duration-150 whitespace-normal`}>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ export { default as KiroSocialOAuthModal } from "./KiroSocialOAuthModal";
|
|||||||
export { default as CursorAuthModal } from "./CursorAuthModal";
|
export { default as CursorAuthModal } from "./CursorAuthModal";
|
||||||
export { default as IFlowCookieModal } from "./IFlowCookieModal";
|
export { default as IFlowCookieModal } from "./IFlowCookieModal";
|
||||||
export { default as SegmentedControl } from "./SegmentedControl";
|
export { default as SegmentedControl } from "./SegmentedControl";
|
||||||
|
export { default as Tooltip } from "./Tooltip";
|
||||||
|
|
||||||
// Layouts
|
// Layouts
|
||||||
export * from "./layouts";
|
export * from "./layouts";
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ export const PROVIDER_ENDPOINTS = {
|
|||||||
openai: "https://api.openai.com/v1/chat/completions",
|
openai: "https://api.openai.com/v1/chat/completions",
|
||||||
anthropic: "https://api.anthropic.com/v1/messages",
|
anthropic: "https://api.anthropic.com/v1/messages",
|
||||||
gemini: "https://generativelanguage.googleapis.com/v1beta/models",
|
gemini: "https://generativelanguage.googleapis.com/v1beta/models",
|
||||||
|
ollama: "https://ollama.com/api/chat",
|
||||||
|
"ollama-local": "http://localhost:11434/api/chat",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Re-export from providers.js for backward compatibility
|
// Re-export from providers.js for backward compatibility
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const OAUTH_PROVIDERS = {
|
|||||||
codex: { id: "codex", alias: "cx", name: "OpenAI Codex", icon: "code", color: "#3B82F6" },
|
codex: { id: "codex", alias: "cx", name: "OpenAI Codex", icon: "code", color: "#3B82F6" },
|
||||||
github: { id: "github", alias: "gh", name: "GitHub Copilot", icon: "code", color: "#333333" },
|
github: { id: "github", alias: "gh", name: "GitHub Copilot", icon: "code", color: "#333333" },
|
||||||
cursor: { id: "cursor", alias: "cu", name: "Cursor IDE", icon: "edit_note", color: "#00D4AA" },
|
cursor: { id: "cursor", alias: "cu", name: "Cursor IDE", icon: "edit_note", color: "#00D4AA" },
|
||||||
|
// "kimi-coding": { id: "kimi-coding", alias: "kmc", name: "Kimi Coding", icon: "psychology", color: "#1E40AF", textIcon: "KC" },
|
||||||
kilocode: { id: "kilocode", alias: "kc", name: "Kilo Code", icon: "code", color: "#FF6B35", textIcon: "KC" },
|
kilocode: { id: "kilocode", alias: "kc", name: "Kilo Code", icon: "code", color: "#FF6B35", textIcon: "KC" },
|
||||||
cline: { id: "cline", alias: "cl", name: "Cline", icon: "smart_toy", color: "#5B9BD5", textIcon: "CL" },
|
cline: { id: "cline", alias: "cl", name: "Cline", icon: "smart_toy", color: "#5B9BD5", textIcon: "CL" },
|
||||||
};
|
};
|
||||||
@@ -47,7 +48,11 @@ export const APIKEY_PROVIDERS = {
|
|||||||
deepgram: { id: "deepgram", alias: "dg", name: "Deepgram", icon: "mic", color: "#13EF93", textIcon: "DG", website: "https://deepgram.com" },
|
deepgram: { id: "deepgram", alias: "dg", name: "Deepgram", icon: "mic", color: "#13EF93", textIcon: "DG", website: "https://deepgram.com" },
|
||||||
assemblyai: { id: "assemblyai", alias: "aai", name: "AssemblyAI", icon: "record_voice_over", color: "#0062FF", textIcon: "AA", website: "https://assemblyai.com" },
|
assemblyai: { id: "assemblyai", alias: "aai", name: "AssemblyAI", icon: "record_voice_over", color: "#0062FF", textIcon: "AA", website: "https://assemblyai.com" },
|
||||||
nanobanana: { id: "nanobanana", alias: "nb", name: "NanoBanana", icon: "image", color: "#FFD700", textIcon: "NB", website: "https://nanobananaapi.ai" },
|
nanobanana: { id: "nanobanana", alias: "nb", name: "NanoBanana", icon: "image", color: "#FFD700", textIcon: "NB", website: "https://nanobananaapi.ai" },
|
||||||
chutes: { id: "chutes", alias: "ch", name: "Chutes AI", icon: "water_drop", color: "#5B6EF5", textIcon: "CH", website: "https://chutes.ai" },
|
chutes: { id: "chutes", alias: "ch", name: "Chutes AI", icon: "water_drop", color: "#ffffffff", textIcon: "CH", website: "https://chutes.ai" },
|
||||||
|
ollama: { id: "ollama", alias: "ollama", name: "Ollama Cloud", icon: "cloud", color: "#ffffffff", textIcon: "OL", website: "https://ollama.com" },
|
||||||
|
"ollama-local": { id: "ollama-local", alias: "ollama-local", name: "Ollama Local", icon: "cloud", color: "#ffffffff", textIcon: "OL", website: "https://ollama.com" },
|
||||||
|
vertex: { id: "vertex", alias: "vx", name: "Vertex AI", icon: "cloud", color: "#4285F4", textIcon: "VX", website: "https://cloud.google.com/vertex-ai" },
|
||||||
|
"vertex-partner": { id: "vertex-partner", alias: "vxp", name: "Vertex Partner", icon: "cloud", color: "#34A853", textIcon: "VP", website: "https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-partner-models" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-";
|
export const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-";
|
||||||
@@ -105,4 +110,11 @@ export const ID_TO_ALIAS = Object.values(AI_PROVIDERS).reduce((acc, p) => {
|
|||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
// Providers that support usage/quota API
|
// Providers that support usage/quota API
|
||||||
export const USAGE_SUPPORTED_PROVIDERS = ["antigravity", "kiro", "github", "codex"];
|
export const USAGE_SUPPORTED_PROVIDERS = [
|
||||||
|
"claude",
|
||||||
|
"antigravity",
|
||||||
|
"kiro",
|
||||||
|
"github",
|
||||||
|
"codex",
|
||||||
|
"kimi-coding",
|
||||||
|
];
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { getModelInfo, getComboModels } from "../services/model.js";
|
|||||||
import { handleChatCore } from "open-sse/handlers/chatCore.js";
|
import { handleChatCore } from "open-sse/handlers/chatCore.js";
|
||||||
import { errorResponse, unavailableResponse } from "open-sse/utils/error.js";
|
import { errorResponse, unavailableResponse } from "open-sse/utils/error.js";
|
||||||
import { handleComboChat } from "open-sse/services/combo.js";
|
import { handleComboChat } from "open-sse/services/combo.js";
|
||||||
import { HTTP_STATUS } from "open-sse/config/constants.js";
|
import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
|
||||||
import { detectFormatByEndpoint } from "open-sse/translator/formats.js";
|
import { detectFormatByEndpoint } from "open-sse/translator/formats.js";
|
||||||
import * as log from "../utils/logger.js";
|
import * as log from "../utils/logger.js";
|
||||||
import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js";
|
import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js";
|
||||||
@@ -111,7 +111,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
|
|||||||
return handleComboChat({
|
return handleComboChat({
|
||||||
body,
|
body,
|
||||||
models: comboModels,
|
models: comboModels,
|
||||||
handleSingleModel: (b, m) => handleSingleModelChat(b, m, clientRawRequest, request, apiKey, forceSourceFormat),
|
handleSingleModel: (b, m) => handleSingleModelChat(b, m, clientRawRequest, request, apiKey),
|
||||||
log
|
log
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -132,12 +132,12 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
|
|||||||
const userAgent = request?.headers?.get("user-agent") || "";
|
const userAgent = request?.headers?.get("user-agent") || "";
|
||||||
|
|
||||||
// Try with available accounts (fallback on errors)
|
// Try with available accounts (fallback on errors)
|
||||||
let excludeConnectionId = null;
|
const excludeConnectionIds = new Set();
|
||||||
let lastError = null;
|
let lastError = null;
|
||||||
let lastStatus = null;
|
let lastStatus = null;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const credentials = await getProviderCredentials(provider, excludeConnectionId, model);
|
const credentials = await getProviderCredentials(provider, excludeConnectionIds, model);
|
||||||
|
|
||||||
// All accounts unavailable
|
// All accounts unavailable
|
||||||
if (!credentials || credentials.allRateLimited) {
|
if (!credentials || credentials.allRateLimited) {
|
||||||
@@ -147,7 +147,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
|
|||||||
log.warn("CHAT", `[${provider}/${model}] ${errorMsg} (${credentials.retryAfterHuman})`);
|
log.warn("CHAT", `[${provider}/${model}] ${errorMsg} (${credentials.retryAfterHuman})`);
|
||||||
return unavailableResponse(status, `[${provider}/${model}] ${errorMsg}`, credentials.retryAfter, credentials.retryAfterHuman);
|
return unavailableResponse(status, `[${provider}/${model}] ${errorMsg}`, credentials.retryAfter, credentials.retryAfterHuman);
|
||||||
}
|
}
|
||||||
if (!excludeConnectionId) {
|
if (excludeConnectionIds.size === 0) {
|
||||||
log.error("AUTH", `No credentials for provider: ${provider}`);
|
log.error("AUTH", `No credentials for provider: ${provider}`);
|
||||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`);
|
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`);
|
||||||
}
|
}
|
||||||
@@ -156,8 +156,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Log account selection
|
// Log account selection
|
||||||
const accountId = credentials.connectionId.slice(0, 8);
|
log.info("AUTH", `\x1b[32mUsing ${provider} account: ${credentials.connectionName}\x1b[0m`);
|
||||||
log.info("AUTH", `Using ${provider} account: ${accountId}...`);
|
|
||||||
|
|
||||||
const refreshedCredentials = await checkAndRefreshToken(provider, credentials);
|
const refreshedCredentials = await checkAndRefreshToken(provider, credentials);
|
||||||
|
|
||||||
@@ -172,6 +171,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use shared chatCore
|
// Use shared chatCore
|
||||||
|
const chatSettings = await getSettings();
|
||||||
const result = await handleChatCore({
|
const result = await handleChatCore({
|
||||||
body: { ...body, model: `${provider}/${model}` },
|
body: { ...body, model: `${provider}/${model}` },
|
||||||
modelInfo: { provider, model },
|
modelInfo: { provider, model },
|
||||||
@@ -181,6 +181,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
|
|||||||
connectionId: credentials.connectionId,
|
connectionId: credentials.connectionId,
|
||||||
userAgent,
|
userAgent,
|
||||||
apiKey,
|
apiKey,
|
||||||
|
ccFilterNaming: !!chatSettings.ccFilterNaming,
|
||||||
// Detect source format by endpoint + body
|
// Detect source format by endpoint + body
|
||||||
sourceFormatOverride: request?.url ? detectFormatByEndpoint(new URL(request.url).pathname, body) : null,
|
sourceFormatOverride: request?.url ? detectFormatByEndpoint(new URL(request.url).pathname, body) : null,
|
||||||
onCredentialsRefreshed: async (newCreds) => {
|
onCredentialsRefreshed: async (newCreds) => {
|
||||||
@@ -202,8 +203,8 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
|
|||||||
const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model);
|
const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model);
|
||||||
|
|
||||||
if (shouldFallback) {
|
if (shouldFallback) {
|
||||||
log.warn("AUTH", `Account ${accountId}... unavailable (${result.status}), trying fallback`);
|
log.warn("AUTH", `Account ${credentials.connectionName} unavailable (${result.status}), trying fallback`);
|
||||||
excludeConnectionId = credentials.connectionId;
|
excludeConnectionIds.add(credentials.connectionId);
|
||||||
lastError = result.error;
|
lastError = result.error;
|
||||||
lastStatus = result.status;
|
lastStatus = result.status;
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { getSettings } from "@/lib/localDb";
|
|||||||
import { getModelInfo } from "../services/model.js";
|
import { getModelInfo } from "../services/model.js";
|
||||||
import { handleEmbeddingsCore } from "open-sse/handlers/embeddingsCore.js";
|
import { handleEmbeddingsCore } from "open-sse/handlers/embeddingsCore.js";
|
||||||
import { errorResponse, unavailableResponse } from "open-sse/utils/error.js";
|
import { errorResponse, unavailableResponse } from "open-sse/utils/error.js";
|
||||||
import { HTTP_STATUS } from "open-sse/config/constants.js";
|
import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
|
||||||
import * as log from "../utils/logger.js";
|
import * as log from "../utils/logger.js";
|
||||||
import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js";
|
import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js";
|
||||||
|
|
||||||
@@ -80,12 +80,12 @@ export async function handleEmbeddings(request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Credential + fallback loop (mirrors handleChat)
|
// Credential + fallback loop (mirrors handleChat)
|
||||||
let excludeConnectionId = null;
|
const excludeConnectionIds = new Set();
|
||||||
let lastError = null;
|
let lastError = null;
|
||||||
let lastStatus = null;
|
let lastStatus = null;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const credentials = await getProviderCredentials(provider, excludeConnectionId, model);
|
const credentials = await getProviderCredentials(provider, excludeConnectionIds, model);
|
||||||
|
|
||||||
// All accounts unavailable
|
// All accounts unavailable
|
||||||
if (!credentials || credentials.allRateLimited) {
|
if (!credentials || credentials.allRateLimited) {
|
||||||
@@ -95,7 +95,7 @@ export async function handleEmbeddings(request) {
|
|||||||
log.warn("EMBEDDINGS", `[${provider}/${model}] ${errorMsg} (${credentials.retryAfterHuman})`);
|
log.warn("EMBEDDINGS", `[${provider}/${model}] ${errorMsg} (${credentials.retryAfterHuman})`);
|
||||||
return unavailableResponse(status, `[${provider}/${model}] ${errorMsg}`, credentials.retryAfter, credentials.retryAfterHuman);
|
return unavailableResponse(status, `[${provider}/${model}] ${errorMsg}`, credentials.retryAfter, credentials.retryAfterHuman);
|
||||||
}
|
}
|
||||||
if (!excludeConnectionId) {
|
if (excludeConnectionIds.size === 0) {
|
||||||
log.error("AUTH", `No credentials for provider: ${provider}`);
|
log.error("AUTH", `No credentials for provider: ${provider}`);
|
||||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`);
|
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`);
|
||||||
}
|
}
|
||||||
@@ -103,8 +103,7 @@ export async function handleEmbeddings(request) {
|
|||||||
return errorResponse(lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE, lastError || "All accounts unavailable");
|
return errorResponse(lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE, lastError || "All accounts unavailable");
|
||||||
}
|
}
|
||||||
|
|
||||||
const accountId = credentials.connectionId.slice(0, 8);
|
log.info("AUTH", `\x1b[32mUsing ${provider} account: ${credentials.connectionName}\x1b[0m`);
|
||||||
log.info("AUTH", `Using ${provider} account: ${accountId}...`);
|
|
||||||
|
|
||||||
const refreshedCredentials = await checkAndRefreshToken(provider, credentials);
|
const refreshedCredentials = await checkAndRefreshToken(provider, credentials);
|
||||||
|
|
||||||
@@ -131,8 +130,8 @@ export async function handleEmbeddings(request) {
|
|||||||
const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model);
|
const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model);
|
||||||
|
|
||||||
if (shouldFallback) {
|
if (shouldFallback) {
|
||||||
log.warn("AUTH", `Account ${accountId}... unavailable (${result.status}), trying fallback`);
|
log.warn("AUTH", `Account ${credentials.connectionName} unavailable (${result.status}), trying fallback`);
|
||||||
excludeConnectionId = credentials.connectionId;
|
excludeConnectionIds.add(credentials.connectionId);
|
||||||
lastError = result.error;
|
lastError = result.error;
|
||||||
lastStatus = result.status;
|
lastStatus = result.status;
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -11,10 +11,14 @@ let selectionMutex = Promise.resolve();
|
|||||||
* Get provider credentials from localDb
|
* Get provider credentials from localDb
|
||||||
* Filters out unavailable accounts and returns the selected account based on strategy
|
* Filters out unavailable accounts and returns the selected account based on strategy
|
||||||
* @param {string} provider - Provider name
|
* @param {string} provider - Provider name
|
||||||
* @param {string|null} excludeConnectionId - Connection ID to exclude (for retry with next account)
|
* @param {Set<string>|string|null} excludeConnectionIds - Connection ID(s) to exclude (for retry with next account)
|
||||||
* @param {string|null} model - Model name for per-model rate limit filtering
|
* @param {string|null} model - Model name for per-model rate limit filtering
|
||||||
*/
|
*/
|
||||||
export async function getProviderCredentials(provider, excludeConnectionId = null, model = null) {
|
export async function getProviderCredentials(provider, excludeConnectionIds = null, model = null) {
|
||||||
|
// Normalize to Set for consistent handling
|
||||||
|
const excludeSet = excludeConnectionIds instanceof Set
|
||||||
|
? excludeConnectionIds
|
||||||
|
: (excludeConnectionIds ? new Set([excludeConnectionIds]) : new Set());
|
||||||
// Acquire mutex to prevent race conditions
|
// Acquire mutex to prevent race conditions
|
||||||
const currentMutex = selectionMutex;
|
const currentMutex = selectionMutex;
|
||||||
let resolveMutex;
|
let resolveMutex;
|
||||||
@@ -27,7 +31,7 @@ export async function getProviderCredentials(provider, excludeConnectionId = nul
|
|||||||
const providerId = resolveProviderId(provider);
|
const providerId = resolveProviderId(provider);
|
||||||
|
|
||||||
const connections = await getProviderConnections({ provider: providerId, isActive: true });
|
const connections = await getProviderConnections({ provider: providerId, isActive: true });
|
||||||
log.debug("AUTH", `${provider} | total connections: ${connections.length}, excludeId: ${excludeConnectionId || "none"}, model: ${model || "any"}`);
|
log.debug("AUTH", `${provider} | total connections: ${connections.length}, excludeIds: ${excludeSet.size > 0 ? [...excludeSet].join(",") : "none"}, model: ${model || "any"}`);
|
||||||
|
|
||||||
if (connections.length === 0) {
|
if (connections.length === 0) {
|
||||||
log.warn("AUTH", `No credentials for ${provider}`);
|
log.warn("AUTH", `No credentials for ${provider}`);
|
||||||
@@ -36,14 +40,14 @@ export async function getProviderCredentials(provider, excludeConnectionId = nul
|
|||||||
|
|
||||||
// Filter out model-locked and excluded connections
|
// Filter out model-locked and excluded connections
|
||||||
const availableConnections = connections.filter(c => {
|
const availableConnections = connections.filter(c => {
|
||||||
if (excludeConnectionId && c.id === excludeConnectionId) return false;
|
if (excludeSet.has(c.id)) return false;
|
||||||
if (isModelLockActive(c, model)) return false;
|
if (isModelLockActive(c, model)) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
log.debug("AUTH", `${provider} | available: ${availableConnections.length}/${connections.length}`);
|
log.debug("AUTH", `${provider} | available: ${availableConnections.length}/${connections.length}`);
|
||||||
connections.forEach(c => {
|
connections.forEach(c => {
|
||||||
const excluded = excludeConnectionId && c.id === excludeConnectionId;
|
const excluded = excludeSet.has(c.id);
|
||||||
const locked = isModelLockActive(c, model);
|
const locked = isModelLockActive(c, model);
|
||||||
if (excluded || locked) {
|
if (excluded || locked) {
|
||||||
const lockUntil = getEarliestModelLockUntil(c);
|
const lockUntil = getEarliestModelLockUntil(c);
|
||||||
@@ -72,11 +76,13 @@ export async function getProviderCredentials(provider, excludeConnectionId = nul
|
|||||||
}
|
}
|
||||||
|
|
||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
const strategy = settings.fallbackStrategy || "fill-first";
|
// Per-provider strategy overrides global setting
|
||||||
|
const providerOverride = (settings.providerStrategies || {})[providerId] || {};
|
||||||
|
const strategy = providerOverride.fallbackStrategy || settings.fallbackStrategy || "fill-first";
|
||||||
|
|
||||||
let connection;
|
let connection;
|
||||||
if (strategy === "round-robin") {
|
if (strategy === "round-robin") {
|
||||||
const stickyLimit = settings.stickyRoundRobinLimit || 3;
|
const stickyLimit = providerOverride.stickyRoundRobinLimit || settings.stickyRoundRobinLimit || 3;
|
||||||
|
|
||||||
// Sort by lastUsed (most recent first) to find current candidate
|
// Sort by lastUsed (most recent first) to find current candidate
|
||||||
const byRecency = [...availableConnections].sort((a, b) => {
|
const byRecency = [...availableConnections].sort((a, b) => {
|
||||||
@@ -178,7 +184,8 @@ export async function markAccountUnavailable(connectionId, status, errorText, pr
|
|||||||
});
|
});
|
||||||
|
|
||||||
const lockKey = Object.keys(lockUpdate)[0];
|
const lockKey = Object.keys(lockUpdate)[0];
|
||||||
log.warn("AUTH", `${connectionId.slice(0, 8)} locked ${lockKey} for ${Math.round(cooldownMs / 1000)}s [${status}]`);
|
const connName = conn?.displayName || conn?.name || conn?.email || connectionId.slice(0, 8);
|
||||||
|
log.warn("AUTH", `${connName} locked ${lockKey} for ${Math.round(cooldownMs / 1000)}s [${status}]`);
|
||||||
|
|
||||||
if (provider && status && reason) {
|
if (provider && status && reason) {
|
||||||
console.error(`❌ ${provider} [${status}]: ${reason}`);
|
console.error(`❌ ${provider} [${status}]: ${reason}`);
|
||||||
@@ -228,7 +235,8 @@ export async function clearAccountError(connectionId, currentConnection, model =
|
|||||||
}
|
}
|
||||||
|
|
||||||
await updateProviderConnection(connectionId, clearObj);
|
await updateProviderConnection(connectionId, clearObj);
|
||||||
log.info("AUTH", `Account ${connectionId.slice(0, 8)} cleared lock for model=${model || "__all"}`);
|
const connName = conn?.displayName || conn?.name || conn?.email || connectionId.slice(0, 8);
|
||||||
|
log.info("AUTH", `Account ${connName} cleared lock for model=${model || "__all"}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ import {
|
|||||||
getAccessToken as _getAccessToken,
|
getAccessToken as _getAccessToken,
|
||||||
refreshTokenByProvider as _refreshTokenByProvider,
|
refreshTokenByProvider as _refreshTokenByProvider,
|
||||||
formatProviderCredentials as _formatProviderCredentials,
|
formatProviderCredentials as _formatProviderCredentials,
|
||||||
getAllAccessTokens as _getAllAccessTokens
|
getAllAccessTokens as _getAllAccessTokens,
|
||||||
|
refreshKiroToken as _refreshKiroToken
|
||||||
} from "open-sse/services/tokenRefresh.js";
|
} from "open-sse/services/tokenRefresh.js";
|
||||||
|
|
||||||
export const TOKEN_EXPIRY_BUFFER_MS = BUFFER_MS;
|
export const TOKEN_EXPIRY_BUFFER_MS = BUFFER_MS;
|
||||||
@@ -50,6 +51,9 @@ export const refreshGitHubToken = (refreshToken) =>
|
|||||||
export const refreshCopilotToken = (githubAccessToken) =>
|
export const refreshCopilotToken = (githubAccessToken) =>
|
||||||
_refreshCopilotToken(githubAccessToken, log);
|
_refreshCopilotToken(githubAccessToken, log);
|
||||||
|
|
||||||
|
export const refreshKiroToken = (refreshToken, providerSpecificData) =>
|
||||||
|
_refreshKiroToken(refreshToken, providerSpecificData, log);
|
||||||
|
|
||||||
export const getAccessToken = (provider, credentials) =>
|
export const getAccessToken = (provider, credentials) =>
|
||||||
_getAccessToken(provider, credentials, log);
|
_getAccessToken(provider, credentials, log);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for open-sse/translator/request/openai-to-claude.js
|
||||||
|
*
|
||||||
|
* Tests cover:
|
||||||
|
* - openaiToClaudeRequest() - OpenAI to Claude request translation
|
||||||
|
* - Response format handling (json_schema, json_object)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { openaiToClaudeRequest } from "../../open-sse/translator/request/openai-to-claude.js";
|
||||||
|
|
||||||
|
describe("openaiToClaudeRequest", () => {
|
||||||
|
describe("response_format handling", () => {
|
||||||
|
it("should inject JSON schema instructions for json_schema type", () => {
|
||||||
|
const body = {
|
||||||
|
messages: [{ role: "user", content: "What is 2+2?" }],
|
||||||
|
response_format: {
|
||||||
|
type: "json_schema",
|
||||||
|
json_schema: {
|
||||||
|
name: "math_response",
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
answer: { type: "number" },
|
||||||
|
explanation: { type: "string" }
|
||||||
|
},
|
||||||
|
required: ["answer", "explanation"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = openaiToClaudeRequest("claude-sonnet-4.5", body, false);
|
||||||
|
|
||||||
|
// Should have system array with instructions
|
||||||
|
expect(result.system).toBeDefined();
|
||||||
|
expect(Array.isArray(result.system)).toBe(true);
|
||||||
|
|
||||||
|
// Check that system prompt includes schema
|
||||||
|
const systemText = result.system
|
||||||
|
.filter(s => s.type === "text")
|
||||||
|
.map(s => s.text)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
expect(systemText).toContain("You must respond with valid JSON");
|
||||||
|
expect(systemText).toContain("\"answer\"");
|
||||||
|
expect(systemText).toContain("\"explanation\"");
|
||||||
|
expect(systemText).toContain("Respond ONLY with the JSON object");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should inject basic JSON instructions for json_object type", () => {
|
||||||
|
const body = {
|
||||||
|
messages: [{ role: "user", content: "Give me a JSON object" }],
|
||||||
|
response_format: {
|
||||||
|
type: "json_object"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = openaiToClaudeRequest("claude-sonnet-4.5", body, false);
|
||||||
|
|
||||||
|
// Should have system array with instructions
|
||||||
|
expect(result.system).toBeDefined();
|
||||||
|
expect(Array.isArray(result.system)).toBe(true);
|
||||||
|
|
||||||
|
const systemText = result.system
|
||||||
|
.filter(s => s.type === "text")
|
||||||
|
.map(s => s.text)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
expect(systemText).toContain("You must respond with valid JSON");
|
||||||
|
expect(systemText).toContain("Respond ONLY with a JSON object");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not modify system prompt when response_format is missing", () => {
|
||||||
|
const body = {
|
||||||
|
messages: [{ role: "user", content: "Hello" }]
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = openaiToClaudeRequest("claude-sonnet-4.5", body, false);
|
||||||
|
|
||||||
|
// Should have system but without JSON instructions
|
||||||
|
expect(result.system).toBeDefined();
|
||||||
|
|
||||||
|
const systemText = result.system
|
||||||
|
.filter(s => s.type === "text")
|
||||||
|
.map(s => s.text)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
// Should NOT contain JSON-specific instructions
|
||||||
|
expect(systemText).not.toContain("You must respond with valid JSON");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should preserve existing system messages when adding response_format", () => {
|
||||||
|
const body = {
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: "You are a helpful math tutor." },
|
||||||
|
{ role: "user", content: "What is 2+2?" }
|
||||||
|
],
|
||||||
|
response_format: {
|
||||||
|
type: "json_schema",
|
||||||
|
json_schema: {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
result: { type: "number" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = openaiToClaudeRequest("claude-sonnet-4.5", body, false);
|
||||||
|
|
||||||
|
// Should preserve original system message
|
||||||
|
const systemText = result.system
|
||||||
|
.filter(s => s.type === "text")
|
||||||
|
.map(s => s.text)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
expect(systemText).toContain("You are a helpful math tutor");
|
||||||
|
expect(systemText).toContain("You must respond with valid JSON");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
import { FORMATS } from "../../open-sse/translator/formats.js";
|
||||||
|
import { translateRequest } from "../../open-sse/translator/index.js";
|
||||||
|
import { claudeToOpenAIRequest } from "../../open-sse/translator/request/claude-to-openai.js";
|
||||||
|
import { filterToOpenAIFormat } from "../../open-sse/translator/helpers/openaiHelper.js";
|
||||||
|
import { parseSSELine } from "../../open-sse/utils/streamHelpers.js";
|
||||||
|
|
||||||
|
describe("request normalization", () => {
|
||||||
|
it("claudeToOpenAIRequest flattens text-only content arrays into string", () => {
|
||||||
|
const body = {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "hi" },
|
||||||
|
{ type: "text", text: "there" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = claudeToOpenAIRequest("gpt-oss:120b", body, true);
|
||||||
|
expect(result.messages[0].content).toBe("hi\nthere");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("claudeToOpenAIRequest preserves multimodal arrays", () => {
|
||||||
|
const body = {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "describe" },
|
||||||
|
{
|
||||||
|
type: "image",
|
||||||
|
source: {
|
||||||
|
type: "base64",
|
||||||
|
media_type: "image/png",
|
||||||
|
data: "ZmFrZQ==",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = claudeToOpenAIRequest("gpt-4o", body, true);
|
||||||
|
expect(Array.isArray(result.messages[0].content)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filterToOpenAIFormat flattens text-only arrays to string", () => {
|
||||||
|
const body = {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "a" },
|
||||||
|
{ type: "text", text: "b" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = filterToOpenAIFormat(JSON.parse(JSON.stringify(body)));
|
||||||
|
expect(result.messages[0].content).toBe("a\nb");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("translateRequest keeps /v1/messages Claude->OpenAI text payloads string-safe", () => {
|
||||||
|
const body = {
|
||||||
|
model: "ollama/gpt-oss:120b",
|
||||||
|
system: [{ type: "text", text: "You are helpful." }],
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "hello" },
|
||||||
|
{ type: "text", text: "world" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
stream: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = translateRequest(
|
||||||
|
FORMATS.CLAUDE,
|
||||||
|
FORMATS.OPENAI,
|
||||||
|
"gpt-oss:120b",
|
||||||
|
JSON.parse(JSON.stringify(body)),
|
||||||
|
true,
|
||||||
|
null,
|
||||||
|
"ollama",
|
||||||
|
);
|
||||||
|
|
||||||
|
const userMessage = result.messages.find((m) => m.role === "user");
|
||||||
|
expect(typeof userMessage.content).toBe("string");
|
||||||
|
expect(userMessage.content).toBe("hello\nworld");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parseSSELine supports provider raw NDJSON stream lines", () => {
|
||||||
|
const raw = JSON.stringify({
|
||||||
|
model: "gpt-oss:120b",
|
||||||
|
message: { role: "assistant", content: "hello" },
|
||||||
|
done: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = parseSSELine(raw);
|
||||||
|
expect(parsed).toEqual({
|
||||||
|
model: "gpt-oss:120b",
|
||||||
|
message: { role: "assistant", content: "hello" },
|
||||||
|
done: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parseSSELine still supports SSE data lines", () => {
|
||||||
|
const parsed = parseSSELine('data: {"choices":[{"delta":{"content":"hi"}}]}');
|
||||||
|
expect(parsed.choices[0].delta.content).toBe("hi");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user