Compare commits

...

14 Commits

Author SHA1 Message Date
diegosouzapw
0ea73bd527 chore(release): bump version to 3.0.0-rc.13 2026-03-23 15:39:11 -03:00
diegosouzapw
b2f0820560 fix(#549): resolve real API key from keyId in codex/droid/kilo settings
CLI settings routes (codex-settings, droid-settings, kilo-settings) were
writing the masked API key string directly to config files when the
dashboard sent a keyId. Now resolves the real key from the database via
getApiKeyById() before writing, matching the pattern already implemented
in claude-settings, openclaw-settings, and cline-settings.

Closes #549
2026-03-23 15:31:34 -03:00
diegosouzapw
7ad5d42982 release: v3.0.0-rc.12 — merge PRs #542, #544, #546, #555 + TDZ fix + build fixes
Community PRs:
- #546: fix(cli): --version returning unknown on Windows
- #555: fix(sse): centralized resolveDataDir() for path resolution
- #544: fix(cli): secure CLI tool detection via known installation paths
- #542: fix(ui): light mode contrast — missing CSS theme variables

Additional:
- Fix TDZ error in cliRuntime.ts (validateEnvPath before getExpectedParentPaths)
- Add pino/pino-pretty to serverExternalPackages for build stability
- 905 tests passing
2026-03-23 15:11:18 -03:00
diegosouzapw
3912734498 fix: cherry-pick PR #542 (light mode contrast) + fix TDZ in cliRuntime.ts
- Add missing CSS theme variables (bg-primary, bg-subtle, text-primary)
- Fix hardcoded dark-mode-only colors with proper dark: variants
- Fix ReferenceError: move validateEnvPath before getExpectedParentPaths
2026-03-23 15:10:19 -03:00
k0valik
0fa3f9a057 fix: (cli) secure CLI tool detection via known installation paths (Win… (#544)
fix(cli): secure CLI tool detection via known installation paths with security hardening — symlink validation, file-type checks, size bounds, minimal env in healthcheck for 8 CLI tools
2026-03-23 15:04:14 -03:00
k0valik
0fbabdcf25 fix(sse): use centralized resolveDataDir() for path resolution (#555)
fix(sse): use centralized resolveDataDir() for path resolution in credentialLoader, autoCombo persistence, responsesTransformer, and requestLogger
2026-03-23 15:04:03 -03:00
k0valik
67b7ae98a6 fix(cli): resolve --version returning 'unknown' on Windows (#546)
fix(cli): resolve --version returning 'unknown' on Windows by using JSON.parse(readFileSync) instead of ESM import with { type: 'json' }
2026-03-23 15:03:51 -03:00
diegosouzapw
0f703c95dd fix(build): add pino and pino-pretty to serverExternalPackages 2026-03-23 11:19:53 -03:00
diegosouzapw
c34b3f41bd feat: Add requested model to logs, enhance background task detection, and introduce AI SDK compatibility utilities. 2026-03-23 11:08:14 -03:00
diegosouzapw
e003b17280 fix(build): add webpack IgnorePlugin for thread-stream test files; exclude compiled app/ dir from git
- thread-stream test fixtures (intentionally malformed) were being picked
  up by Turbopack during production build, causing 111 compile errors
- IgnorePlugin excludes /test/ within thread-stream context
- thread-stream added to serverExternalPackages to prevent bundling
- /app removed: it is a stale npm-package prebuild artifact, not source code
2026-03-23 09:50:21 -03:00
diegosouzapw
e003d58c60 fix(types): cast providerSpecificData.validationModelId to string in EditConnectionModal 2026-03-23 09:23:34 -03:00
diegosouzapw
0546d06c0a fix(types): cast extracted usage to Record<string,number> in stream.ts to resolve TS property errors
Also fix syntax error in openai-to-claude-strip-empty.test.mjs (tool/assistant messages were incorrectly nested)
2026-03-23 09:21:03 -03:00
diegosouzapw
5337111990 chore(release): bump version to 3.0.0-rc.10 2026-03-23 08:35:43 -03:00
diegosouzapw
bb06f8eb0c fix(deps): downgrade Next.js to 16.0.10 to fix turbopack hashing regression
Closes #509, #508

Docs: added rc.8 and rc.9 sprint summary to CHANGELOG.md
2026-03-23 08:20:54 -03:00
103 changed files with 4532 additions and 454 deletions

3
.gitignore vendored
View File

@@ -131,3 +131,6 @@ vscode-extension/
*.sqlite-shm
*.sqlite-wal
*.sqlite-journal
# Compiled npm-package build artifact (not source, should not be in git)
/app

View File

@@ -6,6 +6,74 @@
---
## [3.0.0-rc.13] — 2026-03-23
### 🔧 Bug Fixes
- **config:** resolve real API key from `keyId` in CLI settings routes (`codex-settings`, `droid-settings`, `kilo-settings`) to prevent writing masked strings (#549)
---
## [3.0.0-rc.12] — 2026-03-23
### 🔀 Community PRs Merged
| PR | Author | Summary |
| -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **#546** | @k0valik | fix(cli): `--version` returning `unknown` on Windows — use `JSON.parse(readFileSync)` instead of ESM import |
| **#555** | @k0valik | fix(sse): centralized `resolveDataDir()` for path resolution in credentials, autoCombo, responses logger, and request logger |
| **#544** | @k0valik | fix(cli): secure CLI tool detection via known installation paths (8 tools) with symlink validation, file-type checks, size bounds, minimal env in healthcheck |
| **#542** | @rdself | fix(ui): improve light mode contrast — add missing CSS theme variables (`bg-primary`, `bg-subtle`, `text-primary`) and fix dark-only colors in log detail |
### 🔧 Bug Fixes
- **TDZ fix in `cliRuntime.ts`** — `validateEnvPath` was used before initialization at module startup by `getExpectedParentPaths()`. Reordered declarations to fix `ReferenceError`.
- **Build fixes** — Added `pino` and `pino-pretty` to `serverExternalPackages` to prevent Turbopack from breaking Pino's internal worker loading.
### 🧪 Tests
- Test suite: **905 tests, 0 failures**
---
## [3.0.0-rc.10] — 2026-03-23
### 🔧 Bug Fixes
- **#509 / #508** — Electron build regression: downgraded Next.js from `16.1.x` to `16.0.10` to eliminate Turbopack module-hashing instability that caused blank screens in the Electron desktop bundle.
- **Unit test fixes** — Corrected two stale test assertions (`nanobanana-image-handler` aspect ratio/resolution, `thinking-budget` Gemini `thinkingConfig` field mapping) that had drifted after recent implementation changes.
- **#541** — Responded to user feedback about installation complexity; no code changes required.
---
## [3.0.0-rc.9] — 2026-03-23
### ✨ New Features
- **T29** — Vertex AI SA JSON Executor: implemented using the `jose` library to handle JWT/Service Account auth, along with configurable regions in the UI and automatic partner model URL building.
- **T42** — Image generation aspect ratio mapping: created `sizeMapper` logic for generic OpenAI formats (`size`), added native `imagen3` handling, and updated NanoBanana endpoints to utilize mapped aspect ratios automatically.
- **T38** — Centralized model specifications: `modelSpecs.ts` created for limits and parameters per model.
### 🔧 Improvements
- **T40** — OpenCode CLI tools integration: native `opencode-zen` and `opencode-go` integration completed in earlier PR.
---
## [3.0.0-rc.8] — 2026-03-23
### 🔧 Bug Fixes & Improvements (Fallback, Quota & Budget)
- **T24** — `503` cooldown await fix + `406` mapping: mapped `406 Not Acceptable` to `503 Service Unavailable` with proper cooldown intervals.
- **T25** — Provider validation fallback: graceful fallback to standard validation models when a specific `validationModelId` is not present.
- **T36** — `403` vs `429` provider handling refinement: extracted into `errorClassifier.ts` to properly segregate hard permissions failures (`403`) from rate limits (`429`).
- **T39** — Endpoint Fallback for `fetchAvailableModels`: implemented a tri-tier mechanism (`/models` -> `/v1/models` -> local generic catalog) + `list_models_catalog` MCP tool updates to reflect `source` and `warning`.
- **T33** — Thinking level to budget conversion: translates qualitative thinking levels into precise budget allocations.
- **T41** — Background task auto redirect: routes heavy background evaluation tasks to flash/efficient models automatically.
- **T23** — Intelligent quota reset fallback: accurately extracts `x-ratelimit-reset` / `retry-after` header values or maps static cooldowns.
---
## [3.0.0-rc.7] — 2026-03-23 _(What's New vs v2.9.5 — will be released as v3.0.0)_
> **Upgrade from v2.9.5:** 16 issues resolved · 2 community PRs merged · 2 new providers · 7 new API endpoints · 3 new features · DB migration 008+009 · 832 tests passing · 15 sub2api gap improvements (T01T15 complete).

View File

@@ -116,10 +116,8 @@ if (args.includes("--help") || args.includes("-h")) {
if (args.includes("--version") || args.includes("-v")) {
try {
const pkg = await import(join(ROOT, "package.json"), {
with: { type: "json" },
});
console.log(pkg.default.version);
const { version } = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));
console.log(version);
} catch {
console.log("unknown");
}

View File

@@ -38,15 +38,20 @@ Content-Type: application/json
### Custom Headers
| Header | Direction | Description |
| ------------------------ | --------- | --------------------------------- |
| `X-OmniRoute-No-Cache` | Request | Set to `true` to bypass cache |
| `X-OmniRoute-Progress` | Request | Set to `true` for progress events |
| `Idempotency-Key` | Request | Dedup key (5s window) |
| `X-Request-Id` | Request | Alternative dedup key |
| `X-OmniRoute-Cache` | Response | `HIT` or `MISS` (non-streaming) |
| `X-OmniRoute-Idempotent` | Response | `true` if deduplicated |
| `X-OmniRoute-Progress` | Response | `enabled` if progress tracking on |
| Header | Direction | Description |
| ------------------------ | --------- | ------------------------------------------------ |
| `X-OmniRoute-No-Cache` | Request | Set to `true` to bypass cache |
| `X-OmniRoute-Progress` | Request | Set to `true` for progress events |
| `X-Session-Id` | Request | Sticky session key for external session affinity |
| `x_session_id` | Request | Underscore variant also accepted (direct HTTP) |
| `Idempotency-Key` | Request | Dedup key (5s window) |
| `X-Request-Id` | Request | Alternative dedup key |
| `X-OmniRoute-Cache` | Response | `HIT` or `MISS` (non-streaming) |
| `X-OmniRoute-Idempotent` | Response | `true` if deduplicated |
| `X-OmniRoute-Progress` | Response | `enabled` if progress tracking on |
| `X-OmniRoute-Session-Id` | Response | Effective session ID used by OmniRoute |
> Nginx note: if you rely on underscore headers (for example `x_session_id`), enable `underscores_in_headers on;`.
---

View File

@@ -578,6 +578,22 @@ Configure via **Dashboard → Settings → Routing**.
| **Least Used** | Routes to the account with the oldest `lastUsedAt` timestamp, distributing traffic evenly |
| **Cost Optimized** | Routes to the account with the lowest priority value, optimizing for lowest-cost providers |
#### External Sticky Session Header
For external session affinity (for example, Claude Code/Codex agents behind reverse proxies), send:
```http
X-Session-Id: your-session-key
```
OmniRoute also accepts `x_session_id` and returns the effective session key in `X-OmniRoute-Session-Id`.
If you use Nginx and send underscore-form headers, enable:
```nginx
underscores_in_headers on;
```
#### Wildcard Model Aliases
Create wildcard patterns to remap model names:

View File

@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: OmniRoute API
version: 3.0.0-rc.7
version: 3.0.0-rc.13
description: |
OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible
endpoint that routes requests to multiple AI providers with load balancing,

View File

@@ -13,6 +13,9 @@ const nextConfig = {
},
output: "standalone",
serverExternalPackages: [
"pino",
"pino-pretty",
"thread-stream",
"better-sqlite3",
"zod",
"child_process",
@@ -37,8 +40,16 @@ const nextConfig = {
images: {
unoptimized: true,
},
webpack: (config, { isServer }) => {
webpack: (config, { isServer, webpack }) => {
if (isServer) {
// Webpack IgnorePlugin: skip thread-stream test files that contain
// intentionally broken syntax/imports (they cause Turbopack build errors)
config.plugins.push(
new webpack.IgnorePlugin({
resourceRegExp: /\/test\//,
contextRegExp: /thread-stream/,
})
);
// ── Turbopack / Next.js 16 module-hash patch (#394, #396, #398) ────────
//
// Next.js 16 (with or without Turbopack) compiles the instrumentation hook

View File

@@ -16,6 +16,7 @@
import { readFileSync, existsSync } from "fs";
import { join } from "path";
import { resolveDataDir } from "../../src/lib/dataPaths";
// Fields that can be overridden per provider
const CREDENTIAL_FIELDS = ["clientId", "clientSecret", "tokenUrl", "authUrl", "refreshUrl"];
@@ -30,8 +31,7 @@ let cachedProviders = null;
* Priority: DATA_DIR env → ./data (project root)
*/
function resolveCredentialsPath() {
const dataDir = process.env.DATA_DIR || join(process.cwd(), "data");
return join(dataDir, "provider-credentials.json");
return join(resolveDataDir(), "provider-credentials.json");
}
/**
@@ -93,7 +93,11 @@ export function loadProviderCredentials(providers) {
`[CREDENTIALS] ${isReload ? "Reloaded" : "Loaded"} external credentials: ${overrideCount} field(s) from ${credPath}`
);
} catch (err) {
console.log(`[CREDENTIALS] Error reading credentials file: ${err.message}. Using defaults.`);
const reason =
err instanceof SyntaxError
? "Invalid JSON format"
: (err as NodeJS.ErrnoException).code || "read error";
console.log(`[CREDENTIALS] Error reading credentials file (${reason}). Using defaults.`);
}
cachedProviders = providers;

View File

@@ -6,6 +6,8 @@
* is auto-generated from this registry.
*/
import { platform, arch } from "os";
// ── Types ─────────────────────────────────────────────────────────────────
export interface RegistryModel {
@@ -96,6 +98,32 @@ const KIMI_CODING_SHARED = {
] as RegistryModel[],
} as const;
function mapStainlessOs() {
switch (platform()) {
case "darwin":
return "MacOS";
case "win32":
return "Windows";
case "linux":
return "Linux";
default:
return `Other::${platform()}`;
}
}
function mapStainlessArch() {
switch (arch()) {
case "x64":
return "x64";
case "arm64":
return "arm64";
case "ia32":
return "x86";
default:
return `other::${arch()}`;
}
}
// ── Registry ──────────────────────────────────────────────────────────────
export const REGISTRY: Record<string, RegistryEntry> = {
@@ -112,19 +140,19 @@ export const REGISTRY: Record<string, RegistryEntry> = {
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",
"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/1.0.83 (external, cli)",
"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.55.1",
"X-Stainless-Package-Version": "0.74.0",
"X-Stainless-Runtime": "node",
"X-Stainless-Lang": "js",
"X-Stainless-Arch": "arm64",
"X-Stainless-Os": "MacOS",
"X-Stainless-Timeout": "60",
"X-Stainless-Arch": mapStainlessArch(),
"X-Stainless-Os": mapStainlessOs(),
"X-Stainless-Timeout": "600",
},
oauth: {
clientIdEnv: "CLAUDE_OAUTH_CLIENT_ID",
@@ -159,9 +187,13 @@ export const REGISTRY: Record<string, RegistryEntry> = {
clientSecretDefault: "",
},
models: [
{ id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro High" },
{ id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro Low" },
{ id: "gemini-3.1-pro", name: "Gemini 3.1 Pro" },
{ id: "gemini-3-1-pro", name: "Gemini 3.1 Pro (Alt ID)" },
{ 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-pro", name: "Gemini 2.5 Pro" },
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
{ id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite" },
@@ -191,9 +223,13 @@ export const REGISTRY: Record<string, RegistryEntry> = {
clientSecretDefault: "",
},
models: [
{ id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro High" },
{ id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro Low" },
{ id: "gemini-3.1-pro", name: "Gemini 3.1 Pro" },
{ id: "gemini-3-1-pro", name: "Gemini 3.1 Pro (Alt ID)" },
{ 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-pro", name: "Gemini 2.5 Pro" },
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
{ id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite" },
@@ -322,7 +358,11 @@ export const REGISTRY: Record<string, RegistryEntry> = {
alias: "ag",
format: "antigravity",
executor: "antigravity",
baseUrls: ["https://daily-cloudcode-pa.googleapis.com", "https://cloudcode-pa.googleapis.com"],
baseUrls: [
"https://daily-cloudcode-pa.googleapis.com",
"https://daily-cloudcode-pa.sandbox.googleapis.com",
"https://cloudcode-pa.googleapis.com",
],
urlBuilder: (base, model, stream) => {
const path = stream
? "/v1internal:streamGenerateContent?alt=sse"
@@ -332,7 +372,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
authType: "oauth",
authHeader: "bearer",
headers: {
"User-Agent": "antigravity/1.104.0 darwin/arm64",
"User-Agent": `antigravity/1.107.0 ${platform()}/${arch()}`,
},
oauth: {
clientIdEnv: "ANTIGRAVITY_OAUTH_CLIENT_ID",
@@ -361,9 +401,9 @@ export const REGISTRY: Record<string, RegistryEntry> = {
authHeader: "bearer",
headers: {
"copilot-integration-id": "vscode-chat",
"editor-version": "vscode/1.107.1",
"editor-plugin-version": "copilot-chat/0.26.7",
"user-agent": "GitHubCopilotChat/0.26.7",
"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",
@@ -746,6 +786,10 @@ export const REGISTRY: Record<string, RegistryEntry> = {
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
},
models: [
// T12/T28: MiniMax default upgraded from M2.5 to M2.7
{ id: "minimax-m2.7", name: "MiniMax M2.7" },
{ id: "MiniMax-M2.7", name: "MiniMax M2.7 (Legacy Alias)" },
{ id: "minimax-m2.7-highspeed", name: "MiniMax M2.7 Highspeed" },
{ id: "minimax-m2.5", name: "MiniMax M2.5" },
{ id: "MiniMax-M2.5", name: "MiniMax M2.5 (Legacy Alias)" },
{ id: "MiniMax-M2.1", name: "MiniMax M2.1" },
@@ -767,6 +811,9 @@ export const REGISTRY: Record<string, RegistryEntry> = {
},
models: [
// Keep parity with minimax to ensure model discovery works for minimax-cn connections.
{ id: "minimax-m2.7", name: "MiniMax M2.7" },
{ id: "MiniMax-M2.7", name: "MiniMax M2.7 (Legacy Alias)" },
{ id: "minimax-m2.7-highspeed", name: "MiniMax M2.7 Highspeed" },
{ id: "minimax-m2.5", name: "MiniMax M2.5" },
{ id: "MiniMax-M2.5", name: "MiniMax M2.5 (Legacy Alias)" },
{ id: "MiniMax-M2.1", name: "MiniMax M2.1" },
@@ -1146,7 +1193,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
alias: "vertex",
// Vertex AI uses Google's generateContent format (same as Gemini)
format: "gemini",
executor: "default",
executor: "vertex",
// URL uses {project_id} and {region} from providerSpecificData — handled by custom executor or fallback
// Default to us-central1 / generic endpoint; users configure project via providerSpecificData
baseUrl: "https://us-central1-aiplatform.googleapis.com/v1/projects",
@@ -1160,10 +1207,16 @@ export const REGISTRY: Record<string, RegistryEntry> = {
authType: "apikey",
authHeader: "bearer",
models: [
{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview (Vertex)" },
{ id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview (Vertex)" },
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview (Vertex)" },
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro (Vertex)" },
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash (Vertex)" },
{ id: "gemini-2.0-flash-thinking-exp", name: "Gemini 2.0 Flash Thinking Exp (Vertex)" },
{ id: "gemma-2-27b-it", name: "Gemma 2 27B (Vertex)" },
{ id: "deepseek-v3.2", name: "DeepSeek V3.2 (Vertex Partner)" },
{ id: "qwen3-next-80b", name: "Qwen3 Next 80B (Vertex Partner)" },
{ id: "glm-5", name: "GLM-5 (Vertex Partner)" },
{ id: "claude-opus-4-5@20251101", name: "Claude Opus 4.5 (Vertex)" },
{ id: "claude-sonnet-4-5@20251101", name: "Claude Sonnet 4.5 (Vertex)" },
],

View File

@@ -1,4 +1,4 @@
import { BaseExecutor } from "./base.ts";
import { BaseExecutor, ExecuteInput } from "./base.ts";
import { PROVIDERS, OAUTH_ENDPOINTS } from "../config/constants.ts";
import { getModelTargetFormat } from "../config/providerModels.ts";
@@ -19,15 +19,82 @@ export class GithubExecutor extends BaseExecutor {
return this.config.baseUrl;
}
injectResponseFormat(messages: any[], responseFormat: any) {
if (!responseFormat) return messages;
let formatInstruction = "";
if (responseFormat.type === "json_object") {
formatInstruction =
"Respond only with valid JSON. Do not include any text before or after the JSON object.";
} else if (responseFormat.type === "json_schema" && responseFormat.json_schema) {
formatInstruction = `Respond only with valid JSON matching this schema:\n${JSON.stringify(
responseFormat.json_schema.schema,
null,
2
)}\nDo not include any text before or after the JSON.`;
}
if (!formatInstruction) return messages;
const systemIdx = messages.findIndex((m: any) => m.role === "system");
if (systemIdx >= 0) {
return messages.map((m: any, i: number) =>
i === systemIdx ? { ...m, content: `${m.content}\n\n${formatInstruction}` } : m
);
}
return [{ role: "system", content: formatInstruction }, ...messages];
}
transformRequest(model: string, body: any, stream: boolean, credentials: any): any {
const modifiedBody = JSON.parse(JSON.stringify(body));
if (modifiedBody.response_format && model.toLowerCase().includes("claude")) {
modifiedBody.messages = this.injectResponseFormat(
modifiedBody.messages,
modifiedBody.response_format
);
delete modifiedBody.response_format;
}
return modifiedBody;
}
async execute(input: ExecuteInput) {
const result = await super.execute(input);
if (!result || !result.response?.body) return result;
const isStreaming = input.stream === true;
if (isStreaming) {
const decoder = new TextDecoder();
const transformStream = new TransformStream({
transform(chunk, controller) {
const text = decoder.decode(chunk, { stream: true });
if (text.includes("data: [DONE]")) {
return;
}
controller.enqueue(chunk);
},
});
const newResponse = new Response(result.response.body.pipeThrough(transformStream), {
status: result.response.status,
statusText: result.response.statusText,
headers: result.response.headers, // Headers class carries over correctly
});
result.response = newResponse;
}
return result;
}
buildHeaders(credentials, stream = true) {
const token = credentials.copilotToken || credentials.accessToken;
return {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"copilot-integration-id": "vscode-chat",
"editor-version": "vscode/1.107.1",
"editor-plugin-version": "copilot-chat/0.26.7",
"user-agent": "GitHubCopilotChat/0.26.7",
"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-request-id":
@@ -44,7 +111,7 @@ export class GithubExecutor extends BaseExecutor {
headers: {
Authorization: `token ${githubAccessToken}`,
"User-Agent": "GithubCopilot/1.0",
"Editor-Version": "vscode/1.100.0",
"Editor-Version": "vscode/1.110.0",
"Editor-Plugin-Version": "copilot/1.300.0",
Accept: "application/json",
},

View File

@@ -10,6 +10,7 @@ import { PollinationsExecutor } from "./pollinations.ts";
import { CloudflareAIExecutor } from "./cloudflare-ai.ts";
import { OpencodeExecutor } from "./opencode.ts";
import { PuterExecutor } from "./puter.ts";
import { VertexExecutor } from "./vertex.ts";
const executors = {
antigravity: new AntigravityExecutor(),
@@ -28,6 +29,7 @@ const executors = {
"opencode-go": new OpencodeExecutor("opencode-go"),
puter: new PuterExecutor(),
pu: new PuterExecutor(), // Alias
vertex: new VertexExecutor(),
};
const defaultCache = new Map();
@@ -55,3 +57,4 @@ export { PollinationsExecutor } from "./pollinations.ts";
export { CloudflareAIExecutor } from "./cloudflare-ai.ts";
export { OpencodeExecutor } from "./opencode.ts";
export { PuterExecutor } from "./puter.ts";
export { VertexExecutor } from "./vertex.ts";

View File

@@ -0,0 +1,150 @@
import { SignJWT, importPKCS8 } from "jose";
import { BaseExecutor, ExecuteInput } from "./base.ts";
import { PROVIDERS } from "../config/constants.ts";
interface ServiceAccount {
type: string;
project_id: string;
private_key_id: string;
private_key: string;
client_email: string;
[key: string]: unknown;
}
const TOKEN_CACHE = new Map<string, { token: string; expiresAt: number }>();
function parseSAFromApiKey(apiKey: string): ServiceAccount {
try {
return JSON.parse(apiKey);
} catch {
throw new Error("Vertex AI requires a valid Service Account JSON as the API key");
}
}
async function getAccessToken(sa: ServiceAccount): Promise<string> {
if (!sa.client_email || !sa.private_key) {
throw new Error(
"Service Account JSON is missing required fields (client_email or private_key)"
);
}
const cacheKey = sa.client_email;
const cached = TOKEN_CACHE.get(cacheKey);
// Buffer of 60 seconds
if (cached && Date.now() < cached.expiresAt - 60_000) {
return cached.token;
}
const privateKey = await importPKCS8(sa.private_key, "RS256");
const now = Math.floor(Date.now() / 1000);
const jwt = await new SignJWT({
iss: sa.client_email,
sub: sa.client_email,
aud: "https://oauth2.googleapis.com/token",
iat: now,
exp: now + 3600,
scope: "https://www.googleapis.com/auth/cloud-platform",
})
.setProtectedHeader({ alg: "RS256", kid: sa.private_key_id })
.sign(privateKey);
const tokenRes = 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 (!tokenRes.ok) {
const errorText = await tokenRes.text();
throw new Error(
`Failed to exchange JWT for Vertex access token: ${tokenRes.status} ${errorText}`
);
}
const tokenData = await tokenRes.json();
const accessToken = tokenData.access_token;
if (!accessToken) {
throw new Error("Vertex AI token exchange succeeded but no access_token found");
}
TOKEN_CACHE.set(cacheKey, {
token: accessToken,
expiresAt: (now + 3600) * 1000,
});
return accessToken;
}
const PARTNER_MODELS = new Set([
"claude-3-5-sonnet",
"claude-3-opus",
"claude-3-haiku",
"deepseek-v3",
"deepseek-v3.2",
"deepseek-deepseek-r1",
"qwen3-next-80b",
"llama-3.1",
"mistral-",
"glm-5",
"meta/llama",
]);
function isPartnerModel(model: string) {
return [...PARTNER_MODELS].some((prefix) => model.startsWith(prefix));
}
export class VertexExecutor extends BaseExecutor {
constructor() {
super("vertex", PROVIDERS.vertex);
}
async execute(input: ExecuteInput) {
const { credentials, log } = input;
if (credentials.apiKey && !credentials.accessToken) {
try {
const sa = parseSAFromApiKey(credentials.apiKey);
credentials.accessToken = await getAccessToken(sa);
} catch (err: any) {
log?.error?.("VERTEX", `Failed to generate JWT token: ${err.message}`);
throw err;
}
}
return super.execute(input);
}
buildUrl(model: string, stream: boolean, urlIndex = 0, credentials: any = null) {
const region = credentials?.providerSpecificData?.region || "us-central1";
let project = "unknown-project";
if (credentials?.apiKey) {
try {
const sa = parseSAFromApiKey(credentials.apiKey);
if (sa.project_id) project = sa.project_id;
} catch {
// Ignored, handled in execute
}
}
if (isPartnerModel(model)) {
return `https://aiplatform.googleapis.com/v1/projects/${project}/locations/global/endpoints/openapi/chat/completions`;
}
return `https://aiplatform.googleapis.com/v1/projects/${project}/locations/${region}/publishers/google/models/${model}:${stream ? "streamGenerateContent?alt=sse" : "generateContent"}`;
}
buildHeaders(credentials: any, stream = true) {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (credentials.accessToken) {
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
}
if (stream) {
headers["Accept"] = "text/event-stream";
}
return headers;
}
}

View File

@@ -16,6 +16,9 @@ import { resolveModelAlias } from "../services/modelDeprecation.ts";
import { getUnsupportedParams } from "../config/providerRegistry.ts";
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.ts";
import { HTTP_STATUS } from "../config/constants.ts";
import { classifyProviderError, PROVIDER_ERROR_TYPES } from "../services/errorClassifier.ts";
import { updateProviderConnection } from "@/lib/db/providers";
import { logAuditEvent } from "@/lib/compliance";
import { handleBypassRequest } from "../utils/bypassHandler.ts";
import {
saveRequestUsage,
@@ -25,6 +28,11 @@ import {
} from "@/lib/usageDb";
import { getModelNormalizeToolCallId, getModelPreserveOpenAIDeveloperRole } from "@/lib/localDb";
import { getExecutor } from "../executors/index.ts";
import {
parseCodexQuotaHeaders,
getCodexResetTime,
getCodexModelScope,
} from "../executors/codex.ts";
import { translateNonStreamingResponse } from "./responseTranslator.ts";
import { extractUsageFromResponse } from "./usageExtractor.ts";
import { parseSSEToOpenAIResponse, parseSSEToResponsesOutput } from "./sseParser.ts";
@@ -44,11 +52,17 @@ import { getIdempotencyKey, checkIdempotency, saveIdempotency } from "@/lib/idem
import { createProgressTransform, wantsProgress } from "../utils/progressTracker.ts";
import { isModelUnavailableError, getNextFamilyFallback } from "../services/modelFamilyFallback.ts";
import { computeRequestHash, deduplicate, shouldDeduplicate } from "../services/requestDedup.ts";
import {
getBackgroundTaskReason,
getDegradedModel,
getBackgroundDegradationConfig,
} from "../services/backgroundTaskDetector.ts";
import {
shouldUseFallback,
isFallbackDecision,
EMERGENCY_FALLBACK_CONFIG,
} from "../services/emergencyFallback.ts";
import { resolveStreamFlag, stripMarkdownCodeFence } from "../utils/aiSdkCompat.ts";
export function shouldUseNativeCodexPassthrough({
provider,
@@ -93,7 +107,9 @@ export async function handleChatCore({
userAgent,
comboName,
}) {
const { provider, model, extendedContext } = modelInfo;
let { provider, model, extendedContext } = modelInfo;
const requestedModel =
typeof body?.model === "string" && body.model.trim().length > 0 ? body.model : model;
const startTime = Date.now();
const persistFailureUsage = (statusCode: number, errorCode?: string | null) => {
saveRequestUsage({
@@ -112,6 +128,67 @@ export async function handleChatCore({
}).catch(() => {});
};
const persistCodexQuotaState = async (
headers: Headers | Record<string, string> | null,
status = 0
) => {
if (provider !== "codex" || !connectionId || !headers) return;
try {
const quota = parseCodexQuotaHeaders(headers as Headers);
if (!quota) return;
const existingProviderData =
credentials?.providerSpecificData && typeof credentials.providerSpecificData === "object"
? credentials.providerSpecificData
: {};
const scope = getCodexModelScope(model || requestedModel || "");
const quotaState = {
usage5h: quota.usage5h,
limit5h: quota.limit5h,
resetAt5h: quota.resetAt5h,
usage7d: quota.usage7d,
limit7d: quota.limit7d,
resetAt7d: quota.resetAt7d,
scope,
updatedAt: new Date().toISOString(),
};
const nextProviderData: Record<string, unknown> = {
...existingProviderData,
codexQuotaState: quotaState,
};
// T03/T09: on 429, persist exact reset time per scope to avoid global over-blocking.
if (status === 429) {
const resetTimeMs = getCodexResetTime(quota);
if (resetTimeMs && resetTimeMs > Date.now()) {
const scopeUntil = new Date(resetTimeMs).toISOString();
const scopeMapRaw =
existingProviderData &&
typeof existingProviderData === "object" &&
existingProviderData.codexScopeRateLimitedUntil &&
typeof existingProviderData.codexScopeRateLimitedUntil === "object"
? existingProviderData.codexScopeRateLimitedUntil
: {};
nextProviderData.codexScopeRateLimitedUntil = {
...(scopeMapRaw as Record<string, unknown>),
[scope]: scopeUntil,
};
}
}
await updateProviderConnection(connectionId, {
providerSpecificData: nextProviderData,
});
credentials.providerSpecificData = nextProviderData;
} catch (err) {
log?.debug?.("CODEX", `Failed to persist codex quota state: ${err?.message || err}`);
}
};
// ── Phase 9.2: Idempotency check ──
const idempotencyKey = getIdempotencyKey(clientRawRequest?.headers);
const cachedIdemp = checkIdempotency(idempotencyKey);
@@ -157,6 +234,37 @@ export async function handleChatCore({
// Detect source format and get target format
// Model-specific targetFormat takes priority over provider default
// ── Background Task Redirection (T41) ──
const bgConfig = getBackgroundDegradationConfig();
const backgroundReason = bgConfig.enabled
? getBackgroundTaskReason(body, clientRawRequest?.headers)
: null;
if (backgroundReason) {
const degradedModel = getDegradedModel(model);
if (degradedModel !== model) {
const originalModel = model;
log?.info?.(
"BACKGROUND",
`Background task redirect (${backgroundReason}): ${originalModel}${degradedModel}`
);
model = degradedModel;
if (body && typeof body === "object") {
body.model = model;
}
logAuditEvent({
action: "routing.background_task_redirect",
actor: apiKeyInfo?.name || "system",
target: connectionId || provider || "chat",
details: {
original_model: originalModel,
redirected_to: degradedModel,
reason: backgroundReason,
},
});
}
}
// Apply custom model aliases (Settings → Model Aliases → Pattern→Target) before routing (#315, #472)
// Custom aliases take priority over built-in and must be resolved here so the
// downstream getModelTargetFormat() lookup AND the actual provider request use
@@ -173,7 +281,12 @@ export async function handleChatCore({
const targetFormat = modelTargetFormat || getTargetFormat(provider);
// Default to false unless client explicitly sets stream: true (OpenAI spec compliant)
const stream = body.stream === true;
const acceptHeader =
clientRawRequest?.headers && typeof clientRawRequest.headers.get === "function"
? clientRawRequest.headers.get("accept") || clientRawRequest.headers.get("Accept")
: (clientRawRequest?.headers || {})["accept"] || (clientRawRequest?.headers || {})["Accept"];
const stream = resolveStreamFlag(body?.stream, acceptHeader);
// ── Phase 9.1: Semantic cache check (non-streaming, temp=0 only) ──
if (isCacheable(body, clientRawRequest?.headers)) {
@@ -457,7 +570,7 @@ export async function handleChatCore({
// Non-stream responses need cloning for shared dedup consumers.
const status = rawResult.response.status;
const statusText = rawResult.response.statusText;
const headers = Array.from(rawResult.response.headers.entries());
const headers = Array.from(rawResult.response.headers.entries()) as [string, string][];
const payload = await rawResult.response.text();
return {
@@ -532,6 +645,7 @@ export async function handleChatCore({
path: clientRawRequest?.endpoint || "/v1/chat/completions",
status: error.name === "AbortError" ? 499 : HTTP_STATUS.BAD_GATEWAY,
model,
requestedModel,
provider,
connectionId,
duration: Date.now() - startTime,
@@ -603,6 +717,8 @@ export async function handleChatCore({
}
}
await persistCodexQuotaState(providerResponse.headers, providerResponse.status);
// Check provider response - return error info for fallback handling
if (!providerResponse.ok) {
trackPendingRequest(model, provider, connectionId, false);
@@ -610,6 +726,54 @@ export async function handleChatCore({
providerResponse,
provider
);
// T06/T10/T36: classify provider errors and persist terminal account states.
const errorType = classifyProviderError(statusCode, message);
if (connectionId && errorType) {
try {
if (errorType === PROVIDER_ERROR_TYPES.FORBIDDEN) {
await updateProviderConnection(connectionId, {
isActive: false,
testStatus: "banned",
lastErrorType: errorType,
lastError: message,
errorCode: statusCode,
});
console.warn(
`[provider] Node ${connectionId} banned (${statusCode}) — disabling permanently`
);
} else if (errorType === PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED) {
await updateProviderConnection(connectionId, {
testStatus: "credits_exhausted",
lastErrorType: errorType,
lastError: message,
errorCode: statusCode,
});
console.warn(`[provider] Node ${connectionId} exhausted quota (${statusCode})`);
} else if (errorType === PROVIDER_ERROR_TYPES.ACCOUNT_DEACTIVATED) {
await updateProviderConnection(connectionId, {
isActive: false,
testStatus: "expired",
lastErrorType: errorType,
lastError: message,
errorCode: statusCode,
});
console.warn(
`[provider] Node ${connectionId} account deactivated (${statusCode}) — marked expired`
);
} else if (errorType === PROVIDER_ERROR_TYPES.UNAUTHORIZED) {
// Normal 401 (token/session auth issue): keep account active for refresh/re-auth.
await updateProviderConnection(connectionId, {
lastErrorType: errorType,
lastError: message,
errorCode: statusCode,
});
}
} catch {
// Best-effort state update; request flow should continue with fallback handling.
}
}
appendRequestLog({ model, provider, connectionId, status: `FAILED ${statusCode}` }).catch(
() => {}
);
@@ -618,6 +782,7 @@ export async function handleChatCore({
path: clientRawRequest?.endpoint || "/v1/chat/completions",
status: statusCode,
model,
requestedModel,
provider,
connectionId,
duration: Date.now() - startTime,
@@ -808,6 +973,7 @@ export async function handleChatCore({
path: clientRawRequest?.endpoint || "/v1/chat/completions",
status: 200,
model,
requestedModel,
provider,
connectionId,
duration: Date.now() - startTime,
@@ -848,6 +1014,28 @@ export async function handleChatCore({
? translateNonStreamingResponse(responseBody, targetFormat, sourceFormat)
: responseBody;
// T26: Strip markdown code blocks if provider format is Claude
if (sourceFormat === "claude" && !stream) {
if (typeof translatedResponse?.choices?.[0]?.message?.content === "string") {
translatedResponse.choices[0].message.content = stripMarkdownCodeFence(
translatedResponse.choices[0].message.content
) as string;
}
}
// T18: Normalize finish_reason to 'tool_calls' if tool calls are present
if (translatedResponse?.choices) {
for (const choice of translatedResponse.choices) {
if (
choice.message?.tool_calls &&
choice.message.tool_calls.length > 0 &&
choice.finish_reason !== "tool_calls"
) {
choice.finish_reason = "tool_calls";
}
}
}
// Sanitize response for OpenAI SDK compatibility
// Strips non-standard fields (x_groq, usage_breakdown, service_tier, etc.)
// Extracts <think> tags into reasoning_content
@@ -921,6 +1109,7 @@ export async function handleChatCore({
path: clientRawRequest?.endpoint || "/v1/chat/completions",
status: streamStatus || 200,
model,
requestedModel,
provider,
connectionId,
duration: Date.now() - startTime,

View File

@@ -16,6 +16,7 @@
*/
import { getImageProvider, parseImageModel } from "../config/imageRegistry.ts";
import { mapImageSize } from "../translator/image/sizeMapper.ts";
import { saveCallLog } from "@/lib/usageDb";
import {
submitComfyWorkflow,
@@ -95,11 +96,21 @@ export async function handleImageGeneration({ body, credentials, log, resolvedPr
});
}
// Route to format-specific handler
if (providerConfig.format === "gemini-image") {
return handleGeminiImageGeneration({ model, providerConfig, body, credentials, log });
}
if (providerConfig.format === "imagen3") {
return handleImagen3ImageGeneration({
model,
provider,
providerConfig,
body,
credentials,
log,
});
}
if (providerConfig.format === "hyperbolic") {
return handleHyperbolicImageGeneration({
model,
@@ -539,7 +550,7 @@ async function handleNanoBananaImageGeneration({
? body.aspectRatio
: typeof body.aspect_ratio === "string"
? body.aspect_ratio
: inferAspectRatioFromSize(body.size) || "1:1";
: mapImageSize(body.size);
let resolution =
typeof body.resolution === "string"
@@ -856,18 +867,6 @@ async function normalizeNanoBananaTaskResult(taskData, body, log) {
return [];
}
function inferAspectRatioFromSize(size) {
if (typeof size !== "string") return null;
const [wRaw, hRaw] = size.split("x");
const width = Number(wRaw);
const height = Number(hRaw);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null;
const gcd = (a, b) => (b === 0 ? a : gcd(b, a % b));
const div = gcd(Math.round(width), Math.round(height));
return `${Math.round(width / div)}:${Math.round(height / div)}`;
}
function inferResolutionFromSize(size) {
if (typeof size !== "string") return null;
const [wRaw, hRaw] = size.split("x");
@@ -1081,3 +1080,113 @@ async function handleComfyUIImageGeneration({ model, provider, providerConfig, b
return { success: false, status: 502, error: `Image provider error: ${err.message}` };
}
}
/**
* Handle Imagen 3 image generation
*/
async function handleImagen3ImageGeneration({
model,
provider,
providerConfig,
body,
credentials,
log,
}: any) {
const startTime = Date.now();
const token = credentials.apiKey || credentials.accessToken;
const aspectRatio = mapImageSize(body.size);
const upstreamBody = {
prompt: body.prompt,
aspect_ratio: aspectRatio,
number_of_images: body.n ?? 1,
};
if (log) {
const promptPreview = String(body.prompt ?? "").slice(0, 60);
log.info(
"IMAGE",
`${provider}/${model} (imagen3) | prompt: "${promptPreview}..." | aspect_ratio: ${aspectRatio}`
);
}
try {
const response = await fetch(providerConfig.baseUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(upstreamBody),
});
if (!response.ok) {
const errorText = await response.text();
if (log)
log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`);
saveCallLog({
method: "POST",
path: "/v1/images/generations",
status: response.status,
model: `${provider}/${model}`,
provider,
duration: Date.now() - startTime,
error: errorText.slice(0, 500),
requestBody: upstreamBody,
}).catch(() => {});
return { success: false, status: response.status, error: errorText };
}
const data = await response.json();
// Normalize response to OpenAI format
const images: any[] = [];
if (Array.isArray(data.images)) {
images.push(
...data.images.map((img: any) => ({
b64_json: img.image || img.b64_json || img.url || img,
revised_prompt: body.prompt,
}))
);
} else if (Array.isArray(data.data)) {
images.push(...data.data);
} else if (data.url || data.b64_json || data.image) {
images.push({
b64_json: data.image || data.b64_json || data.url,
url: data.url,
revised_prompt: body.prompt,
});
}
saveCallLog({
method: "POST",
path: "/v1/images/generations",
status: 200,
model: `${provider}/${model}`,
provider,
duration: Date.now() - startTime,
responseBody: { images_count: images.length },
}).catch(() => {});
return {
success: true,
data: { created: data.created || Math.floor(Date.now() / 1000), data: images },
};
} catch (err: any) {
if (log) log.error("IMAGE", `${provider} fetch error: ${err.message}`);
saveCallLog({
method: "POST",
path: "/v1/images/generations",
status: 502,
model: `${provider}/${model}`,
provider,
duration: Date.now() - startTime,
error: err.message,
}).catch(() => {});
return { success: false, status: 502, error: `Image provider error: ${err.message}` };
}
}

View File

@@ -20,6 +20,51 @@ function toNumber(value: unknown, fallback = 0): number {
return Number.isFinite(parsed) ? parsed : fallback;
}
function extractMessageOutputText(item: JsonRecord): string {
if (!Array.isArray(item.content)) return "";
let text = "";
for (const part of item.content) {
if (!part || typeof part !== "object") continue;
const partObj = toRecord(part);
if (partObj.type === "output_text" && typeof partObj.text === "string") {
text += partObj.text;
}
}
return text;
}
/**
* T19: Pick the last non-empty message output text from Responses API output.
* Falls back to the last message item even when all message texts are empty.
*/
function findBestMessageText(output: unknown[]): {
text: string;
selectedMessageIndex: number;
messageItems: JsonRecord[];
} {
const messageItems = output
.map((item) => toRecord(item))
.filter((item) => item.type === "message" && Array.isArray(item.content));
for (let i = messageItems.length - 1; i >= 0; i -= 1) {
const text = extractMessageOutputText(messageItems[i]);
if (text.trim().length > 0) {
return { text, selectedMessageIndex: i, messageItems };
}
}
if (messageItems.length > 0) {
const lastIndex = messageItems.length - 1;
return {
text: extractMessageOutputText(messageItems[lastIndex]),
selectedMessageIndex: lastIndex,
messageItems,
};
}
return { text: "", selectedMessageIndex: -1, messageItems: [] };
}
/**
* Translate non-streaming response to OpenAI format
* Handles different provider response formats (Gemini, Claude, etc.)
@@ -44,7 +89,8 @@ export function translateNonStreamingResponse(
const output = Array.isArray(response.output) ? response.output : [];
const usage = toRecord(response.usage ?? responseRoot.usage);
let textContent = "";
const messageSelection = findBestMessageText(output);
let textContent = messageSelection.text;
let reasoningContent = "";
const toolCalls: JsonRecord[] = [];
@@ -56,9 +102,7 @@ export function translateNonStreamingResponse(
for (const part of itemObj.content) {
if (!part || typeof part !== "object") continue;
const partObj = toRecord(part);
if (partObj.type === "output_text" && typeof partObj.text === "string") {
textContent += partObj.text;
} else if (partObj.type === "summary_text" && typeof partObj.text === "string") {
if (partObj.type === "summary_text" && typeof partObj.text === "string") {
reasoningContent += partObj.text;
}
}
@@ -103,6 +147,18 @@ export function translateNonStreamingResponse(
message.content = "";
}
if (process.env.DEBUG_RESPONSES_SSE_TO_JSON === "true") {
console.log(
`[ResponsesSSE] ${output.length} output items, ${messageSelection.messageItems.length} message items`
);
messageSelection.messageItems.forEach((item, idx) => {
const textLen = extractMessageOutputText(item).length;
console.log(` [${idx}] text length: ${textLen}`);
});
console.log(` → Selected message index: ${messageSelection.selectedMessageIndex}`);
console.log(` → Final text content length: ${textContent.length}`);
}
const createdAt = toNumber(response.created_at, Math.floor(Date.now() / 1000));
const model = toString(response.model || responseRoot.model, "openai-responses");
const finishReason = toolCalls.length > 0 ? "tool_calls" : "stop";

View File

@@ -23,9 +23,18 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) {
const first = chunks[0];
const contentParts = [];
const reasoningParts = [];
const accumulatedToolCalls = new Map<string, any>();
let unknownToolCallSeq = 0;
let finishReason = "stop";
let usage = null;
const getToolCallKey = (toolCall: any) => {
if (toolCall?.id) return `id:${toolCall.id}`;
if (Number.isInteger(toolCall?.index)) return `idx:${toolCall.index}`;
unknownToolCallSeq += 1;
return `seq:${unknownToolCallSeq}`;
};
for (const chunk of chunks) {
const choice = chunk?.choices?.[0];
const delta = choice?.delta || {};
@@ -36,6 +45,40 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) {
if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) {
reasoningParts.push(delta.reasoning_content);
}
// T18: Accumulate tool calls correctly across streamed chunks
if (delta.tool_calls) {
for (const tc of delta.tool_calls) {
const key = getToolCallKey(tc);
const existing = accumulatedToolCalls.get(key);
const deltaArgs = typeof tc?.function?.arguments === "string" ? tc.function.arguments : "";
if (!existing) {
accumulatedToolCalls.set(key, {
id: tc?.id ?? null,
index: Number.isInteger(tc?.index) ? tc.index : accumulatedToolCalls.size,
type: tc?.type || "function",
function: {
name: tc?.function?.name || "unknown",
arguments: deltaArgs,
},
});
} else {
existing.id = existing.id || tc?.id || null;
if (!Number.isInteger(existing.index) && Number.isInteger(tc?.index)) {
existing.index = tc.index;
}
if (tc?.function?.name && !existing.function?.name) {
existing.function = existing.function || {};
existing.function.name = tc.function.name;
}
existing.function = existing.function || {};
existing.function.arguments = `${existing.function.arguments || ""}${deltaArgs}`;
accumulatedToolCalls.set(key, existing);
}
}
}
if (choice?.finish_reason) {
finishReason = choice.finish_reason;
}
@@ -46,12 +89,22 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) {
const message: Record<string, unknown> = {
role: "assistant",
content: contentParts.join(""),
content: contentParts.length > 0 ? contentParts.join("") : null,
};
if (reasoningParts.length > 0) {
message.reasoning_content = reasoningParts.join("");
}
const finalToolCalls = [...accumulatedToolCalls.values()].filter(Boolean).sort((a, b) => {
const ai = Number.isInteger(a?.index) ? a.index : 0;
const bi = Number.isInteger(b?.index) ? b.index : 0;
return ai - bi;
});
if (finalToolCalls.length > 0) {
finishReason = "tool_calls"; // T18 normalization
message.tool_calls = finalToolCalls;
}
const result: Record<string, unknown> = {
id: first.id || `chatcmpl-${Date.now()}`,
object: "chat.completion",

View File

@@ -433,23 +433,48 @@ async function handleListModelsCatalog(args: { provider?: string; capability?: s
const start = Date.now();
try {
let path = "/v1/models";
const params = new URLSearchParams();
if (args.provider) params.set("provider", args.provider);
if (args.capability) params.set("capability", args.capability);
if (params.toString()) path += `?${params.toString()}`;
let isProviderSpecific = false;
let source = "local_catalog";
let warning = undefined;
if (args.provider && !args.capability) {
// Use direct provider fetch to get real-time API status
path = `/api/providers/${encodeURIComponent(args.provider)}/models`;
isProviderSpecific = true;
} else {
const params = new URLSearchParams();
if (args.provider) params.set("provider", args.provider);
if (args.capability) params.set("capability", args.capability);
if (params.toString()) path += `?${params.toString()}`;
}
const raw = toRecord(await omniRouteFetch(path));
// If we used the direct provider endpoint
let rawModels = [];
if (isProviderSpecific) {
rawModels = Array.isArray(raw.models) ? raw.models : [];
source = typeof raw.source === "string" ? raw.source : "api";
if (raw.warning) warning = String(raw.warning);
} else {
rawModels = Array.isArray(raw.data) ? raw.data : [];
source = "local_catalog";
// OmniRoute's global /v1/models is always a cached/local catalog
}
const result = {
models: toArray(raw.data).map((rawModel) => {
models: rawModels.map((rawModel) => {
const model = toRecord(rawModel);
return {
id: toString(model.id, ""),
provider: toString(model.owned_by, toString(model.provider, "unknown")),
provider: toString(model.owned_by, toString(model.provider, args.provider || "unknown")),
capabilities: toStringArray(model.capabilities, ["chat"]),
status: toString(model.status, "available"),
pricing: model.pricing,
};
}),
source,
...(warning ? { warning } : {}),
};
await logToolCall(

View File

@@ -342,11 +342,45 @@ export function checkFallbackError(
errorText,
backoffLevel = 0,
model = null,
provider = null
provider = null,
headers = null
) {
const errorStr = (errorText || "").toString();
function parseResetFromHeaders(headers, errorStr = "") {
if (!headers) return null;
// Retry-After header
const retryAfter =
typeof headers.get === "function"
? headers.get("retry-after")
: headers["retry-after"] || headers["Retry-After"];
if (retryAfter) {
const seconds = parseInt(retryAfter, 10);
if (!isNaN(seconds) && String(seconds) === String(retryAfter).trim()) {
return Date.now() + seconds * 1000;
}
const date = new Date(retryAfter);
if (!isNaN(date.getTime())) return date.getTime();
}
// X-RateLimit-Reset
const rlReset =
typeof headers.get === "function"
? headers.get("x-ratelimit-reset")
: headers["x-ratelimit-reset"] || headers["X-RateLimit-Reset"];
if (rlReset) {
const ts = parseInt(rlReset, 10);
if (!isNaN(ts)) {
return ts > 10000000000 ? ts : ts * 1000;
}
}
return null;
}
// Check error message FIRST - specific patterns take priority over status codes
if (errorText) {
const errorStr = typeof errorText === "string" ? errorText : JSON.stringify(errorText);
const lowerError = errorStr.toLowerCase();
// T06 (sub2api #1037): Permanent account deactivation — do NOT retry, mark as permanent failure
@@ -393,6 +427,18 @@ export function checkFallbackError(
lowerError.includes("capacity") ||
lowerError.includes("overloaded")
) {
const resetTime = parseResetFromHeaders(headers);
if (resetTime) {
const waitMs = resetTime - Date.now();
if (waitMs > 60_000) {
return {
shouldFallback: true,
cooldownMs: waitMs,
newBackoffLevel: 0,
reason: RateLimitReason.RATE_LIMIT_EXCEEDED,
};
}
}
const newLevel = Math.min(backoffLevel + 1, BACKOFF_CONFIG.maxLevel);
const reason = classifyErrorText(errorStr);
return {
@@ -430,6 +476,19 @@ export function checkFallbackError(
// 429 - Rate limit with exponential backoff
if (status === HTTP_STATUS.RATE_LIMITED) {
const resetTime = parseResetFromHeaders(headers);
if (resetTime) {
const waitMs = resetTime - Date.now();
if (waitMs > 60_000) {
return {
shouldFallback: true,
cooldownMs: waitMs,
newBackoffLevel: 0,
reason: RateLimitReason.RATE_LIMIT_EXCEEDED,
};
}
}
const newLevel = Math.min(backoffLevel + 1, BACKOFF_CONFIG.maxLevel);
return {
shouldFallback: true,
@@ -449,6 +508,19 @@ export function checkFallbackError(
HTTP_STATUS.GATEWAY_TIMEOUT,
];
if (transientStatuses.includes(status)) {
const resetTime = parseResetFromHeaders(headers, errorStr);
if (resetTime) {
const waitMs = resetTime - Date.now();
if (waitMs > 60_000) {
return {
shouldFallback: true,
cooldownMs: waitMs,
newBackoffLevel: 0,
reason: RateLimitReason.SERVER_ERROR,
};
}
}
const profile = provider ? getProviderProfile(provider) : null;
const baseCooldown = profile?.transientCooldown ?? COOLDOWN_MS.transientInitial;
const maxLevel = profile?.maxBackoffLevel ?? BACKOFF_CONFIG.maxLevel;

View File

@@ -7,6 +7,7 @@
import fs from "fs";
import path from "path";
import { resolveDataDir } from "../../../src/lib/dataPaths";
export interface AdaptationState {
comboId: string;
@@ -23,7 +24,7 @@ export interface AdaptationState {
lastUpdated: string;
}
const PERSISTENCE_DIR = path.join(process.cwd(), "data");
const PERSISTENCE_DIR = resolveDataDir();
const STATE_FILE = path.join(PERSISTENCE_DIR, "auto_combo_state.json");
let stateCache = new Map<string, AdaptationState>();

View File

@@ -47,16 +47,16 @@ const DEFAULT_DETECTION_PATTERNS = [
const DEFAULT_DEGRADATION_MAP: Record<string, string> = {
// Premium → Cheap alternatives
"claude-opus-4-6": "gemini-2.5-flash",
"claude-opus-4-6-thinking": "gemini-2.5-flash",
"claude-opus-4-5-20251101": "gemini-2.5-flash",
"claude-sonnet-4-5-20250929": "gemini-2.5-flash",
"claude-sonnet-4-20250514": "gemini-2.5-flash",
"claude-sonnet-4": "gemini-2.5-flash",
"gemini-3.1-pro": "gemini-3.1-flash",
"gemini-3.1-pro-high": "gemini-3.1-flash",
"claude-opus-4-6": "gemini-3-flash",
"claude-opus-4-6-thinking": "gemini-3-flash",
"claude-opus-4-5-20251101": "gemini-3-flash",
"claude-sonnet-4-5-20250929": "gemini-3-flash",
"claude-sonnet-4-20250514": "gemini-3-flash",
"claude-sonnet-4": "gemini-3-flash",
"gemini-3.1-pro": "gemini-3-flash",
"gemini-3.1-pro-high": "gemini-3-flash",
"gemini-3-pro-preview": "gemini-3-flash-preview",
"gemini-2.5-pro": "gemini-2.5-flash",
"gemini-2.5-pro": "gemini-3-flash",
"gpt-4o": "gpt-4o-mini",
"gpt-5": "gpt-5-mini",
"gpt-5.1": "gpt-5-mini",
@@ -114,12 +114,93 @@ interface BackgroundMessage {
interface BackgroundTaskBody {
messages?: BackgroundMessage[];
input?: BackgroundMessage[];
max_tokens?: unknown;
max_completion_tokens?: unknown;
max_output_tokens?: unknown;
}
function toMessageArray(value: unknown): BackgroundMessage[] {
return Array.isArray(value) ? (value as BackgroundMessage[]) : [];
}
function toFiniteNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function headerValue(headers: Record<string, string> | null, key: string): string {
if (!headers) return "";
const value = headers[key] ?? headers[key.toLowerCase()] ?? headers[key.toUpperCase()];
return typeof value === "string" ? value.trim() : "";
}
/**
* Get reason label when request is a background/utility task.
*
* @param {object} body - Request body
* @param {object} [headers] - Request headers (optional)
* @returns {string | null} Reason label or null when not detected
*/
export function getBackgroundTaskReason(
body: BackgroundTaskBody | unknown,
headers: Record<string, string> | null = null
): string | null {
if (!body || typeof body !== "object") return null;
const typedBody = body as BackgroundTaskBody;
// 1. Check explicit header
if (headers) {
const taskType = headerValue(headers, "x-task-type");
const priority = headerValue(headers, "x-request-priority");
const initiator = headerValue(headers, "x-initiator");
const explicitValue = [taskType, priority, initiator].find(Boolean);
if (explicitValue && explicitValue.toLowerCase() === "background") {
return "header_background";
}
}
// 2. Very low max tokens usually indicates utility/background tasks
const maxTokens = toFiniteNumber(
typedBody.max_tokens ?? typedBody.max_completion_tokens ?? typedBody.max_output_tokens
);
if (maxTokens !== null && maxTokens > 0 && maxTokens < 50) {
return "low_max_tokens";
}
// 3. Check system prompt for background task patterns
const messages = toMessageArray(typedBody.messages ?? typedBody.input ?? []);
if (!Array.isArray(messages) || messages.length === 0) return null;
// Find system message
const systemMsg = messages.find(
(message: BackgroundMessage) => message.role === "system" || message.role === "developer"
);
if (!systemMsg) return null;
const systemContent =
typeof systemMsg.content === "string" ? systemMsg.content.toLowerCase() : "";
if (!systemContent) return null;
// Check against detection patterns
const matched = _config.detectionPatterns.some((pattern) =>
systemContent.includes(pattern.toLowerCase())
);
if (!matched) return null;
// 4. Additional heuristic: background tasks typically have very few messages
// (system + 1-2 user messages)
const userMessages = messages.filter((message: BackgroundMessage) => message.role === "user");
if (userMessages.length > 3) return null; // Too many turns for a background task
return "system_prompt_pattern";
}
/**
* Check if a request is a background/utility task.
*
@@ -131,44 +212,7 @@ export function isBackgroundTask(
body: BackgroundTaskBody | unknown,
headers: Record<string, string> | null = null
): boolean {
if (!body || typeof body !== "object") return false;
const typedBody = body as BackgroundTaskBody;
// 1. Check explicit header
if (headers) {
const priority =
headers["x-request-priority"] || headers["X-Request-Priority"] || headers["x-initiator"];
if (priority === "background" || priority === "Background") return true;
}
// 2. Check system prompt for background task patterns
const messages = toMessageArray(typedBody.messages ?? typedBody.input ?? []);
if (!Array.isArray(messages) || messages.length === 0) return false;
// Find system message
const systemMsg = messages.find(
(message: BackgroundMessage) => message.role === "system" || message.role === "developer"
);
if (!systemMsg) return false;
const systemContent =
typeof systemMsg.content === "string" ? systemMsg.content.toLowerCase() : "";
if (!systemContent) return false;
// Check against detection patterns
const matched = _config.detectionPatterns.some((pattern) =>
systemContent.includes(pattern.toLowerCase())
);
if (!matched) return false;
// 3. Additional heuristic: background tasks typically have very few messages
// (system + 1-2 user messages)
const userMessages = messages.filter((message: BackgroundMessage) => message.role === "user");
if (userMessages.length > 3) return false; // Too many turns for a background task
return true;
return getBackgroundTaskReason(body, headers) !== null;
}
/**

View File

@@ -841,7 +841,8 @@ export async function handleComboChat({
errorText,
0,
null,
provider
provider,
result.headers
);
// Record failure in circuit breaker for transient errors
@@ -865,6 +866,12 @@ export async function handleComboChat({
if (!lastStatus) lastStatus = result.status;
if (i > 0) fallbackCount++;
log.warn("COMBO", `Model ${modelStr} failed, trying next`, { status: result.status });
if ([502, 503, 504].includes(result.status) && cooldownMs > 0 && cooldownMs <= 5000) {
log.info("COMBO", `Waiting ${cooldownMs}ms before fallback to next model`);
await new Promise((r) => setTimeout(r, cooldownMs));
}
break; // Move to next model
}
}
@@ -886,7 +893,20 @@ export async function handleComboChat({
);
}
const status = lastStatus || 406;
if (!lastStatus) {
return new Response(
JSON.stringify({
error: {
message: "Service temporarily unavailable: all upstream accounts are inactive",
type: "service_unavailable",
code: "ALL_ACCOUNTS_INACTIVE",
},
}),
{ status: 503, headers: { "Content-Type": "application/json" } }
);
}
const status = lastStatus;
const msg = lastError || "All combo models unavailable";
if (earliestRetryAfter) {
@@ -941,7 +961,7 @@ async function handleRoundRobinCombo({
const modelCount = orderedModels.length;
if (modelCount === 0) {
return unavailableResponse(406, "Round-robin combo has no models");
return unavailableResponse(503, "Round-robin combo has no models");
}
// Get and increment atomic counter
@@ -1077,7 +1097,8 @@ async function handleRoundRobinCombo({
errorText,
0,
null,
provider
provider,
result.headers
);
// Transient errors → mark in semaphore AND record circuit breaker failure
@@ -1106,6 +1127,12 @@ async function handleRoundRobinCombo({
if (!lastStatus) lastStatus = result.status;
if (offset > 0) fallbackCount++;
log.warn("COMBO-RR", `${modelStr} failed, trying next model`, { status: result.status });
if ([502, 503, 504].includes(result.status) && cooldownMs > 0 && cooldownMs <= 5000) {
log.info("COMBO-RR", `Waiting ${cooldownMs}ms before fallback to next model`);
await new Promise((r) => setTimeout(r, cooldownMs));
}
break;
}
} finally {
@@ -1136,7 +1163,20 @@ async function handleRoundRobinCombo({
);
}
const status = lastStatus || 406;
if (!lastStatus) {
return new Response(
JSON.stringify({
error: {
message: "Service temporarily unavailable: all upstream accounts are inactive",
type: "service_unavailable",
code: "ALL_ACCOUNTS_INACTIVE",
},
}),
{ status: 503, headers: { "Content-Type": "application/json" } }
);
}
const status = lastStatus;
const msg = lastError || "All round-robin combo models unavailable";
if (earliestRetryAfter) {

View File

@@ -0,0 +1,53 @@
import { isAccountDeactivated, isCreditsExhausted } from "./accountFallback.ts";
export const PROVIDER_ERROR_TYPES = {
RATE_LIMITED: "rate_limited", // 429 — transient, retry with backoff
UNAUTHORIZED: "unauthorized", // 401 — token expired, refresh
ACCOUNT_DEACTIVATED: "account_deactivated", // 401 + deactivation signal
FORBIDDEN: "forbidden", // 403 — account banned/revoked, disable node
SERVER_ERROR: "server_error", // 500/502/503 — retry limited
QUOTA_EXHAUSTED: "quota_exhausted", // 402/429/400 + billing signals
};
function responseBodyToString(responseBody: unknown): string {
if (typeof responseBody === "string") return responseBody;
if (responseBody !== null && typeof responseBody === "object") {
try {
return JSON.stringify(responseBody);
} catch {
return "";
}
}
return "";
}
export function classifyProviderError(statusCode: number, responseBody: unknown): string | null {
const bodyStr = responseBodyToString(responseBody);
const creditsExhausted = isCreditsExhausted(bodyStr);
const accountDeactivated = isAccountDeactivated(bodyStr);
// T10: credits exhausted is terminal and can appear as 400/402/429 depending on provider.
if (
creditsExhausted &&
(statusCode === 400 || statusCode === 402 || statusCode === 429 || statusCode === 403)
) {
return PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED;
}
if (statusCode === 429) {
return PROVIDER_ERROR_TYPES.RATE_LIMITED;
}
// T06: only deactivation-like 401s should be treated as permanent account expiry.
if (statusCode === 401) {
return accountDeactivated
? PROVIDER_ERROR_TYPES.ACCOUNT_DEACTIVATED
: PROVIDER_ERROR_TYPES.UNAUTHORIZED;
}
if (statusCode === 402) return PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED;
if (statusCode === 403) return PROVIDER_ERROR_TYPES.FORBIDDEN;
if (statusCode >= 500) return PROVIDER_ERROR_TYPES.SERVER_ERROR;
return null;
}

View File

@@ -4,6 +4,8 @@
* IP-based access control with blacklist, whitelist, priority modes, and temporary bans.
*/
import { isIP } from "node:net";
// In-memory IP lists
let _config = {
enabled: false,
@@ -161,10 +163,10 @@ export function createIPFilterMiddleware() {
*/
export function checkRequestIP(request) {
const ip =
request.headers?.get?.("x-forwarded-for")?.split(",")[0].trim() ||
request.headers?.get?.("x-real-ip") ||
request.headers?.get?.("cf-connecting-ip") ||
request.ip ||
pickFirstValidIp(request.headers?.get?.("cf-connecting-ip")) ||
pickFirstValidIp(request.headers?.get?.("x-forwarded-for")) ||
pickFirstValidIp(request.headers?.get?.("x-real-ip")) ||
normalizeIP(request.ip || "") ||
"unknown";
return checkIP(ip);
}
@@ -177,6 +179,18 @@ function normalizeIP(ip) {
return ip.replace(/^::ffff:/, "").trim();
}
function pickFirstValidIp(rawValue) {
if (typeof rawValue !== "string" || rawValue.trim().length === 0) return null;
const candidates = rawValue.split(",");
for (const candidate of candidates) {
const normalized = normalizeIP(candidate);
if (normalized && isIP(normalized) !== 0) {
return normalized;
}
}
return null;
}
function matchesAny(ip, ipSet) {
// Direct match
if (ipSet.has(ip)) return true;
@@ -225,12 +239,13 @@ function matchesWildcard(ip, pattern) {
}
function extractClientIP(req) {
const headers = req.headers || {};
return (
req.headers?.["x-forwarded-for"]?.split(",")[0].trim() ||
req.headers?.["x-real-ip"] ||
req.headers?.["cf-connecting-ip"] ||
req.socket?.remoteAddress ||
req.ip ||
pickFirstValidIp(headers["cf-connecting-ip"]) ||
pickFirstValidIp(headers["x-forwarded-for"]) ||
pickFirstValidIp(headers["x-real-ip"]) ||
pickFirstValidIp(req.socket?.remoteAddress) ||
pickFirstValidIp(req.ip) ||
"unknown"
);
}

View File

@@ -18,6 +18,8 @@ const BUILT_IN_ALIASES: Record<string, string> = {
"gemini-1.5-flash": "gemini-2.5-flash",
"gemini-1.0-pro": "gemini-2.5-pro",
"gemini-2.0-flash": "gemini-2.5-flash",
"gemini-3-pro-high": "gemini-3.1-pro-high",
"gemini-3-pro-low": "gemini-3.1-pro-low",
// Claude legacy → current
"claude-3-opus-20240229": "claude-opus-4-20250514",

View File

@@ -101,6 +101,7 @@ const MODEL_UNAVAILABLE_FRAGMENTS = [
"does not support",
"not enabled for",
"access to model",
"improperly formed request", // Kiro 400 (model unavailable)
];
/**

View File

@@ -12,6 +12,7 @@ import Bottleneck from "bottleneck";
import { parseRetryAfterFromBody, lockModel } from "./accountFallback.ts";
import { getProviderCategory } from "../config/providerRegistry.ts";
import { DEFAULT_API_LIMITS } from "../config/constants.ts";
import { getCodexRateLimitKey } from "../executors/codex.ts";
interface LearnedLimitEntry {
provider: string;
@@ -195,8 +196,15 @@ export function isRateLimitEnabled(connectionId) {
/**
* Get or create a limiter for a given provider+connection combination
*/
function getLimiterKey(provider, connectionId, model = null) {
if (provider === "codex" && model) {
return `${provider}:${getCodexRateLimitKey(connectionId, model)}`;
}
return `${provider}:${connectionId}`;
}
function getLimiter(provider, connectionId, model = null) {
const key = model ? `${provider}:${connectionId}:${model}` : `${provider}:${connectionId}`;
const key = getLimiterKey(provider, connectionId, model);
if (!limiters.has(key)) {
const limiter = new Bottleneck({
@@ -235,7 +243,7 @@ export async function withRateLimit(provider, connectionId, model, fn) {
return fn();
}
const limiter = getLimiter(provider, connectionId, null);
const limiter = getLimiter(provider, connectionId, model);
return limiter.schedule(fn);
}
@@ -320,7 +328,7 @@ export function updateFromHeaders(provider, connectionId, headers, status, model
if (!enabledConnections.has(connectionId)) return;
if (!headers) return;
const limiter = getLimiter(provider, connectionId, null);
const limiter = getLimiter(provider, connectionId, model);
const headerMap =
provider === "claude" || provider === "anthropic" ? ANTHROPIC_HEADERS : STANDARD_HEADERS;
@@ -340,7 +348,7 @@ export function updateFromHeaders(provider, connectionId, headers, status, model
if (status === 429) {
const retryAfterMs = parseResetTime(retryAfterStr) || 60000; // Default 60s
const counts = limiter.counts();
const limiterKey = `${provider}:${connectionId}`;
const limiterKey = getLimiterKey(provider, connectionId, model);
console.log(
`🚫 [RATE-LIMIT] ${provider}:${connectionId.slice(0, 8)} — 429 received, pausing for ${Math.ceil(retryAfterMs / 1000)}s, dropping ${counts.QUEUED} queued request(s)`
);
@@ -397,7 +405,12 @@ export function updateFromHeaders(provider, connectionId, headers, status, model
limiter.updateSettings(updates);
// Persist learned limits (debounced)
recordLearnedLimit(provider, connectionId, { limit, remaining, minTime: updates.minTime });
recordLearnedLimit(
provider,
connectionId,
{ limit, remaining, minTime: updates.minTime },
model
);
}
}
@@ -459,9 +472,10 @@ export function getLearnedLimits() {
function recordLearnedLimit(
provider: string,
connectionId: string,
limits: Partial<Omit<LearnedLimitEntry, "provider" | "connectionId" | "lastUpdated">>
limits: Partial<Omit<LearnedLimitEntry, "provider" | "connectionId" | "lastUpdated">>,
model: string | null = null
) {
const key = `${provider}:${connectionId}`;
const key = getLimiterKey(provider, connectionId, model);
learnedLimits[key] = {
...limits,
provider,

View File

@@ -41,7 +41,13 @@ const SESSION_TTL_MS = 30 * 60 * 1000;
const _cleanupTimer = setInterval(() => {
const now = Date.now();
for (const [key, entry] of sessions) {
if (now - entry.lastActive > SESSION_TTL_MS) sessions.delete(key);
if (now - entry.lastActive > SESSION_TTL_MS) {
sessions.delete(key);
for (const [apiKeyId, sessionSet] of activeSessionsByKey) {
sessionSet.delete(key);
if (sessionSet.size === 0) activeSessionsByKey.delete(apiKeyId);
}
}
}
}, 60_000);
_cleanupTimer.unref();
@@ -191,6 +197,17 @@ export function getActiveSessionCountForKey(apiKeyId: string): number {
return activeSessionsByKey.get(apiKeyId)?.size ?? 0;
}
/**
* Snapshot of active session counts per API key.
*/
export function getAllActiveSessionCountsByKey(): Record<string, number> {
const out: Record<string, number> = {};
for (const [apiKeyId, sessionIds] of activeSessionsByKey) {
out[apiKeyId] = sessionIds.size;
}
return out;
}
/**
* T08: Register a session as belonging to an API key.
* Call this after session creation is allowed (i.e., limit check passed).
@@ -202,6 +219,13 @@ export function registerKeySession(apiKeyId: string, sessionId: string): void {
activeSessionsByKey.get(apiKeyId)!.add(sessionId);
}
/**
* Check whether a given session is already registered for an API key.
*/
export function isSessionRegisteredForKey(apiKeyId: string, sessionId: string): boolean {
return activeSessionsByKey.get(apiKeyId)?.has(sessionId) === true;
}
/**
* T08: Unregister a session from an API key's active set.
* Call this when the request closes or the session TTL expires.
@@ -256,6 +280,7 @@ export function extractExternalSessionId(
const h = headers as Headers;
const raw =
h.get("x-session-id") ?? // Preferred: hyphenated (passes through Nginx)
h.get("x_session_id") ?? // Underscore variant (direct HTTP / custom clients)
h.get("x-omniroute-session") ?? // OmniRoute-specific form
h.get("session-id") ?? // Bare session-id
null;

View File

@@ -13,12 +13,14 @@ export const ThinkingMode = {
ADAPTIVE: "adaptive", // Scale based on request complexity
};
import { capThinkingBudget, getDefaultThinkingBudget } from "@/shared/constants/modelSpecs";
// Effort → budget token mapping
export const EFFORT_BUDGETS = {
none: 0,
low: 1024,
medium: 10240,
high: 131072,
high: 131072, // Handled globally by capThinkingBudget later
max: 131072, // T11: Claude "max" / "xhigh" — full budget
xhigh: 131072, // T11: explicit alias used internally
};
@@ -27,9 +29,9 @@ export const EFFORT_BUDGETS = {
// Used when clients send string-based thinking levels (e.g., VS Code Copilot)
export const THINKING_LEVEL_MAP = {
none: 0,
low: 1024,
medium: 10240,
high: 131072,
low: 4096,
medium: 8192,
high: 24576,
max: 131072, // T11: max = full Claude budget (sub2api: xhigh)
xhigh: 131072, // T11: explicit xhigh alias
};
@@ -72,8 +74,9 @@ export function normalizeThinkingLevel(body) {
// Handle top-level thinkingLevel or thinking_level string fields
const levelStr = result.thinkingLevel || result.thinking_level;
if (typeof levelStr === "string" && THINKING_LEVEL_MAP[levelStr] !== undefined) {
const budget = THINKING_LEVEL_MAP[levelStr];
if (typeof levelStr === "string" && THINKING_LEVEL_MAP[levelStr.toLowerCase()] !== undefined) {
const rawBudget = THINKING_LEVEL_MAP[levelStr.toLowerCase()];
const budget = capThinkingBudget(result.model || "", rawBudget);
// Convert to Claude thinking format as canonical representation
result.thinking = {
type: budget > 0 ? "enabled" : "disabled",
@@ -87,15 +90,22 @@ export function normalizeThinkingLevel(body) {
const geminiLevel =
result.generationConfig?.thinkingConfig?.thinkingLevel ||
result.generationConfig?.thinking_config?.thinkingLevel;
if (typeof geminiLevel === "string" && THINKING_LEVEL_MAP[geminiLevel] !== undefined) {
const budget = THINKING_LEVEL_MAP[geminiLevel];
if (
typeof geminiLevel === "string" &&
THINKING_LEVEL_MAP[geminiLevel.toLowerCase()] !== undefined
) {
const rawBudget = THINKING_LEVEL_MAP[geminiLevel.toLowerCase()];
const budget = capThinkingBudget(result.model || "", rawBudget);
result.generationConfig = {
...result.generationConfig,
thinking_config: { thinking_budget: budget },
thinkingConfig: { ...result.generationConfig.thinkingConfig, thinkingBudget: budget },
};
// Clean up camelCase variant if it was the source
// Clean up string variants
if (result.generationConfig.thinkingConfig) {
delete result.generationConfig.thinkingConfig;
delete result.generationConfig.thinkingConfig.thinkingLevel;
}
if (result.generationConfig.thinking_config) {
delete result.generationConfig.thinking_config;
}
}
@@ -122,7 +132,7 @@ export function ensureThinkingConfig(body) {
const result = { ...body };
result.thinking = {
type: "enabled",
budget_tokens: EFFORT_BUDGETS.medium, // 10240 default
budget_tokens: getDefaultThinkingBudget(model) || EFFORT_BUDGETS.medium,
};
return result;
}
@@ -257,8 +267,11 @@ function applyAdaptiveBudget(body, cfg) {
if (toolCount > 3) multiplier += 0.5;
if (lastMsgLength > 2000) multiplier += 0.3;
const baseBudget = EFFORT_BUDGETS[cfg.effortLevel] || EFFORT_BUDGETS.medium;
const budget = Math.min(Math.ceil(baseBudget * multiplier), 131072);
const baseBudget =
EFFORT_BUDGETS[cfg.effortLevel] ||
getDefaultThinkingBudget(body.model || "") ||
EFFORT_BUDGETS.medium;
const budget = capThinkingBudget(body.model || "", Math.ceil(baseBudget * multiplier));
return setCustomBudget(body, budget);
}

View File

@@ -1,5 +1,6 @@
import * as fs from 'fs';
import * as path from 'path';
import * as fs from "fs";
import * as path from "path";
import { resolveDataDir } from "../../src/lib/dataPaths";
/**
* Responses API Transformer
* Converts OpenAI Chat Completions SSE to Codex Responses API SSE format
@@ -39,7 +40,7 @@ export function createResponsesLogger(model, logsDir = null) {
const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15);
const uniqueId = Math.random().toString(36).slice(2, 8);
const baseDir = logsDir || (typeof process !== "undefined" ? process.cwd() : ".");
const baseDir = logsDir || resolveDataDir();
const logDir = path.join(baseDir, "logs", `responses_${model}_${timestamp}_${uniqueId}`);
try {
@@ -402,6 +403,16 @@ export function createResponsesApiTransformStream(logger = null) {
const newCallId = tc.id;
const funcName = tc.function?.name;
// T37: Prevent merging if a new tool_call uses the same index
if (state.funcCallIds[tcIdx] && newCallId && state.funcCallIds[tcIdx] !== newCallId) {
closeToolCall(controller, tcIdx);
delete state.funcCallIds[tcIdx];
delete state.funcNames[tcIdx];
delete state.funcArgsBuf[tcIdx];
delete state.funcArgsDone[tcIdx];
delete state.funcItemDone[tcIdx];
}
if (funcName) state.funcNames[tcIdx] = funcName;
if (!state.funcCallIds[tcIdx] && newCallId) {

View File

@@ -172,6 +172,9 @@ function convertEnumValuesToStrings(obj) {
if (obj.enum && Array.isArray(obj.enum)) {
obj.enum = obj.enum.map((v) => String(v));
if (!obj.type) {
obj.type = "string";
}
}
for (const value of Object.values(obj)) {

View File

@@ -0,0 +1,22 @@
const OPENAI_SIZE_TO_ASPECT_RATIO: Record<string, string> = {
"256x256": "1:1",
"512x512": "1:1",
"1024x1024": "1:1",
"1792x1024": "16:9",
"1024x1792": "9:16",
"1536x1024": "3:2",
"1024x1536": "2:3",
};
// Supports direct aspect ratios (e.g. "16:9")
const ASPECT_RATIO_PASSTHROUGH = /^\d+:\d+$/;
export function mapImageSize(sizeParam?: string | null): string {
if (!sizeParam) return "1:1"; // default
// Native aspect ratio (e.g. "16:9") — pass-through
if (ASPECT_RATIO_PASSTHROUGH.test(sizeParam)) return sizeParam;
// Map OpenAI sizes to aspect ratios
return OPENAI_SIZE_TO_ASPECT_RATIO[sizeParam] ?? "1:1";
}

View File

@@ -1,6 +1,10 @@
import { register } from "../registry.ts";
import { FORMATS } from "../formats.ts";
import { DEFAULT_SAFETY_SETTINGS, tryParseJSON } from "../helpers/geminiHelper.ts";
import {
DEFAULT_SAFETY_SETTINGS,
tryParseJSON,
cleanJSONSchemaForAntigravity,
} from "../helpers/geminiHelper.ts";
import { DEFAULT_THINKING_GEMINI_SIGNATURE } from "../../config/defaultThinkingSignature.ts";
/**
@@ -154,7 +158,9 @@ export function claudeToGeminiRequest(model, body, stream) {
functionDeclarations.push({
name: tool.name,
description: tool.description || "",
parameters: tool.input_schema || { type: "object", properties: {} },
parameters: cleanJSONSchemaForAntigravity(
tool.input_schema || { type: "object", properties: {} }
),
});
}
}

View File

@@ -3,6 +3,11 @@ import { FORMATS } from "../formats.ts";
import { DEFAULT_THINKING_GEMINI_SIGNATURE } from "../../config/defaultThinkingSignature.ts";
import { ANTIGRAVITY_DEFAULT_SYSTEM } from "../../config/constants.ts";
import { openaiToClaudeRequestForAntigravity } from "./openai-to-claude.ts";
import {
capMaxOutputTokens,
capThinkingBudget,
getDefaultThinkingBudget,
} from "../../../src/shared/constants/modelSpecs.ts";
function generateUUID() {
return crypto.randomUUID();
@@ -88,7 +93,9 @@ function openaiToGeminiBase(model, body, stream) {
result.generationConfig.topK = body.top_k;
}
if (body.max_tokens !== undefined) {
result.generationConfig.maxOutputTokens = body.max_tokens;
result.generationConfig.maxOutputTokens = capMaxOutputTokens(model, body.max_tokens);
} else {
result.generationConfig.maxOutputTokens = capMaxOutputTokens(model);
}
// Build tool_call_id -> name map
@@ -283,8 +290,12 @@ export function openaiToGeminiCLIRequest(model, body, stream) {
// Add thinking config for CLI
if (body.reasoning_effort) {
const budgetMap = { low: 1024, medium: 8192, high: 32768 };
const budget = budgetMap[body.reasoning_effort] || 8192;
const budgetMap = {
low: 1024,
medium: getDefaultThinkingBudget(model) || 8192,
high: capThinkingBudget(model, 32768),
};
const budget = budgetMap[body.reasoning_effort] || getDefaultThinkingBudget(model) || 8192;
gemini.generationConfig.thinkingConfig = {
thinkingBudget: budget,
include_thoughts: true,

View File

@@ -257,6 +257,17 @@ function emitToolCall(state, emit, tc) {
const newCallId = tc.id;
const funcName = tc.function?.name;
// T37: If we already have a tool call at this index but the ID changed,
// we must close the current one and start a new one to prevent merging.
if (state.funcCallIds[tcIdx] && newCallId && state.funcCallIds[tcIdx] !== newCallId) {
closeToolCall(state, emit, tcIdx);
delete state.funcCallIds[tcIdx];
delete state.funcNames[tcIdx];
delete state.funcArgsBuf[tcIdx];
delete state.funcArgsDone[tcIdx];
delete state.funcItemDone[tcIdx];
}
if (funcName) state.funcNames[tcIdx] = funcName;
if (!state.funcCallIds[tcIdx] && newCallId) {

View File

@@ -0,0 +1,31 @@
/**
* AI SDK compatibility helpers (T26).
*/
/**
* Detects when a client explicitly prefers JSON (non-SSE) responses.
*/
export function clientWantsJsonResponse(acceptHeader: unknown): boolean {
if (typeof acceptHeader !== "string") return false;
const normalized = acceptHeader.toLowerCase();
return normalized.includes("application/json") && !normalized.includes("text/event-stream");
}
/**
* Resolves stream behavior from request body + Accept header.
* OpenAI-compatible behavior: stream only when `stream: true` and client did not force JSON.
*/
export function resolveStreamFlag(bodyStream: unknown, acceptHeader: unknown): boolean {
return bodyStream === true && !clientWantsJsonResponse(acceptHeader);
}
/**
* Removes surrounding markdown code fences when Claude wraps JSON payloads.
* Example: ```json\n{"ok":true}\n``` -> {"ok":true}
*/
export function stripMarkdownCodeFence(text: unknown): unknown {
if (typeof text !== "string") return text;
const codeBlockRegex = /^```(?:json|javascript|typescript|js|ts)?\s*\n?([\s\S]*?)\n?```\s*$/i;
const match = text.trim().match(codeBlockRegex);
return match ? match[1].trim() : text;
}

View File

@@ -12,6 +12,7 @@ type PendingToolCall = {
export function transformToOllama(response, model) {
let buffer = "";
let pendingToolCalls: Record<number, PendingToolCall> = {};
const completedToolCalls: PendingToolCall[] = [];
const transform = new TransformStream({
transform(chunk, controller) {
@@ -41,6 +42,13 @@ export function transformToOllama(response, model) {
if (toolCalls) {
for (const tc of toolCalls) {
const idx = tc.index;
// T37: Prevent merging tool_calls on same index if ID changes
if (pendingToolCalls[idx] && tc.id && pendingToolCalls[idx].id !== tc.id) {
completedToolCalls.push(pendingToolCalls[idx]);
delete pendingToolCalls[idx];
}
if (!pendingToolCalls[idx]) {
pendingToolCalls[idx] = { id: tc.id, function: { name: "", arguments: "" } };
}
@@ -59,7 +67,7 @@ export function transformToOllama(response, model) {
const finishReason = parsed.choices?.[0]?.finish_reason;
if (finishReason === "tool_calls" || finishReason === "stop") {
const toolCallsArr = Object.values(pendingToolCalls);
const toolCallsArr = [...completedToolCalls, ...Object.values(pendingToolCalls)];
if (toolCallsArr.length > 0) {
const formattedCalls = toolCallsArr.map((tc) => ({
function: {

View File

@@ -6,6 +6,7 @@ import {
proxyUrlForLogs,
} from "./proxyDispatcher.ts";
import tlsClient from "./tlsClient.ts";
import { isProxyReachable } from "@/lib/proxyHealth";
function isTlsFingerprintEnabled() {
return process.env.ENABLE_TLS_FINGERPRINT === "true";
@@ -134,6 +135,22 @@ export async function runWithProxyContext(proxyConfig, fn) {
const resolvedProxyUrl = proxyConfig ? proxyConfigToUrl(proxyConfig) : null;
// T14: Proxy Fast-Fail
// Perform a short TCP reachability check before issuing upstream requests.
if (resolvedProxyUrl) {
const reachable = await isProxyReachable(resolvedProxyUrl);
if (!reachable) {
const proxyLabel = proxyUrlForLogs(resolvedProxyUrl);
const err = new Error(`[Proxy Fast-Fail] Proxy unreachable: ${proxyLabel}`) as Error & {
code?: string;
statusCode?: number;
};
err.code = "PROXY_UNREACHABLE";
err.statusCode = 503;
throw err;
}
}
return proxyContext.run(proxyConfig || null, async () => {
if (resolvedProxyUrl) {
console.log(

View File

@@ -16,10 +16,8 @@ async function ensureNodeModules() {
try {
fs = await import("fs");
path = await import("path");
LOGS_DIR = path.join(
typeof process !== "undefined" && process.cwd ? process.cwd() : ".",
"logs"
);
const { resolveDataDir } = await import("../../src/lib/dataPaths");
LOGS_DIR = path.join(resolveDataDir(), "logs");
} catch {
// Running in non-Node environment (Worker, Browser, etc.)
}

View File

@@ -222,16 +222,17 @@ export function createSSEStream(options: StreamOptions = {}) {
const extracted = extractUsage(parsed);
if (extracted) {
// Non-destructive merge: never overwrite a positive value with 0
// message_start carries input_tokens, message_delta carries output_tokens
if (!usage) usage = {};
if (extracted.prompt_tokens > 0) usage.prompt_tokens = extracted.prompt_tokens;
if (extracted.completion_tokens > 0)
usage.completion_tokens = extracted.completion_tokens;
if (extracted.total_tokens > 0) usage.total_tokens = extracted.total_tokens;
if (extracted.cache_read_input_tokens)
usage.cache_read_input_tokens = extracted.cache_read_input_tokens;
if (extracted.cache_creation_input_tokens)
usage.cache_creation_input_tokens = extracted.cache_creation_input_tokens;
// message_start carries input_tokens, message_delta carries output_tokens;
if (!usage) usage = {} as any;
const u = usage as Record<string, number>;
const eu = extracted as Record<string, number>;
if (eu.prompt_tokens > 0) u.prompt_tokens = eu.prompt_tokens;
if (eu.completion_tokens > 0) u.completion_tokens = eu.completion_tokens;
if (eu.total_tokens > 0) u.total_tokens = eu.total_tokens;
if (eu.cache_read_input_tokens)
u.cache_read_input_tokens = eu.cache_read_input_tokens;
if (eu.cache_creation_input_tokens)
u.cache_creation_input_tokens = eu.cache_creation_input_tokens;
}
// Track content length and accumulate from Claude format
if (parsed.delta?.text) {
@@ -263,6 +264,11 @@ export function createSSEStream(options: StreamOptions = {}) {
}
}
// T18: Track if we saw tool calls
if (delta?.tool_calls && delta.tool_calls.length > 0) {
(state as any).passthroughHasToolCalls = true;
}
const content = delta?.content || delta?.reasoning_content;
if (content && typeof content === "string") {
totalContentLength += content.length;
@@ -278,6 +284,20 @@ export function createSSEStream(options: StreamOptions = {}) {
}
const isFinishChunk = parsed.choices?.[0]?.finish_reason;
// T18: Normalize finish_reason to 'tool_calls' if tool calls were used
if (
isFinishChunk &&
(state as any).passthroughHasToolCalls &&
parsed.choices[0].finish_reason !== "tool_calls"
) {
parsed.choices[0].finish_reason = "tool_calls";
// If we modify it, we must output the modified object
if (!injectedUsage && hasValidUsage(parsed.usage)) {
output = `data: ${JSON.stringify(parsed)}\n`;
injectedUsage = true;
}
}
if (isFinishChunk && !hasValidUsage(parsed.usage)) {
const estimated = estimateUsage(body, totalContentLength, FORMATS.OPENAI);
parsed.usage = filterUsageForFormat(estimated, FORMATS.OPENAI);
@@ -529,19 +549,17 @@ export function createSSEStream(options: StreamOptions = {}) {
if (!state.usage) {
state.usage = extracted;
} else {
if (extracted.prompt_tokens > 0)
state.usage.prompt_tokens = extracted.prompt_tokens;
if (extracted.completion_tokens > 0)
state.usage.completion_tokens = extracted.completion_tokens;
if (extracted.total_tokens > 0) state.usage.total_tokens = extracted.total_tokens;
if (extracted.cache_read_input_tokens > 0)
state.usage.cache_read_input_tokens = extracted.cache_read_input_tokens;
if (extracted.cache_creation_input_tokens > 0)
state.usage.cache_creation_input_tokens = extracted.cache_creation_input_tokens;
if (extracted.cached_tokens > 0)
state.usage.cached_tokens = extracted.cached_tokens;
if (extracted.reasoning_tokens > 0)
state.usage.reasoning_tokens = extracted.reasoning_tokens;
const su = state.usage as Record<string, number>;
const eu = extracted as Record<string, number>;
if (eu.prompt_tokens > 0) su.prompt_tokens = eu.prompt_tokens;
if (eu.completion_tokens > 0) su.completion_tokens = eu.completion_tokens;
if (eu.total_tokens > 0) su.total_tokens = eu.total_tokens;
if (eu.cache_read_input_tokens > 0)
su.cache_read_input_tokens = eu.cache_read_input_tokens;
if (eu.cache_creation_input_tokens > 0)
su.cache_creation_input_tokens = eu.cache_creation_input_tokens;
if (eu.cached_tokens > 0) su.cached_tokens = eu.cached_tokens;
if (eu.reasoning_tokens > 0) su.reasoning_tokens = eu.reasoning_tokens;
}
}

View File

@@ -134,7 +134,36 @@ export function createDisconnectAwareStream(transformStream, streamController) {
controller.enqueue(value);
} catch (error) {
streamController.handleError(error);
controller.error(error);
// T35: Encapsulate mid-stream errors as SSE events instead of abruptly aborting
// This prevents TransferEncodingError on the client side
const errorMsg = error instanceof Error ? error.message : "Upstream stream error";
const statusCode =
typeof error === "object" && error !== null && "statusCode" in error
? (error as any).statusCode
: 500;
const errorEvent = {
object: "chat.completion.chunk",
choices: [
{
index: 0,
delta: {},
finish_reason: "error",
},
],
error: {
message: errorMsg,
type: "upstream_error",
code: statusCode,
},
};
const encoder = new TextEncoder();
controller.enqueue(encoder.encode(`data: ${JSON.stringify(errorEvent)}\n\n`));
controller.enqueue(encoder.encode(`data: [DONE]\n\n`));
controller.close();
}
},

114
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "3.0.0-rc.5",
"version": "3.0.0-rc.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "3.0.0-rc.5",
"version": "3.0.0-rc.13",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -28,7 +28,7 @@
"jose": "^6.1.3",
"lowdb": "^7.0.1",
"monaco-editor": "^0.55.1",
"next": "^16.1.6",
"next": "^16.0.10",
"next-intl": "^4.8.3",
"node-machine-id": "^1.1.12",
"open": "^11.0.0",
@@ -61,7 +61,7 @@
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"eslint": "^9.39.2",
"eslint-config-next": "16.1.6",
"eslint-config-next": "^16.0.10",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"prettier": "^3.8.1",
@@ -2567,15 +2567,15 @@
}
},
"node_modules/@next/env": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.7.tgz",
"integrity": "sha512-rJJbIdJB/RQr2F1nylZr/PJzamvNNhfr3brdKP6s/GW850jbtR70QlSfFselvIBbcPUOlQwBakexjFzqLzF6pg==",
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.10.tgz",
"integrity": "sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.6.tgz",
"integrity": "sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==",
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.0.10.tgz",
"integrity": "sha512-b2NlWN70bbPLmfyoLvvidPKWENBYYIe017ZGUpElvQjDytCWgxPJx7L9juxHt0xHvNVA08ZHJdOyhGzon/KJuw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2583,9 +2583,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.7.tgz",
"integrity": "sha512-b2wWIE8sABdyafc4IM8r5Y/dS6kD80JRtOGrUiKTsACFQfWWgUQ2NwoUX1yjFMXVsAwcQeNpnucF2ZrujsBBPg==",
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.10.tgz",
"integrity": "sha512-4XgdKtdVsaflErz+B5XeG0T5PeXKDdruDf3CRpnhN+8UebNa5N2H58+3GDgpn/9GBurrQ1uWW768FfscwYkJRg==",
"cpu": [
"arm64"
],
@@ -2599,9 +2599,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.7.tgz",
"integrity": "sha512-zcnVaaZulS1WL0Ss38R5Q6D2gz7MtBu8GZLPfK+73D/hp4GFMrC2sudLky1QibfV7h6RJBJs/gOFvYP0X7UVlQ==",
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.10.tgz",
"integrity": "sha512-spbEObMvRKkQ3CkYVOME+ocPDFo5UqHb8EMTS78/0mQ+O1nqE8toHJVioZo4TvebATxgA8XMTHHrScPrn68OGw==",
"cpu": [
"x64"
],
@@ -2615,12 +2615,15 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.7.tgz",
"integrity": "sha512-2ant89Lux/Q3VyC8vNVg7uBaFVP9SwoK2jJOOR0L8TQnX8CAYnh4uctAScy2Hwj2dgjVHqHLORQZJ2wH6VxhSQ==",
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.10.tgz",
"integrity": "sha512-uQtWE3X0iGB8apTIskOMi2w/MKONrPOUCi5yLO+v3O8Mb5c7K4Q5KD1jvTpTF5gJKa3VH/ijKjKUq9O9UhwOYw==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2631,12 +2634,15 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.7.tgz",
"integrity": "sha512-uufcze7LYv0FQg9GnNeZ3/whYfo+1Q3HnQpm16o6Uyi0OVzLlk2ZWoY7j07KADZFY8qwDbsmFnMQP3p3+Ftprw==",
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.10.tgz",
"integrity": "sha512-llA+hiDTrYvyWI21Z0L1GiXwjQaanPVQQwru5peOgtooeJ8qx3tlqRV2P7uH2pKQaUfHxI/WVarvI5oYgGxaTw==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2647,12 +2653,15 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.7.tgz",
"integrity": "sha512-KWVf2gxYvHtvuT+c4MBOGxuse5TD7DsMFYSxVxRBnOzok/xryNeQSjXgxSv9QpIVlaGzEn/pIuI6Koosx8CGWA==",
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.10.tgz",
"integrity": "sha512-AK2q5H0+a9nsXbeZ3FZdMtbtu9jxW4R/NgzZ6+lrTm3d6Zb7jYrWcgjcpM1k8uuqlSy4xIyPR2YiuUr+wXsavA==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2663,12 +2672,15 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.7.tgz",
"integrity": "sha512-HguhaGwsGr1YAGs68uRKc4aGWxLET+NevJskOcCAwXbwj0fYX0RgZW2gsOCzr9S11CSQPIkxmoSbuVaBp4Z3dA==",
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.10.tgz",
"integrity": "sha512-1TDG9PDKivNw5550S111gsO4RGennLVl9cipPhtkXIFVwo31YZ73nEbLjNC8qG3SgTz/QZyYyaFYMeY4BKZR/g==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2679,9 +2691,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.7.tgz",
"integrity": "sha512-S0n3KrDJokKTeFyM/vGGGR8+pCmXYrjNTk2ZozOL1C/JFdfUIL9O1ATaJOl5r2POe56iRChbsszrjMAdWSv7kQ==",
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.10.tgz",
"integrity": "sha512-aEZIS4Hh32xdJQbHz121pyuVZniSNoqDVx1yIr2hy+ZwJGipeqnMZBJHyMxv2tiuAXGx6/xpTcQJ6btIiBjgmg==",
"cpu": [
"arm64"
],
@@ -2695,9 +2707,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.7.tgz",
"integrity": "sha512-mwgtg8CNZGYm06LeEd+bNnOUfwOyNem/rOiP14Lsz+AnUY92Zq/LXwtebtUiaeVkhbroRCQ0c8GlR4UT1U+0yg==",
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.10.tgz",
"integrity": "sha512-E+njfCoFLb01RAFEnGZn6ERoOqhK1Gl3Lfz1Kjnj0Ulfu7oJbuMyvBKNj/bw8XZnenHDASlygTjZICQW+rYW1Q==",
"cpu": [
"x64"
],
@@ -7522,6 +7534,7 @@
"version": "2.9.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
"integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.js"
@@ -9705,13 +9718,13 @@
}
},
"node_modules/eslint-config-next": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.6.tgz",
"integrity": "sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==",
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.0.10.tgz",
"integrity": "sha512-BxouZUm0I45K4yjOOIzj24nTi0H2cGo0y7xUmk+Po/PYtJXFBYVDS1BguE7t28efXjKdcN0tmiLivxQy//SsZg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@next/eslint-plugin-next": "16.1.6",
"@next/eslint-plugin-next": "16.0.10",
"eslint-import-resolver-node": "^0.3.6",
"eslint-import-resolver-typescript": "^3.5.2",
"eslint-plugin-import": "^2.32.0",
@@ -14658,14 +14671,13 @@
}
},
"node_modules/next": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/next/-/next-16.1.7.tgz",
"integrity": "sha512-WM0L7WrSvKwoLegLYr6V+mz+RIofqQgVAfHhMp9a88ms0cFX8iX9ew+snpWlSBwpkURJOUdvCEt3uLl3NNzvWg==",
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/next/-/next-16.0.10.tgz",
"integrity": "sha512-RtWh5PUgI+vxlV3HdR+IfWA1UUHu0+Ram/JBO4vWB54cVPentCD0e+lxyAYEsDTqGGMg7qpjhKh6dc6aW7W/sA==",
"license": "MIT",
"dependencies": {
"@next/env": "16.1.7",
"@next/env": "16.0.10",
"@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
"styled-jsx": "5.1.6"
@@ -14677,14 +14689,14 @@
"node": ">=20.9.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "16.1.7",
"@next/swc-darwin-x64": "16.1.7",
"@next/swc-linux-arm64-gnu": "16.1.7",
"@next/swc-linux-arm64-musl": "16.1.7",
"@next/swc-linux-x64-gnu": "16.1.7",
"@next/swc-linux-x64-musl": "16.1.7",
"@next/swc-win32-arm64-msvc": "16.1.7",
"@next/swc-win32-x64-msvc": "16.1.7",
"@next/swc-darwin-arm64": "16.0.10",
"@next/swc-darwin-x64": "16.0.10",
"@next/swc-linux-arm64-gnu": "16.0.10",
"@next/swc-linux-arm64-musl": "16.0.10",
"@next/swc-linux-x64-gnu": "16.0.10",
"@next/swc-linux-x64-musl": "16.0.10",
"@next/swc-win32-arm64-msvc": "16.0.10",
"@next/swc-win32-x64-msvc": "16.0.10",
"sharp": "^0.34.4"
},
"peerDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "3.0.0-rc.7",
"version": "3.0.0-rc.13",
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
"type": "module",
"bin": {
@@ -96,7 +96,7 @@
"jose": "^6.1.3",
"lowdb": "^7.0.1",
"monaco-editor": "^0.55.1",
"next": "^16.1.6",
"next": "^16.0.10",
"next-intl": "^4.8.3",
"node-machine-id": "^1.1.12",
"open": "^11.0.0",
@@ -125,7 +125,7 @@
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"eslint": "^9.39.2",
"eslint-config-next": "16.1.6",
"eslint-config-next": "^16.0.10",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"prettier": "^3.8.1",

View File

@@ -69,6 +69,7 @@ interface ApiKey {
noLog?: boolean;
autoResolve?: boolean;
isActive?: boolean;
maxSessions?: number;
accessSchedule?: AccessSchedule | null;
createdAt: string;
}
@@ -109,6 +110,7 @@ export default function ApiManagerPageClient() {
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [usageStats, setUsageStats] = useState<Record<string, KeyUsageStats>>({});
const [sessionCounts, setSessionCounts] = useState<Record<string, number>>({});
const { copied, copy } = useCopyToClipboard();
@@ -150,6 +152,7 @@ export default function ApiManagerPageClient() {
setKeys(data.keys || []);
// Fetch usage stats after keys are loaded
fetchUsageStats(data.keys || []);
fetchSessionCounts(data.keys || []);
}
} catch (error) {
console.log("Error fetching keys:", error);
@@ -187,6 +190,31 @@ export default function ApiManagerPageClient() {
}
};
const fetchSessionCounts = async (apiKeys: ApiKey[]) => {
if (apiKeys.length === 0) {
setSessionCounts({});
return;
}
try {
const res = await fetch("/api/sessions");
if (!res.ok) return;
const data = await res.json();
const byApiKeyRaw =
data && typeof data.byApiKey === "object" && !Array.isArray(data.byApiKey)
? data.byApiKey
: {};
const normalized: Record<string, number> = {};
for (const key of apiKeys) {
const value = byApiKeyRaw[key.id];
normalized[key.id] =
typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
}
setSessionCounts(normalized);
} catch (error) {
console.log("Error fetching session counts:", error);
}
};
const clearError = useCallback(() => setError(null), []);
const handleCreateKey = async () => {
@@ -266,6 +294,7 @@ export default function ApiManagerPageClient() {
allowedConnections: string[],
autoResolve: boolean,
isActive: boolean,
maxSessions: number,
accessSchedule: AccessSchedule | null
) => {
if (!editingKey || !editingKey.id) return;
@@ -291,6 +320,10 @@ export default function ApiManagerPageClient() {
const validConnections = allowedConnections.filter(
(id) => typeof id === "string" && /^[0-9a-f-]{36}$/i.test(id)
);
const normalizedMaxSessions =
typeof maxSessions === "number" && Number.isFinite(maxSessions)
? Math.max(0, Math.floor(maxSessions))
: 0;
setIsSubmitting(true);
clearError();
@@ -305,6 +338,7 @@ export default function ApiManagerPageClient() {
noLog,
autoResolve,
isActive,
maxSessions: normalizedMaxSessions,
accessSchedule,
}),
});
@@ -505,6 +539,9 @@ export default function ApiManagerPageClient() {
Array.isArray(key.allowedConnections) && key.allowedConnections.length > 0;
const noLogEnabled = key.noLog === true;
const keyIsActive = key.isActive !== false; // default true
const maxSessions = typeof key.maxSessions === "number" ? key.maxSessions : 0;
const hasSessionLimit = maxSessions > 0;
const activeSessions = sessionCounts[key.id] || 0;
const hasSchedule = key.accessSchedule?.enabled === true;
return (
<div
@@ -574,6 +611,12 @@ export default function ApiManagerPageClient() {
Auto-Resolve
</span>
)}
{hasSessionLimit && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-indigo-500/10 text-indigo-600 dark:text-indigo-400 text-[11px] font-medium">
<span className="material-symbols-outlined text-[12px]">group</span>
Sessions: {activeSessions}/{maxSessions}
</span>
)}
{!keyIsActive && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-red-500/10 text-red-600 dark:text-red-400 text-[11px] font-medium">
<span className="material-symbols-outlined text-[12px]">block</span>
@@ -778,6 +821,7 @@ const PermissionsModal = memo(function PermissionsModal({
connections: string[],
autoResolve: boolean,
isActive: boolean,
maxSessions: number,
accessSchedule: AccessSchedule | null
) => void;
}) {
@@ -794,6 +838,9 @@ const PermissionsModal = memo(function PermissionsModal({
const [noLogEnabled, setNoLogEnabled] = useState(apiKey?.noLog === true);
const [autoResolveEnabled, setAutoResolveEnabled] = useState(apiKey?.autoResolve === true);
const [keyIsActive, setKeyIsActive] = useState(apiKey?.isActive !== false);
const [maxSessions, setMaxSessions] = useState(
typeof apiKey?.maxSessions === "number" && apiKey.maxSessions > 0 ? apiKey.maxSessions : 0
);
const [scheduleEnabled, setScheduleEnabled] = useState(apiKey?.accessSchedule?.enabled === true);
const [scheduleFrom, setScheduleFrom] = useState(apiKey?.accessSchedule?.from ?? "08:00");
const [scheduleUntil, setScheduleUntil] = useState(apiKey?.accessSchedule?.until ?? "18:00");
@@ -905,6 +952,7 @@ const PermissionsModal = memo(function PermissionsModal({
allowAllConnections ? [] : selectedConnections,
autoResolveEnabled,
keyIsActive,
maxSessions,
schedule
);
}, [
@@ -916,6 +964,7 @@ const PermissionsModal = memo(function PermissionsModal({
selectedConnections,
autoResolveEnabled,
keyIsActive,
maxSessions,
scheduleEnabled,
scheduleFrom,
scheduleUntil,
@@ -1007,6 +1056,28 @@ const PermissionsModal = memo(function PermissionsModal({
</button>
</div>
{/* Max Sessions Limit (T08) */}
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">Max Active Sessions</p>
<p className="text-xs text-text-muted">
0 = unlimited. Return 429 when this key exceeds concurrent sticky sessions.
</p>
</div>
<div className="w-32">
<Input
type="number"
min={0}
step={1}
value={String(maxSessions)}
onChange={(e) => {
const parsed = Number.parseInt(e.target.value || "0", 10);
setMaxSessions(Number.isFinite(parsed) && parsed > 0 ? parsed : 0);
}}
/>
</div>
</div>
{/* Access Schedule */}
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex items-start justify-between gap-3">

View File

@@ -153,7 +153,7 @@ export default function DefaultToolCard({
};
// Check if this tool supports direct config file write
const supportsDirectSave = ["continue"].includes(toolId);
const supportsDirectSave = ["continue", "opencode"].includes(toolId);
const renderApiKeySelector = () => {
return (

View File

@@ -3017,6 +3017,7 @@ CooldownTimer.propTypes = {
const ERROR_TYPE_LABELS = {
runtime_error: { labelKey: "errorTypeRuntime", variant: "warning" },
upstream_auth_error: { labelKey: "errorTypeUpstreamAuth", variant: "error" },
account_deactivated: { labelKey: "Account Deactivated", variant: "error" },
auth_missing: { labelKey: "errorTypeMissingCredential", variant: "warning" },
token_refresh_failed: { labelKey: "errorTypeRefreshFailed", variant: "warning" },
token_expired: { labelKey: "errorTypeTokenExpired", variant: "warning" },
@@ -3025,10 +3026,14 @@ const ERROR_TYPE_LABELS = {
network_error: { labelKey: "errorTypeNetworkError", variant: "warning" },
unsupported: { labelKey: "errorTypeTestUnsupported", variant: "default" },
upstream_error: { labelKey: "errorTypeUpstreamError", variant: "error" },
banned: { labelKey: "403 Banned", variant: "error" },
credits_exhausted: { labelKey: "No Credits", variant: "warning" },
};
function inferErrorType(connection, isCooldown) {
if (isCooldown) return "upstream_rate_limited";
if (connection.testStatus === "banned") return "banned";
if (connection.testStatus === "credits_exhausted") return "credits_exhausted";
if (connection.lastErrorType) return connection.lastErrorType;
const code = Number(connection.errorCode);
@@ -3108,6 +3113,16 @@ function getStatusPresentation(connection, effectiveStatus, isCooldown, t) {
};
}
if (errorType === "account_deactivated") {
return {
statusVariant: "error",
statusLabel: t("statusDeactivated", "Deactivated"),
errorType,
errorBadge,
errorTextClass: "text-red-600 font-bold",
};
}
if (
errorType === "upstream_auth_error" ||
errorType === "auth_missing" ||
@@ -3153,6 +3168,26 @@ function getStatusPresentation(connection, effectiveStatus, isCooldown, t) {
};
}
if (errorType === "banned") {
return {
statusVariant: "error",
statusLabel: t("statusBanned", "Banned (403)"),
errorType,
errorBadge,
errorTextClass: "text-red-600 font-bold",
};
}
if (errorType === "credits_exhausted") {
return {
statusVariant: "warning",
statusLabel: t("statusCreditsExhausted", "Out of Credits"),
errorType,
errorBadge,
errorTextClass: "text-amber-500",
};
}
const fallbackStatusMap = {
unavailable: t("statusUnavailable"),
failed: t("statusFailed"),
@@ -3520,12 +3555,16 @@ function AddApiKeyModal({
const t = useTranslations("providers");
const isBailian = provider === "bailian-coding-plan";
const defaultBailianUrl = "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1";
const isVertex = provider === "vertex";
const defaultRegion = "us-central1";
const [formData, setFormData] = useState({
name: "",
apiKey: "",
priority: 1,
baseUrl: isBailian ? defaultBailianUrl : "",
region: isVertex ? defaultRegion : "",
validationModelId: "",
});
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState(null);
@@ -3539,7 +3578,11 @@ function AddApiKeyModal({
const res = await fetch("/api/providers/validate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider, apiKey: formData.apiKey }),
body: JSON.stringify({
provider,
apiKey: formData.apiKey,
validationModelId: formData.validationModelId || undefined,
}),
});
const data = await res.json();
setValidationResult(data.valid ? "success" : "failed");
@@ -3573,7 +3616,11 @@ function AddApiKeyModal({
const res = await fetch("/api/providers/validate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider, apiKey: formData.apiKey }),
body: JSON.stringify({
provider,
apiKey: formData.apiKey,
validationModelId: formData.validationModelId || undefined,
}),
});
const data = await res.json();
isValid = !!data.valid;
@@ -3602,6 +3649,10 @@ function AddApiKeyModal({
payload.providerSpecificData = {
baseUrl: validatedBailianBaseUrl,
};
} else if (isVertex) {
payload.providerSpecificData = {
region: formData.region,
};
}
const error = await onSave(payload);
@@ -3635,6 +3686,7 @@ function AddApiKeyModal({
value={formData.apiKey}
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
className="flex-1"
placeholder={isVertex ? "Cole o Service Account JSON aqui" : undefined}
/>
<div className="pt-6">
<Button
@@ -3667,6 +3719,13 @@ function AddApiKeyModal({
})}
</p>
)}
<Input
label="Model ID (opcional)"
placeholder="ex: grok-3 ou meta-llama/Llama-3.1-8B-Instruct"
value={formData.validationModelId}
onChange={(e) => setFormData({ ...formData, validationModelId: e.target.value })}
hint="Usado como fallback se a listagem de models não estiver disponível"
/>
<Input
label={t("priorityLabel")}
type="number"
@@ -3684,6 +3743,15 @@ function AddApiKeyModal({
hint="Optional: Custom base URL for bailian-coding-plan provider"
/>
)}
{isVertex && (
<Input
label="Região (Region)"
value={formData.region}
onChange={(e) => setFormData({ ...formData, region: e.target.value })}
placeholder={defaultRegion}
hint="ex: us-central1 ou europe-west4. Partner models usam a região global automaticamente."
/>
)}
<div className="flex gap-2">
<Button
onClick={handleSubmit}
@@ -3732,6 +3800,8 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
apiKey: "",
healthCheckInterval: 60,
baseUrl: "",
region: "",
validationModelId: "",
});
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState(null);
@@ -3744,17 +3814,23 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
const isBailian = connection?.provider === "bailian-coding-plan";
const defaultBailianUrl = "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1";
const isVertex = connection?.provider === "vertex";
const defaultRegion = "us-central1";
useEffect(() => {
if (connection) {
const rawBaseUrl = connection.providerSpecificData?.baseUrl;
const existingBaseUrl = typeof rawBaseUrl === "string" ? rawBaseUrl : "";
const rawRegion = connection.providerSpecificData?.region;
const existingRegion = typeof rawRegion === "string" ? rawRegion : "";
setFormData({
name: connection.name || "",
priority: connection.priority || 1,
apiKey: "",
healthCheckInterval: connection.healthCheckInterval ?? 60,
baseUrl: existingBaseUrl || (isBailian ? defaultBailianUrl : ""),
region: existingRegion || (isVertex ? defaultRegion : ""),
validationModelId: (connection.providerSpecificData?.validationModelId as string) || "",
});
// Load existing extra keys from providerSpecificData
const existing = connection.providerSpecificData?.extraApiKeys;
@@ -3771,7 +3847,13 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
setTesting(true);
setTestResult(null);
try {
const res = await fetch(`/api/providers/${connection.id}/test`, { method: "POST" });
const res = await fetch(`/api/providers/${connection.id}/test`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
validationModelId: formData.validationModelId || undefined,
}),
});
const data = await res.json();
setTestResult({
valid: !!data.valid,
@@ -3797,7 +3879,11 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
const res = await fetch("/api/providers/validate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider: connection.provider, apiKey: formData.apiKey }),
body: JSON.stringify({
provider: connection.provider,
apiKey: formData.apiKey,
validationModelId: formData.validationModelId || undefined,
}),
});
const data = await res.json();
setValidationResult(data.valid ? "success" : "failed");
@@ -3838,7 +3924,11 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
const res = await fetch("/api/providers/validate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider: connection.provider, apiKey: formData.apiKey }),
body: JSON.stringify({
provider: connection.provider,
apiKey: formData.apiKey,
validationModelId: formData.validationModelId || undefined,
}),
});
const data = await res.json();
isValid = !!data.valid;
@@ -3865,9 +3955,14 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
...(connection.providerSpecificData || {}),
extraApiKeys: extraApiKeys.filter((k) => k.trim().length > 0),
};
if (formData.validationModelId) {
updates.providerSpecificData.validationModelId = formData.validationModelId;
}
// Update baseUrl for bailian-coding-plan
if (isBailian) {
updates.providerSpecificData.baseUrl = validatedBailianBaseUrl;
} else if (isVertex) {
updates.providerSpecificData.region = formData.region;
}
}
const error = (await onSave(updates)) as void | unknown;
@@ -3935,7 +4030,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
type="password"
value={formData.apiKey}
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
placeholder={t("enterNewApiKey")}
placeholder={isVertex ? "Cole o Service Account JSON aqui" : t("enterNewApiKey")}
hint={t("leaveBlankKeepCurrentApiKey")}
className="flex-1"
/>
@@ -3959,6 +4054,13 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
{saveError}
</div>
)}
<Input
label="Model ID (opcional)"
placeholder="ex: grok-3 ou meta-llama/Llama-3.1-8B-Instruct"
value={formData.validationModelId}
onChange={(e) => setFormData({ ...formData, validationModelId: e.target.value })}
hint="Usado como fallback se a listagem de models não estiver disponível"
/>
</>
)}
@@ -3972,6 +4074,16 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
/>
)}
{isVertex && (
<Input
label="Região (Region)"
value={formData.region}
onChange={(e) => setFormData({ ...formData, region: e.target.value })}
placeholder={defaultRegion}
hint="ex: us-central1 ou europe-west4. Partner models usam a região global automaticamente."
/>
)}
{/* T07: Extra API Keys for round-robin rotation */}
{!isOAuth && (
<div className="flex flex-col gap-2">

View File

@@ -165,6 +165,7 @@ export default function ProviderLimitCard({
percentage={percentage}
unlimited={unlimited}
resetTime={quota.resetAt}
staleAfterReset={quota.staleAfterReset === true}
/>
);
})}

View File

@@ -71,6 +71,7 @@ export default function QuotaProgressBar({
total = 0,
unlimited = false,
resetTime = null,
staleAfterReset = false,
}) {
const colors = getColorClasses(percentage);
const countdown = formatResetTime(resetTime);
@@ -105,12 +106,17 @@ export default function QuotaProgressBar({
<span>
{used.toLocaleString()} / {total.toLocaleString()} requests
</span>
{countdown !== "-" && (
{staleAfterReset ? (
<div className="flex items-center gap-1">
<span></span>
<span className="font-medium">Refreshing...</span>
</div>
) : countdown !== "-" ? (
<div className="flex items-center gap-1">
<span></span>
<span className="font-medium">Reset in {countdown}</span>
</div>
)}
) : null}
</div>
{/* Reset time display */}

View File

@@ -92,6 +92,7 @@ export default function QuotaTable({ quotas = [] }) {
quota.remainingPercentage !== undefined
? Math.round(quota.remainingPercentage)
: calculatePercentage(quota.used, quota.total);
const staleAfterReset = quota.staleAfterReset === true;
const colors = getColorClasses(remaining);
const countdown = formatResetTime(quota.resetAt);
@@ -140,7 +141,9 @@ export default function QuotaTable({ quotas = [] }) {
{/* Reset Time */}
<td className="py-2 px-3">
{countdown !== t("notAvailableSymbol") || resetDisplay ? (
{staleAfterReset ? (
<div className="text-xs text-text-muted"> Refreshing...</div>
) : countdown !== t("notAvailableSymbol") || resetDisplay ? (
<div className="space-y-0.5">
{countdown !== t("notAvailableSymbol") && (
<div className="text-sm text-text-primary font-medium">

View File

@@ -122,6 +122,7 @@ export default function ProviderLimits() {
const intervalRef = useRef(null);
const countdownRef = useRef(null);
const lastFetchTimeRef = useRef({});
const staleProbeRef = useRef({});
const fetchConnections = useCallback(async () => {
try {
@@ -137,11 +138,12 @@ export default function ProviderLimits() {
}
}, []);
const fetchQuota = useCallback(async (connectionId, provider) => {
const fetchQuota = useCallback(async (connectionId, provider, options = {}) => {
const force = options?.force === true;
// Debounce: skip if last fetch was < MIN_FETCH_INTERVAL_MS ago
const now = Date.now();
const lastFetch = lastFetchTimeRef.current[connectionId] || 0;
if (now - lastFetch < MIN_FETCH_INTERVAL_MS) {
if (!force && now - lastFetch < MIN_FETCH_INTERVAL_MS) {
return; // Skip, data is still fresh
}
lastFetchTimeRef.current[connectionId] = now;
@@ -165,6 +167,20 @@ export default function ProviderLimits() {
}
const data = await response.json();
const parsedQuotas = parseQuotaData(provider, data);
// T13: If resetAt already passed but provider still returned stale cumulative usage,
// display 0 immediately and trigger a background probe to refresh snapshot.
const hasStaleAfterReset = parsedQuotas.some((q) => q?.staleAfterReset === true);
if (hasStaleAfterReset) {
const lastProbeAt = staleProbeRef.current[connectionId] || 0;
if (Date.now() - lastProbeAt >= MIN_FETCH_INTERVAL_MS) {
staleProbeRef.current[connectionId] = Date.now();
setTimeout(() => {
fetchQuota(connectionId, provider, { force: true }).catch(() => {});
}, 5000);
}
}
setQuotaData((prev) => ({
...prev,
[connectionId]: {
@@ -571,6 +587,7 @@ export default function ProviderLimits() {
const colors = getBarColor(remaining);
const cd = formatCountdown(q.resetAt);
const shortName = getShortModelName(q.name);
const staleAfterReset = q.staleAfterReset === true;
return (
<div key={i} className="flex items-center gap-1.5 min-w-[200px] shrink-0">
@@ -583,11 +600,15 @@ export default function ProviderLimits() {
</span>
{/* Countdown */}
{cd && (
{staleAfterReset ? (
<span className="text-[10px] text-text-muted whitespace-nowrap">
Refreshing...
</span>
) : cd ? (
<span className="text-[10px] text-text-muted whitespace-nowrap">
{cd}
</span>
)}
) : null}
{/* Progress bar */}
<div className="flex-1 h-1.5 rounded-sm bg-white/[0.06] min-w-[60px] overflow-hidden">

View File

@@ -76,6 +76,40 @@ export function calculatePercentage(used, total) {
return Math.round(((total - used) / total) * 100);
}
function isPastResetWindow(resetAt) {
if (!resetAt) return false;
const resetTime =
typeof resetAt === "number" ? resetAt : typeof resetAt === "string" ? Date.parse(resetAt) : NaN;
if (!Number.isFinite(resetTime)) return false;
return Date.now() >= resetTime;
}
function normalizeQuotaEntry(name, quota = {}, extras = {}) {
const usedRaw = Number(quota?.used || 0);
const totalRaw = Number(quota?.total || 0);
const resetAt = quota?.resetAt || null;
const staleAfterReset = isPastResetWindow(resetAt);
const used = staleAfterReset ? 0 : usedRaw;
const total = Number.isFinite(totalRaw) ? totalRaw : 0;
const remainingPercentageRaw = safePercentage(quota?.remainingPercentage);
const remainingPercentage =
staleAfterReset && total > 0
? 100
: remainingPercentageRaw !== undefined
? remainingPercentageRaw
: undefined;
return {
name,
used: Number.isFinite(used) ? used : 0,
total,
resetAt,
staleAfterReset,
...(remainingPercentage !== undefined ? { remainingPercentage } : {}),
...extras,
};
}
/**
* Parse provider-specific quota structures into normalized array
* @param {string} provider - Provider name (github, antigravity, codex, kiro, claude)
@@ -95,13 +129,7 @@ export function parseQuotaData(provider, data) {
if (quota?.unlimited && (!quota?.total || quota.total <= 0)) {
return;
}
normalizedQuotas.push({
name,
used: quota.used || 0,
total: quota.total || 0,
resetAt: quota.resetAt || null,
remainingPercentage: safePercentage(quota.remainingPercentage),
});
normalizedQuotas.push(normalizeQuotaEntry(name, quota));
});
}
break;
@@ -109,14 +137,11 @@ export function parseQuotaData(provider, data) {
case "antigravity":
if (data.quotas) {
Object.entries(data.quotas).forEach(([modelKey, quota]: [string, any]) => {
normalizedQuotas.push({
name: quota.displayName || modelKey,
modelKey: modelKey, // Keep modelKey for sorting
used: quota.used || 0,
total: quota.total || 0,
resetAt: quota.resetAt || null,
remainingPercentage: safePercentage(quota.remainingPercentage),
});
normalizedQuotas.push(
normalizeQuotaEntry(quota.displayName || modelKey, quota, {
modelKey: modelKey, // Keep modelKey for sorting
})
);
});
}
break;
@@ -124,12 +149,7 @@ export function parseQuotaData(provider, data) {
case "codex":
if (data.quotas) {
Object.entries(data.quotas).forEach(([quotaType, quota]: [string, any]) => {
normalizedQuotas.push({
name: quotaType,
used: quota.used || 0,
total: quota.total || 0,
resetAt: quota.resetAt || null,
});
normalizedQuotas.push(normalizeQuotaEntry(quotaType, quota));
});
}
break;
@@ -137,12 +157,7 @@ export function parseQuotaData(provider, data) {
case "kiro":
if (data.quotas) {
Object.entries(data.quotas).forEach(([quotaType, quota]: [string, any]) => {
normalizedQuotas.push({
name: quotaType,
used: quota.used || 0,
total: quota.total || 0,
resetAt: quota.resetAt || null,
});
normalizedQuotas.push(normalizeQuotaEntry(quotaType, quota));
});
}
break;
@@ -159,13 +174,7 @@ export function parseQuotaData(provider, data) {
});
} else if (data.quotas) {
Object.entries(data.quotas).forEach(([name, quota]: [string, any]) => {
normalizedQuotas.push({
name,
used: quota.used || 0,
total: quota.total || 0,
resetAt: quota.resetAt || null,
remainingPercentage: safePercentage(quota.remainingPercentage),
});
normalizedQuotas.push(normalizeQuotaEntry(name, quota));
});
}
break;
@@ -174,12 +183,7 @@ export function parseQuotaData(provider, data) {
// Generic fallback for unknown providers
if (data.quotas) {
Object.entries(data.quotas).forEach(([name, quota]: [string, any]) => {
normalizedQuotas.push({
name,
used: quota.used || 0,
total: quota.total || 0,
resetAt: quota.resetAt || null,
});
normalizedQuotas.push(normalizeQuotaEntry(name, quota));
});
}
}
@@ -218,11 +222,7 @@ export function normalizePlanTier(plan) {
const upper = raw.toUpperCase();
if (
upper.includes("PRO+") ||
upper.includes("PRO PLUS") ||
upper.includes("PROPLUS")
) {
if (upper.includes("PRO+") || upper.includes("PRO PLUS") || upper.includes("PROPLUS")) {
return { key: "plus", label: "Pro+", variant: "secondary", rank: 4, raw };
}

View File

@@ -12,6 +12,7 @@ import { createMultiBackup } from "@/shared/services/backupService";
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
import { cliModelConfigSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { getApiKeyById } from "@/lib/localDb";
const getCodexConfigPath = () => getCliConfigPaths("codex").config;
const getCodexAuthPath = () => getCliConfigPaths("codex").auth;
@@ -166,7 +167,8 @@ export async function POST(request: Request) {
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { baseUrl, apiKey, model } = validation.data;
const { baseUrl, model } = validation.data;
let { apiKey } = validation.data;
if (!apiKey) {
return NextResponse.json(
{ error: "baseUrl, apiKey and model are required" },
@@ -174,6 +176,21 @@ export async function POST(request: Request) {
);
}
// (#549) Resolve real key from DB if keyId was provided.
// The dashboard sends masked key strings — resolving by ID guarantees
// we always write the full key value to the config file.
const keyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null;
if (keyId) {
try {
const keyRecord = await getApiKeyById(keyId);
if (keyRecord?.key) {
apiKey = keyRecord.key as string;
}
} catch {
// Non-critical: fall back to whatever value was in apiKey
}
}
const codexDir = getCodexDir();
const configPath = getCodexConfigPath();
const authPath = getCodexAuthPath();

View File

@@ -12,6 +12,7 @@ import { createBackup } from "@/shared/services/backupService";
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
import { cliModelConfigSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { getApiKeyById } from "@/lib/localDb";
const getDroidSettingsPath = () => getCliPrimaryConfigPath("droid");
const getDroidDir = () => path.dirname(getDroidSettingsPath());
@@ -101,7 +102,21 @@ export async function POST(request: Request) {
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { baseUrl, apiKey, model } = validation.data;
const { baseUrl, model } = validation.data;
let { apiKey } = validation.data;
// (#549) Resolve real key from DB if keyId was provided.
const keyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null;
if (keyId) {
try {
const keyRecord = await getApiKeyById(keyId);
if (keyRecord?.key) {
apiKey = keyRecord.key as string;
}
} catch {
// Non-critical: fall back to whatever value was in apiKey
}
}
const droidDir = getDroidDir();
const settingsPath = getDroidSettingsPath();

View File

@@ -3,6 +3,8 @@ import fs from "fs/promises";
import path from "path";
import os from "os";
import { getRuntimePorts } from "@/lib/runtime/ports";
import { getOpenCodeConfigPath } from "@/shared/services/cliRuntime";
import { mergeOpenCodeConfig } from "@/shared/services/opencodeConfig";
import { guideSettingsSaveSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
@@ -10,7 +12,7 @@ import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
* POST /api/cli-tools/guide-settings/:toolId
*
* Save configuration for guide-based tools that have config files.
* Currently supports: continue
* Currently supports: continue, opencode
*/
export async function POST(request, { params }) {
let rawBody;
@@ -131,50 +133,39 @@ async function saveContinueConfig({ baseUrl, apiKey, model }) {
}
/**
* Save OpenCode config to ~/.config/opencode/config.toml (XDG_CONFIG_HOME aware).
* Save OpenCode config to:
* - Linux/macOS: ~/.config/opencode/opencode.json (XDG_CONFIG_HOME aware)
* - Windows: %APPDATA%/opencode/opencode.json
*
* (#524) OpenCode was silently failing because this handler was missing.
*/
async function saveOpenCodeConfig({ baseUrl, apiKey, model }) {
const { apiPort } = getRuntimePorts();
// Honour $XDG_CONFIG_HOME if set, otherwise use ~/.config per the XDG Base Directory spec
const xdgConfigHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
const configPath = path.join(xdgConfigHome, "opencode", "config.toml");
const configPath = getOpenCodeConfigPath();
const configDir = path.dirname(configPath);
// Ensure ~/.config/opencode/ exists
// Ensure config directory exists
await fs.mkdir(configDir, { recursive: true });
const normalizedBaseUrl = String(baseUrl || "")
.trim()
.replace(/\/+$/, "");
// Read existing TOML to preserve any user settings outside our block
let existingContent = "";
// Read existing JSON to preserve other provider entries
let existingConfig: Record<string, any> = {};
try {
existingContent = await fs.readFile(configPath, "utf-8");
const raw = await fs.readFile(configPath, "utf-8");
existingConfig = JSON.parse(raw);
} catch {
// File doesn't exist yet — start fresh
// File doesn't exist or invalid JSON — start fresh
}
// Build the OmniRoute TOML block.
// opencode config.toml uses the [provider.X] table format.
void apiPort; // available for future port-based detection
const omniBlock = `
# OmniRoute managed — updated automatically by OmniRoute CLI Tools
[provider.omniroute]
api_key = "${apiKey || "sk_omniroute"}"
base_url = "${normalizedBaseUrl}"
model = "${model}"
`;
const nextConfig = mergeOpenCodeConfig(existingConfig, {
baseUrl: normalizedBaseUrl,
apiKey,
model,
});
// Remove old OmniRoute-managed block (if any) then append fresh one
const cleanedContent = existingContent
.replace(/\n?# OmniRoute managed[\s\S]*?(?=\n\[|$)/, "")
.trimEnd();
const newContent = (cleanedContent ? cleanedContent + "\n" : "") + omniBlock;
await fs.writeFile(configPath, newContent, "utf-8");
await fs.writeFile(configPath, JSON.stringify(nextConfig, null, 2), "utf-8");
return NextResponse.json({
success: true,

View File

@@ -9,6 +9,7 @@ import { createBackup } from "@/shared/services/backupService";
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
import { cliModelConfigSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { getApiKeyById } from "@/lib/localDb";
const KILO_DATA_DIR = path.join(os.homedir(), ".local", "share", "kilo");
const AUTH_PATH = path.join(KILO_DATA_DIR, "auth.json");
@@ -133,7 +134,21 @@ export async function POST(request) {
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { baseUrl, apiKey, model } = validation.data;
const { baseUrl, model } = validation.data;
let { apiKey } = validation.data;
// (#549) Resolve real key from DB if keyId was provided.
const keyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null;
if (keyId) {
try {
const keyRecord = await getApiKeyById(keyId);
if (keyRecord?.key) {
apiKey = keyRecord.key as string;
}
} catch {
// Non-critical: fall back to whatever value was in apiKey
}
}
// Ensure directories exist
await fs.mkdir(KILO_DATA_DIR, { recursive: true });

View File

@@ -62,6 +62,7 @@ export async function PATCH(request, { params }) {
noLog,
autoResolve,
isActive,
maxSessions,
accessSchedule,
} = validation.data;
@@ -72,6 +73,7 @@ export async function PATCH(request, { params }) {
if (noLog !== undefined) payload.noLog = noLog;
if (autoResolve !== undefined) payload.autoResolve = autoResolve;
if (isActive !== undefined) payload.isActive = isActive;
if (maxSessions !== undefined) payload.maxSessions = maxSessions;
if (accessSchedule !== undefined) payload.accessSchedule = accessSchedule;
const updated = await updateApiKeyPermissions(id, payload);
@@ -90,6 +92,7 @@ export async function PATCH(request, { params }) {
...(noLog !== undefined && { noLog }),
...(autoResolve !== undefined && { autoResolve }),
...(isActive !== undefined && { isActive }),
...(maxSessions !== undefined && { maxSessions }),
...(accessSchedule !== undefined && { accessSchedule }),
});
} catch (error) {

View File

@@ -4,6 +4,7 @@ import {
isOpenAICompatibleProvider,
isAnthropicCompatibleProvider,
} from "@/shared/constants/providers";
import { PROVIDER_MODELS } from "@/shared/constants/models";
type JsonRecord = Record<string, unknown>;
@@ -336,39 +337,85 @@ export async function GET(request, { params }) {
);
}
let modelsUrl = baseUrl.replace(/\/$/, "");
if (modelsUrl.endsWith("/chat/completions")) {
modelsUrl = modelsUrl.slice(0, -17) + "/models";
} else if (modelsUrl.endsWith("/completions")) {
modelsUrl = modelsUrl.slice(0, -12) + "/models";
} else {
modelsUrl = `${modelsUrl}/models`;
let base = baseUrl.replace(/\/$/, "");
if (base.endsWith("/chat/completions")) {
base = base.slice(0, -17);
} else if (base.endsWith("/completions")) {
base = base.slice(0, -12);
} else if (base.endsWith("/v1")) {
base = base.slice(0, -3);
}
const response = await fetch(modelsUrl, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
});
// T39: Try multiple endpoint formats
const endpoints = [
`${base}/v1/models`,
`${base}/models`,
`${baseUrl.replace(/\/$/, "")}/models`, // Original fallback
];
if (!response.ok) {
const errorText = await response.text();
console.log(`Error fetching models from ${provider}:`, errorText);
return NextResponse.json(
{ error: `Failed to fetch models: ${response.status}` },
{ status: response.status }
);
// Remove duplicates
const uniqueEndpoints = [...new Set(endpoints)];
let models = null;
let lastErrorStatus = null;
for (const modelsUrl of uniqueEndpoints) {
try {
const response = await fetch(modelsUrl, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
signal: AbortSignal.timeout(5000), // Quick timeout for fallbacks
});
if (response.ok) {
const data = await response.json();
models = data.data || data.models || [];
break; // Success!
}
if (response.status === 401 || response.status === 403) {
lastErrorStatus = response.status;
throw new Error("auth_failed");
}
} catch (err: any) {
if (err.message === "auth_failed") break; // Don't try other endpoints if auth failed
}
}
const data = await response.json();
const models = data.data || data.models || [];
// If all endpoints failed (but not because of auth), fallback to local catalog
if (!models) {
if (lastErrorStatus === 401 || lastErrorStatus === 403) {
return NextResponse.json(
{ error: `Auth failed: ${lastErrorStatus}` },
{ status: lastErrorStatus }
);
}
console.warn(`[models] All endpoints failed for ${provider}, using local catalog`);
const localModels = PROVIDER_MODELS[provider] || [];
models = localModels.map((m: any) => ({
id: m.id,
name: m.name || m.id,
owned_by: provider,
}));
}
// Track source for MCP tool T39 requirement
const source =
models === null || (models && models.length > 0 && models[0].owned_by === provider)
? "local_catalog"
: "api";
return NextResponse.json({
provider,
connectionId,
models,
source,
...(source === "local_catalog"
? { warning: "API unavailable — using cached catalog" }
: {}),
});
}

View File

@@ -516,6 +516,7 @@ async function testApiKeyConnection(connection: any) {
return {
valid: !!result.valid,
error,
warning: result.warning || null,
diagnosis,
};
}
@@ -523,9 +524,10 @@ async function testApiKeyConnection(connection: any) {
/**
* Core test logic — reusable by test-batch without HTTP self-calls.
* @param {string} connectionId
* @param {string} validationModelId Optional custom model ID to test connection with
* @returns {Promise<object>} Test result (same shape as the JSON response)
*/
export async function testSingleConnection(connectionId: string) {
export async function testSingleConnection(connectionId: string, validationModelId?: string) {
const connection = await getProviderConnectionById(connectionId);
if (!connection) {
@@ -567,8 +569,17 @@ export async function testSingleConnection(connectionId: string) {
diagnosis: (runtime as any).diagnosis,
};
} else if (connection.authType === "apikey") {
const enrichedConnection = validationModelId
? {
...connection,
providerSpecificData: {
...((connection.providerSpecificData as any) || {}),
validationModelId,
},
}
: connection;
result = await runWithProxyContext(proxyInfo?.proxy || null, () =>
testApiKeyConnection(connection)
testApiKeyConnection(enrichedConnection)
);
} else {
result = await runWithProxyContext(proxyInfo?.proxy || null, () =>
@@ -657,6 +668,7 @@ export async function testSingleConnection(connectionId: string) {
return {
valid: result.valid,
error: result.error,
warning: result.warning || null,
refreshed: result.refreshed || false,
diagnosis,
latencyMs,
@@ -670,7 +682,17 @@ export async function testSingleConnection(connectionId: string) {
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const data = await testSingleConnection(id);
// Parse optional body for validationModelId
let validationModelId;
try {
const body = await request.json();
validationModelId = body?.validationModelId;
} catch {
// Body is optional
}
const data = await testSingleConnection(id, validationModelId);
if (data.error === "Connection not found") {
return NextResponse.json({ error: "Connection not found" }, { status: 404 });

View File

@@ -30,9 +30,9 @@ export async function POST(request) {
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { provider, apiKey } = validation.data;
const { provider, apiKey, validationModelId } = validation.data;
let providerSpecificData = {};
let providerSpecificData: any = { validationModelId };
if (isOpenAICompatibleProvider(provider) || isAnthropicCompatibleProvider(provider)) {
const node: any = await getProviderNodeById(provider);
@@ -44,6 +44,7 @@ export async function POST(request) {
);
}
providerSpecificData = {
...providerSpecificData,
baseUrl: node.baseUrl,
apiType: node.apiType,
};
@@ -62,6 +63,8 @@ export async function POST(request) {
return NextResponse.json({
valid: !!result.valid,
error: result.valid ? null : result.error || "Invalid API key",
warning: result.warning || null,
method: result.method || null,
});
} catch (error) {
console.log("Error validating API key:", error);

View File

@@ -2,13 +2,15 @@ import { NextResponse } from "next/server";
import {
getActiveSessions,
getActiveSessionCount,
getAllActiveSessionCountsByKey,
} from "@omniroute/open-sse/services/sessionManager.ts";
export async function GET() {
try {
const sessions = getActiveSessions();
const count = getActiveSessionCount();
return NextResponse.json({ count, sessions });
const byApiKey = getAllActiveSessionCountsByKey();
return NextResponse.json({ count, sessions, byApiKey });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}

View File

@@ -1,6 +1,36 @@
import { NextResponse } from "next/server";
import { getUsageDb } from "@/lib/usageDb";
import { computeAnalytics } from "@/lib/usageAnalytics";
import { getDbInstance } from "@/lib/db/core";
function getRangeStartIso(range: string): string | null {
const end = new Date();
const start = new Date(end);
switch (range) {
case "1d":
start.setDate(start.getDate() - 1);
break;
case "7d":
start.setDate(start.getDate() - 7);
break;
case "30d":
start.setDate(start.getDate() - 30);
break;
case "90d":
start.setDate(start.getDate() - 90);
break;
case "ytd":
start.setMonth(0, 1);
start.setHours(0, 0, 0, 0);
break;
case "all":
default:
return null;
}
return start.toISOString();
}
export async function GET(request) {
try {
@@ -36,6 +66,47 @@ export async function GET(request) {
const analytics = await computeAnalytics(history, range, connectionMap);
// T01: fallback transparency metrics from call_logs (requested_model vs routed model).
try {
const db = getDbInstance();
const sinceIso = getRangeStartIso(range);
const whereClause = sinceIso ? "WHERE timestamp >= @since" : "";
const row = db
.prepare(
`
SELECT
COUNT(*) as total,
SUM(CASE WHEN requested_model IS NOT NULL AND requested_model != '' THEN 1 ELSE 0 END) as with_requested,
SUM(CASE
WHEN requested_model IS NOT NULL
AND requested_model != ''
AND model IS NOT NULL
AND requested_model != model
THEN 1 ELSE 0 END
) as fallbacks
FROM call_logs
${whereClause}
`
)
.get(sinceIso ? { since: sinceIso } : {}) as
| { total?: number; with_requested?: number; fallbacks?: number }
| undefined;
const total = Number(row?.total || 0);
const withRequested = Number(row?.with_requested || 0);
const fallbackCount = Number(row?.fallbacks || 0);
analytics.summary.fallbackCount = fallbackCount;
analytics.summary.fallbackRatePct =
withRequested > 0 ? Number(((fallbackCount / withRequested) * 100).toFixed(2)) : 0;
analytics.summary.requestedModelCoveragePct =
total > 0 ? Number(((withRequested / total) * 100).toFixed(2)) : 0;
} catch {
analytics.summary.fallbackCount = 0;
analytics.summary.fallbackRatePct = 0;
analytics.summary.requestedModelCoveragePct = 0;
}
return NextResponse.json(analytics);
} catch (error) {
console.error("Error computing analytics:", error);

View File

@@ -36,10 +36,13 @@
/* Light theme */
--color-bg: #f9f9fb;
--color-bg-alt: #f0f0f5;
--color-bg-primary: #f9f9fb;
--color-bg-subtle: #f0f0f5;
--color-surface: #ffffff;
--color-sidebar: rgba(245, 245, 250, 0.8);
--color-border: rgba(0, 0, 0, 0.08);
--color-text-main: #1a1a2e;
--color-text-primary: #1a1a2e;
--color-text-muted: #71717a;
/* Shadows */
@@ -52,10 +55,13 @@
/* Dark theme (ClawHub deep) */
--color-bg: #0b0e14;
--color-bg-alt: #111520;
--color-bg-primary: #0b0e14;
--color-bg-subtle: #111520;
--color-surface: #161b22;
--color-sidebar: rgba(16, 20, 30, 0.8);
--color-border: rgba(255, 255, 255, 0.08);
--color-text-main: #e6e6ef;
--color-text-primary: #e6e6ef;
--color-text-muted: #a1a1aa;
/* Dark shadows */
@@ -81,10 +87,13 @@
/* Auto-switch colors (use CSS variables from :root/.dark) */
--color-bg: var(--color-bg);
--color-bg-primary: var(--color-bg-primary);
--color-bg-subtle: var(--color-bg-subtle);
--color-surface: var(--color-surface);
--color-sidebar: var(--color-sidebar);
--color-border: var(--color-border);
--color-text-main: var(--color-text-main);
--color-text-primary: var(--color-text-primary);
--color-text-muted: var(--color-text-muted);
/* Static colors (for explicit light/dark usage) */

View File

@@ -99,8 +99,10 @@ async function validateOpenAILikeProvider({
return { valid: false, error: `Validation failed: ${modelsRes.status}` };
}
const testModelId = (providerSpecificData as any)?.validationModelId || modelId;
const testBody = {
model: modelId,
model: testModelId,
messages: [{ role: "user", content: "test" }],
max_tokens: 1,
};
@@ -131,7 +133,13 @@ async function validateOpenAILikeProvider({
return { valid: true, error: null };
}
async function validateAnthropicLikeProvider({ apiKey, baseUrl, modelId, headers = {} }: any) {
async function validateAnthropicLikeProvider({
apiKey,
baseUrl,
modelId,
headers = {},
providerSpecificData = {},
}: any) {
if (!baseUrl) {
return { valid: false, error: "Missing base URL" };
}
@@ -149,11 +157,14 @@ async function validateAnthropicLikeProvider({ apiKey, baseUrl, modelId, headers
requestHeaders["anthropic-version"] = "2023-06-01";
}
const testModelId =
providerSpecificData?.validationModelId || modelId || "claude-3-5-sonnet-20241022";
const response = await fetch(baseUrl, {
method: "POST",
headers: requestHeaders,
body: JSON.stringify({
model: modelId || "claude-3-5-sonnet-20241022",
model: testModelId,
max_tokens: 1,
messages: [{ role: "user", content: "test" }],
}),
@@ -352,52 +363,104 @@ async function validateOpenAICompatibleProvider({ apiKey, providerSpecificData =
return { valid: false, error: "No base URL configured for OpenAI compatible provider" };
}
const validationModelId =
typeof providerSpecificData?.validationModelId === "string"
? providerSpecificData.validationModelId.trim()
: "";
// Step 1: Try GET /models
let modelsReachable = false;
try {
const modelsRes = await fetch(`${baseUrl}/models`, {
method: "GET",
headers: buildBearerHeaders(apiKey),
});
modelsReachable = true;
if (modelsRes.ok) {
return { valid: true, error: null };
return { valid: true, error: null, method: "models_endpoint" };
}
if (modelsRes.status === 401 || modelsRes.status === 403) {
return { valid: false, error: "Invalid API key" };
}
// Endpoint responded and auth seems valid, but quota is exhausted/rate-limited.
if (modelsRes.status === 429) {
return {
valid: true,
error: null,
method: "models_endpoint",
warning: "Rate limited, but credentials are valid",
};
}
} catch {
// /models fetch failed (network error, etc.) — fall through to chat test
}
// T25: if /models cannot be used and no custom model was provided, return a
// clear actionable message instead of a generic connection error.
if (!validationModelId) {
return {
valid: false,
error: "Endpoint /models unavailable. Provide a Model ID to validate via /chat/completions.",
};
}
// Step 2: Fallback — try a minimal chat completion request
// Many providers don't expose /models but accept chat completions fine
const apiType = providerSpecificData.apiType || "chat";
const chatSuffix = apiType === "responses" ? "/responses" : "/chat/completions";
const chatUrl = `${baseUrl}${chatSuffix}`;
const testModelId = validationModelId;
try {
const chatRes = await fetch(chatUrl, {
method: "POST",
headers: buildBearerHeaders(apiKey),
body: JSON.stringify({
model: "gpt-4o-mini",
model: testModelId,
messages: [{ role: "user", content: "test" }],
max_tokens: 1,
}),
});
if (chatRes.ok) {
return { valid: true, error: null };
return { valid: true, error: null, method: "chat_completions" };
}
if (chatRes.status === 401 || chatRes.status === 403) {
return { valid: false, error: "Invalid API key" };
}
if (chatRes.status === 429) {
return {
valid: true,
error: null,
method: "chat_completions",
warning: "Rate limited, but credentials are valid",
};
}
// If /models was reachable but returned non-auth error, and chat succeeds
// auth-wise, this still confirms credentials are valid.
if (chatRes.status === 400) {
return {
valid: true,
error: null,
method: "inference_available",
warning: "Model ID may be invalid, but credentials are valid",
};
}
// 4xx other than auth (e.g. 400 bad model, 422) usually means auth passed
if (chatRes.status >= 400 && chatRes.status < 500) {
return { valid: true, error: null };
return {
valid: true,
error: null,
method: "inference_available",
};
}
if (chatRes.status >= 500) {
@@ -410,6 +473,10 @@ async function validateOpenAICompatibleProvider({ apiKey, providerSpecificData =
// Step 3: Final fallback — simple connectivity check
// For local providers (Ollama, LM Studio, etc.) that may not respond to
// standard OpenAI endpoints but are still reachable
if (!modelsReachable) {
return { valid: false, error: "Connection failed while testing /chat/completions" };
}
try {
const pingRes = await fetch(baseUrl, {
method: "GET",
@@ -464,12 +531,13 @@ async function validateAnthropicCompatibleProvider({ apiKey, providerSpecificDat
}
// Step 2: Fallback — try a minimal messages request
const testModelId = providerSpecificData?.validationModelId || "claude-3-5-sonnet-20241022";
try {
const messagesRes = await fetch(`${baseUrl}/messages`, {
method: "POST",
headers,
body: JSON.stringify({
model: "claude-3-5-sonnet-20241022",
model: testModelId,
max_tokens: 1,
messages: [{ role: "user", content: "test" }],
}),
@@ -646,6 +714,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
baseUrl: requestBaseUrl,
modelId,
headers: requestHeaders,
providerSpecificData,
});
}

View File

@@ -330,7 +330,7 @@ export async function getCallLogs(filter: any = {}) {
}
if (filter.model) {
conditions.push("model LIKE @modelQ");
conditions.push("(model LIKE @modelQ OR requested_model LIKE @modelQ)");
params.modelQ = `%${filter.model}%`;
}
if (filter.provider) {
@@ -351,7 +351,8 @@ export async function getCallLogs(filter: any = {}) {
if (filter.search) {
conditions.push(`(
model LIKE @searchQ OR path LIKE @searchQ OR account LIKE @searchQ OR
provider LIKE @searchQ OR api_key_name LIKE @searchQ OR api_key_id LIKE @searchQ OR
requested_model LIKE @searchQ OR provider LIKE @searchQ OR
api_key_name LIKE @searchQ OR api_key_id LIKE @searchQ OR
combo_name LIKE @searchQ OR CAST(status AS TEXT) LIKE @searchQ
)`);
params.searchQ = `%${filter.search}%`;
@@ -408,6 +409,7 @@ export async function getCallLogById(id: string) {
path: toStringOrNull(entryRow.path),
status: toNumber(entryRow.status),
model: toStringOrNull(entryRow.model),
requestedModel: toStringOrNull(entryRow.requested_model),
provider: toStringOrNull(entryRow.provider),
account: toStringOrNull(entryRow.account),
connectionId: toStringOrNull(entryRow.connection_id),

View File

@@ -36,7 +36,7 @@ function PayloadSection({ title, json, onCopy }) {
{copied ? "Copied!" : "Copy"}
</button>
</div>
<pre className="p-4 rounded-xl bg-black/30 border border-border overflow-x-auto text-xs font-mono text-text-primary max-h-[600px] overflow-y-auto leading-relaxed whitespace-pre-wrap break-words">
<pre className="p-4 rounded-xl bg-black/5 dark:bg-black/30 border border-border overflow-x-auto text-xs font-mono text-text-main max-h-[600px] overflow-y-auto leading-relaxed whitespace-pre-wrap break-words">
{json}
</pre>
</div>
@@ -138,7 +138,7 @@ export default function RequestLoggerDetail({ log, detail, loading, onClose, onC
<span className="px-2 py-0.5 rounded bg-primary/20 text-primary text-xs font-bold">
In: {(detail?.tokens?.in || log.tokens?.in || 0).toLocaleString()}
</span>
<span className="px-2 py-0.5 rounded bg-emerald-500/20 text-emerald-400 text-xs font-bold">
<span className="px-2 py-0.5 rounded bg-emerald-500/20 text-emerald-700 dark:text-emerald-400 text-xs font-bold">
Out: {(detail?.tokens?.out || log.tokens?.out || 0).toLocaleString()}
</span>
</div>
@@ -147,6 +147,21 @@ export default function RequestLoggerDetail({ log, detail, loading, onClose, onC
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">Model</div>
<div className="text-sm font-medium text-primary font-mono">{log.model}</div>
</div>
<div>
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
Requested Model
</div>
<div
className={`text-sm font-medium font-mono ${
(detail?.requestedModel || log.requestedModel) &&
(detail?.requestedModel || log.requestedModel) !== log.model
? "text-amber-600 dark:text-amber-400"
: "text-text-muted"
}`}
>
{detail?.requestedModel || log.requestedModel || "—"}
</div>
</div>
<div>
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
Provider
@@ -198,7 +213,7 @@ export default function RequestLoggerDetail({ log, detail, loading, onClose, onC
<div>
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">Combo</div>
{detail?.comboName || log.comboName ? (
<span className="inline-block px-2.5 py-1 rounded-full text-[10px] font-bold bg-violet-500/20 text-violet-300 border border-violet-500/30">
<span className="inline-block px-2.5 py-1 rounded-full text-[10px] font-bold bg-violet-500/20 text-violet-700 dark:text-violet-300 border border-violet-500/30">
{detail?.comboName || log.comboName}
</span>
) : (
@@ -210,10 +225,12 @@ export default function RequestLoggerDetail({ log, detail, loading, onClose, onC
{/* Error Message */}
{(detail?.error || log.error) && (
<div className="p-4 rounded-xl bg-red-500/10 border border-red-500/30">
<div className="text-[10px] text-red-400 uppercase tracking-wider mb-1 font-bold">
<div className="text-[10px] text-red-600 dark:text-red-400 uppercase tracking-wider mb-1 font-bold">
Error
</div>
<div className="text-sm text-red-300 font-mono">{detail?.error || log.error}</div>
<div className="text-sm text-red-600 dark:text-red-300 font-mono">
{detail?.error || log.error}
</div>
</div>
)}

View File

@@ -29,6 +29,7 @@ const STATUS_FILTERS = [
const COLUMNS = [
{ key: "status", label: "Status" },
{ key: "model", label: "Model" },
{ key: "requestedModel", label: "Requested" },
{ key: "provider", label: "Provider" },
{ key: "protocol", label: "Protocol" },
{ key: "account", label: "Account" },
@@ -234,7 +235,9 @@ export default function RequestLoggerV2() {
// Unique accounts and providers for dropdowns
const uniqueAccounts = [...new Set(logs.map((l) => l.account).filter((a) => a && a !== "-"))];
const uniqueModels = [...new Set(logs.map((l) => l.model).filter(Boolean))].sort();
const uniqueModels = [
...new Set(logs.flatMap((l) => [l.model, l.requestedModel]).filter((value) => Boolean(value))),
].sort();
const uniqueProviders = [
...new Set(logs.map((l) => l.provider).filter((p) => p && p !== "-")),
].sort();
@@ -514,6 +517,11 @@ export default function RequestLoggerV2() {
Model
</th>
)}
{visibleColumns.requestedModel && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
Requested
</th>
)}
{visibleColumns.provider && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
Provider
@@ -596,6 +604,28 @@ export default function RequestLoggerV2() {
{log.model}
</td>
)}
{visibleColumns.requestedModel && (
<td className="px-3 py-2 font-mono text-[11px]">
{log.requestedModel ? (
<span
className={
log.requestedModel !== log.model
? "text-amber-600 dark:text-amber-400"
: "text-text-muted"
}
title={
log.requestedModel !== log.model
? `Requested ${log.requestedModel}, routed as ${log.model}`
: log.requestedModel
}
>
{log.requestedModel}
</span>
) : (
<span className="text-text-muted text-[10px]"></span>
)}
</td>
)}
{visibleColumns.provider && (
<td className="px-3 py-2">
<span

View File

@@ -114,7 +114,7 @@ export default function UsageAnalytics() {
</div>
{/* Summary Cards — Row 1: Core metrics */}
<div className="grid grid-cols-2 md:grid-cols-7 gap-3">
<div className="grid grid-cols-2 md:grid-cols-8 gap-3">
<StatCard
icon="generating_tokens"
label="Total Tokens"
@@ -142,10 +142,17 @@ export default function UsageAnalytics() {
<StatCard icon="group" label="Accounts" value={s.uniqueAccounts || 0} />
<StatCard icon="vpn_key" label="API Keys" value={s.uniqueApiKeys || 0} />
<StatCard icon="model_training" label="Models" value={s.uniqueModels || 0} />
<StatCard
icon="swap_horiz"
label="Fallback Rate"
value={`${Number(s.fallbackRatePct || 0).toFixed(1)}%`}
subValue={`${fmtFull(s.fallbackCount || 0)} fallbacks`}
color="text-amber-500"
/>
</div>
{/* Summary Cards — Row 2: Derived insights */}
<div className="grid grid-cols-2 md:grid-cols-7 gap-3">
<div className="grid grid-cols-2 md:grid-cols-8 gap-3">
<StatCard
icon="speed"
label="Avg Tokens/Req"
@@ -168,6 +175,12 @@ export default function UsageAnalytics() {
<StatCard icon="cloud" label="Top Provider" value={topProvider} color="text-teal-500" />
<StatCard icon="today" label="Busiest Day" value={busiestDay} color="text-rose-500" />
<StatCard icon="dns" label="Providers" value={providerCount} color="text-indigo-500" />
<StatCard
icon="rule"
label="Requested Coverage"
value={`${Number(s.requestedModelCoveragePct || 0).toFixed(1)}%`}
color="text-sky-500"
/>
</div>
{/* Activity Heatmap + Weekly Widgets */}

View File

@@ -16,6 +16,7 @@ export const CLI_TOOLS = {
},
modelAliases: ["default", "sonnet", "opus", "haiku", "opusplan"],
settingsFile: "~/.claude/settings.json",
defaultCommand: "claude",
defaultModels: [
{
id: "opus",
@@ -47,6 +48,7 @@ export const CLI_TOOLS = {
color: "#10A37F",
description: "OpenAI Codex CLI",
configType: "custom",
defaultCommand: "codex",
},
droid: {
id: "droid",
@@ -55,6 +57,7 @@ export const CLI_TOOLS = {
color: "#00D4FF",
description: "Factory Droid AI Assistant",
configType: "custom",
defaultCommand: "droid",
},
openclaw: {
id: "openclaw",
@@ -63,6 +66,7 @@ export const CLI_TOOLS = {
color: "#FF6B35",
description: "Open Claw AI Assistant",
configType: "custom",
defaultCommand: "openclaw",
},
cursor: {
id: "cursor",
@@ -72,6 +76,7 @@ export const CLI_TOOLS = {
description: "Cursor AI Code Editor",
configType: "guide",
requiresCloud: true,
defaultCommands: ["agent", "cursor"],
notes: [
{ type: "warning", text: "Requires Cursor Pro account to use this feature." },
{
@@ -95,6 +100,7 @@ export const CLI_TOOLS = {
color: "#00D1B2",
description: "Cline AI Coding Assistant CLI",
configType: "custom",
defaultCommand: "cline",
},
kilo: {
id: "kilo",
@@ -103,6 +109,7 @@ export const CLI_TOOLS = {
color: "#FF6B6B",
description: "Kilo Code AI Assistant CLI",
configType: "custom",
defaultCommand: "kilocode",
},
continue: {
id: "continue",
@@ -180,12 +187,47 @@ export const CLI_TOOLS = {
color: "#FF6B35",
description: "OpenCode AI coding agent (Terminal)",
configType: "guide",
defaultCommand: "opencode",
notes: [
{
type: "warning",
text: "Config path: Linux/macOS ~/.config/opencode/opencode.json • Windows %APPDATA%\\\\opencode\\\\opencode.json",
},
{
type: "warning",
text: 'Thinking variant example: opencode run "implement this feature" --model omniroute/claude-sonnet-4-5-thinking --variant high',
},
],
guideSteps: [
{ step: 1, title: "Install OpenCode", desc: "Install via npm: npm install -g opencode-ai" },
{ step: 2, title: "API Key", type: "apiKeySelector" },
{ step: 3, title: "Set Base URL", desc: "opencode config set baseUrl {{baseUrl}}" },
{ step: 4, title: "Select Model", type: "modelSelector" },
{
step: 5,
title: "Use Thinking Variant",
desc: "For thinking models, run with --variant high/low/max (example command below).",
},
],
codeBlock: {
language: "json",
code: `{
"providers": {
"omniroute": {
"name": "OmniRoute",
"api": "openai",
"baseURL": "{{baseUrl}}",
"apiKey": "{{apiKey}}",
"models": [
"{{model}}",
"claude-sonnet-4-5-thinking",
"gemini-3.1-pro-high",
"gemini-3-flash"
]
}
}
}`,
},
},
kiro: {
id: "kiro",

View File

@@ -0,0 +1,111 @@
/**
* Centralized specifications for AI Models.
* Contains maximum token caps and thinking budgets to prevent API errors
* when clients request more than the model supports.
*/
export interface ModelSpec {
maxOutputTokens: number;
contextWindow?: number;
defaultThinkingBudget?: number;
thinkingBudgetCap?: number;
thinkingOverhead?: number; // buffer de tokens para thinking
adaptiveMaxTokens?: number; // tokens disponíveis para output quando thinking ativo
aliases?: string[]; // IDs alternativos para este modelo
supportsThinking?: boolean;
supportsTools?: boolean;
supportsVision?: boolean;
}
export const MODEL_SPECS: Record<string, ModelSpec> = {
// ── Gemini 3 Flash series ───────────────────────────────────────
"gemini-3-flash": {
maxOutputTokens: 65536,
contextWindow: 1048576,
defaultThinkingBudget: 0,
thinkingBudgetCap: 0,
supportsThinking: false,
supportsTools: true,
supportsVision: true,
aliases: ["gemini-3-flash-preview", "gemini-3.1-flash-lite-preview"],
},
// ── Gemini 3.1 Pro High ─────────────────────────────────────────
"gemini-3.1-pro-high": {
maxOutputTokens: 131072,
contextWindow: 1048576,
defaultThinkingBudget: 24576,
thinkingBudgetCap: 32768,
thinkingOverhead: 1000,
supportsThinking: true,
supportsTools: true,
supportsVision: true,
aliases: ["gemini-3-pro-high"],
},
// ── Gemini 3.1 Pro Low ──────────────────────────────────────────
"gemini-3.1-pro-low": {
maxOutputTokens: 131072,
contextWindow: 1048576,
defaultThinkingBudget: 8192,
thinkingBudgetCap: 16000,
supportsThinking: true,
supportsTools: true,
supportsVision: true,
aliases: ["gemini-3-pro-low"],
},
// ── Claude Opus 4.5 ─────────────────────────────────────────────
"claude-opus-4-5": {
maxOutputTokens: 32768,
contextWindow: 200000,
defaultThinkingBudget: 10000,
thinkingBudgetCap: 32000,
supportsThinking: true,
supportsTools: true,
supportsVision: true,
},
// Defaults
__default__: {
maxOutputTokens: 8192,
},
};
export function getModelSpec(modelId: string): ModelSpec | undefined {
if (MODEL_SPECS[modelId]) return MODEL_SPECS[modelId];
// Buscas por alias
for (const [canonical, spec] of Object.entries(MODEL_SPECS)) {
if (spec.aliases?.includes(modelId)) return spec;
}
// Prefix matching
for (const [key, spec] of Object.entries(MODEL_SPECS)) {
if (key !== "__default__" && modelId.startsWith(key)) return spec;
}
return undefined;
}
export function capMaxOutputTokens(modelId: string, requested?: number): number {
const spec = getModelSpec(modelId);
const cap = spec?.maxOutputTokens ?? MODEL_SPECS.__default__.maxOutputTokens;
return requested ? Math.min(requested, cap) : cap;
}
export function getDefaultThinkingBudget(modelId: string): number {
return getModelSpec(modelId)?.defaultThinkingBudget ?? 0;
}
export function capThinkingBudget(modelId: string, budget: number): number {
const cap = getModelSpec(modelId)?.thinkingBudgetCap ?? budget;
return Math.min(budget, cap);
}
export function resolveModelAlias(modelId: string): string {
for (const [canonical, spec] of Object.entries(MODEL_SPECS)) {
if (spec.aliases?.includes(modelId)) return canonical;
}
return modelId;
}

View File

@@ -102,6 +102,21 @@ export const DEFAULT_PRICING = {
reasoning: 30.0,
cache_creation: 5.0,
},
// T12: fallback pricing for gpt-5.4 mini variants
"gpt-5.4-mini": {
input: 1.5,
output: 6.0,
cached: 0.75,
reasoning: 9.0,
cache_creation: 1.5,
},
"gpt5.4-mini": {
input: 1.5,
output: 6.0,
cached: 0.75,
reasoning: 9.0,
cache_creation: 1.5,
},
// GPT 5.3 Codex family (all same pricing tier)
"gpt-5.3-codex": GPT_5_3_CODEX_PRICING,
"gpt-5.3-codex-xhigh": GPT_5_3_CODEX_PRICING,
@@ -183,6 +198,13 @@ export const DEFAULT_PRICING = {
reasoning: 4.5,
cache_creation: 0.5,
},
"gemini-3.1-flash-lite-preview": {
input: 0.5,
output: 3.0,
cached: 0.03,
reasoning: 4.5,
cache_creation: 0.5,
},
"gemini-3-pro-preview": {
input: 2.0,
output: 12.0,
@@ -197,6 +219,20 @@ export const DEFAULT_PRICING = {
reasoning: 18.0,
cache_creation: 2.0,
},
"gemini-3-flash-preview": {
input: 0.5,
output: 3.0,
cached: 0.03,
reasoning: 4.5,
cache_creation: 0.5,
},
"gemini-3.1-flash-lite-preview": {
input: 0.5,
output: 3.0,
cached: 0.03,
reasoning: 4.5,
cache_creation: 0.5,
},
"gemini-2.5-pro": {
input: 2.0,
output: 12.0,
@@ -707,11 +743,11 @@ export const DEFAULT_PRICING = {
// GLM
glm: {
"glm-5": {
input: 1.0,
output: 3.2,
cached: 0.5,
reasoning: 4.8,
cache_creation: 1.0,
input: 0.38,
output: 1.98,
cached: 0.19,
reasoning: 2.97,
cache_creation: 0.38,
},
"glm-5-turbo": {
input: 1.2,
@@ -721,11 +757,11 @@ export const DEFAULT_PRICING = {
cache_creation: 1.2,
},
"glm-4.7": {
input: 0.75,
output: 3.0,
cached: 0.375,
reasoning: 4.5,
cache_creation: 0.75,
input: 0.38,
output: 1.98,
cached: 0.19,
reasoning: 2.97,
cache_creation: 0.38,
},
"glm-4.6": {
input: 0.5,
@@ -761,6 +797,20 @@ export const DEFAULT_PRICING = {
reasoning: 4.5,
cache_creation: 0.6,
},
"kimi-k2.5-thinking": {
input: 0.6,
output: 3.0,
cached: 0.3,
reasoning: 4.5,
cache_creation: 0.6,
},
"kimi-for-coding": {
input: 0.6,
output: 3.0,
cached: 0.3,
reasoning: 4.5,
cache_creation: 0.6,
},
"moonshot-kimi-k2.5": {
input: 0.6,
output: 3.0,
@@ -770,6 +820,30 @@ export const DEFAULT_PRICING = {
},
},
// Kimi Coding aliases (OAuth/API key)
kmc: {
"kimi-k2.5": { input: 0.6, output: 3.0, cached: 0.3, reasoning: 4.5, cache_creation: 0.6 },
"kimi-k2.5-thinking": {
input: 0.6,
output: 3.0,
cached: 0.3,
reasoning: 4.5,
cache_creation: 0.6,
},
"kimi-latest": { input: 1.0, output: 4.0, cached: 0.5, reasoning: 6.0, cache_creation: 1.0 },
},
kmca: {
"kimi-k2.5": { input: 0.6, output: 3.0, cached: 0.3, reasoning: 4.5, cache_creation: 0.6 },
"kimi-k2.5-thinking": {
input: 0.6,
output: 3.0,
cached: 0.3,
reasoning: 4.5,
cache_creation: 0.6,
},
"kimi-latest": { input: 1.0, output: 4.0, cached: 0.5, reasoning: 6.0, cache_creation: 1.0 },
},
// MiniMax
minimax: {
"minimax-m2.1": {
@@ -789,18 +863,18 @@ export const DEFAULT_PRICING = {
// MiniMax M2.5 — mais barato que M2.1, reasoning + tools
// Context: 204.800 tokens | Max Output: 16.384 tokens
"minimax-m2.5": {
input: 0.3,
output: 1.2,
cached: 0.15,
reasoning: 1.8,
cache_creation: 0.3,
input: 0.27,
output: 0.95,
cached: 0.135,
reasoning: 1.425,
cache_creation: 0.27,
},
"MiniMax-M2.5": {
input: 0.3,
output: 1.2,
cached: 0.15,
reasoning: 1.8,
cache_creation: 0.3,
input: 0.27,
output: 0.95,
cached: 0.135,
reasoning: 1.425,
cache_creation: 0.27,
},
// T12: MiniMax M2.7 — new default model (sub2api PR #1120)
// Upgraded from M2.5, same API endpoint api.minimax.io
@@ -1107,11 +1181,11 @@ export const DEFAULT_PRICING = {
// ─────────────────────────────────────────────────────────────────────
zai: {
"glm-5": {
input: 1.0,
output: 3.2,
cached: 0.5,
reasoning: 4.8,
cache_creation: 1.0,
input: 0.38,
output: 1.98,
cached: 0.19,
reasoning: 2.97,
cache_creation: 0.38,
},
"glm-5-turbo": {
input: 1.2,
@@ -1120,6 +1194,13 @@ export const DEFAULT_PRICING = {
reasoning: 6.0,
cache_creation: 1.2,
},
"glm-4.7": {
input: 0.38,
output: 1.98,
cached: 0.19,
reasoning: 2.97,
cache_creation: 0.38,
},
},
kiro: {

View File

@@ -98,7 +98,7 @@ const CLI_TOOLS: Record<string, any> = {
// opencode takes several seconds on cold start environments
healthcheckTimeoutMs: 15000,
paths: {
config: ".config/opencode/config.toml",
config: ".config/opencode/opencode.json",
},
},
};
@@ -197,15 +197,220 @@ const getRuntimeMode = () => {
return VALID_RUNTIME_MODES.has(mode) ? mode : "auto";
};
/**
* T12: Validate a CLI executable path to prevent shell injection.
* Enforces: absolute path, no dangerous shell metacharacters, must exist and be a file.
* Inspired by Antigravity Manager commit 96732c2 (Mar 11, 2026).
*/
const DANGEROUS_PATH_CHARS = ["&", "|", ";", "<", ">", "(", ")", "`", "$", "^", "%", "!"];
/**
* Check if a path is within a parent directory (case-insensitive, handles mixed separators).
* Normalizes both paths to forward slashes before comparison to handle
* inconsistent separator styles on Windows.
*/
const isPathWithin = (childPath: string, parentPath: string): boolean => {
// Normalize to forward slashes for consistent comparison
const normalize = (p: string) => path.normalize(p).toLowerCase().replace(/\\/g, "/");
const normalizedChild = normalize(childPath);
const normalizedParent = normalize(parentPath);
if (normalizedChild === normalizedParent) return true;
// Ensure parent ends with / for proper prefix matching
const parentWithSep = normalizedParent.endsWith("/") ? normalizedParent : normalizedParent + "/";
return normalizedChild.startsWith(parentWithSep);
};
const isSafePath = (execPath: string): boolean => {
if (!execPath || !path.isAbsolute(execPath)) return false;
if (DANGEROUS_PATH_CHARS.some((c) => execPath.includes(c))) return false;
// Allow path.sep and path.delimiter — no further character filtering needed
return true;
};
/**
* Validate that an environment variable value is a safe, absolute path
* within acceptable directory trees. Rejects traversal, special chars,
* and paths outside expected locations.
*/
const validateEnvPath = (value: string | undefined, allowedParents: string[]): string => {
if (!value) return "";
const trimmed = value.trim();
// Reject if not absolute
if (!path.isAbsolute(trimmed)) return "";
// Reject dangerous characters (same as isSafePath but applied to env vars)
if (DANGEROUS_PATH_CHARS.some((c) => trimmed.includes(c))) return "";
// Reject if contains path traversal segments
const normalized = path.normalize(trimmed);
if (normalized.includes("..")) return "";
// Reject if outside allowed parent directories
if (allowedParents.length > 0) {
const withinAllowed = allowedParents.some((parent) => isPathWithin(normalized, parent));
if (!withinAllowed) return "";
}
return normalized;
};
/**
* Pre-compute expected parent directories at module startup for performance.
* These are the allowed directories for CLI binary installation locations.
*/
const getExpectedParentPaths = (): string[] => {
const home = os.homedir();
const userProfile = process.env.USERPROFILE || home;
const validatedAppData = validateEnvPath(process.env.APPDATA, [home, userProfile]);
const validatedLocalAppData = validateEnvPath(process.env.LOCALAPPDATA, [
path.join(home, "AppData", "Local"),
path.join(userProfile, "AppData", "Local"),
userProfile,
]);
const validatedProgramFiles = validateEnvPath(process.env.ProgramFiles, [
"C:\\Program Files",
"C:\\Program Files (x86)",
]);
const validatedProgramFilesX86 = validateEnvPath(process.env["ProgramFiles(x86)"], [
"C:\\Program Files",
"C:\\Program Files (x86)",
]);
return [
home,
userProfile,
validatedAppData,
validatedLocalAppData,
validatedProgramFiles,
validatedProgramFilesX86,
].filter(Boolean);
};
// Cache expected parent paths at module startup (avoid recalculation on every checkKnownPath call)
const EXPECTED_PARENT_PATHS = getExpectedParentPaths();
const getExtraPaths = () =>
String(process.env.CLI_EXTRA_PATHS || "")
.split(path.delimiter)
.map((segment) => segment.trim())
.filter(Boolean);
.filter(Boolean)
.filter((p) => {
// Must be absolute
if (!path.isAbsolute(p)) return false;
// No dangerous characters
if (DANGEROUS_PATH_CHARS.some((c) => p.includes(c))) return false;
// No path traversal
if (path.normalize(p).includes("..")) return false;
return true;
});
/**
* Get known installation paths for a specific CLI tool on Windows.
* Returns ONLY verified, tool-specific paths - NOT generic user bin directories.
* This is more secure than searching PATH as it checks known locations only.
*/
const getKnownToolPaths = (toolId: string): string[] => {
if (!isWindows()) return [];
const home = os.homedir();
const userProfile = process.env.USERPROFILE || home;
// Validate environment paths against allowed parent directories
const appData = validateEnvPath(process.env.APPDATA, [home, userProfile]);
const localAppData = validateEnvPath(process.env.LOCALAPPDATA, [
path.join(home, "AppData", "Local"),
path.join(userProfile, "AppData", "Local"),
userProfile,
]);
// Cache nvm node path to avoid duplicate detection calls
const nvmNodePath = getNvmNodePath();
// Tool-specific known installation paths (verified locations only)
const knownPaths: Record<string, string[]> = {
claude: [
// Official Claude Code standalone installer locations
path.join(home, ".local", "bin", "claude.exe"),
...(localAppData ? [path.join(localAppData, "Programs", "Claude", "claude.exe")] : []),
...(localAppData ? [path.join(localAppData, "claude-code", "claude.exe")] : []),
// npm global (only if nvm-windows is detected)
...(nvmNodePath ? [path.join(nvmNodePath, "claude-code.cmd")] : []),
],
codex: [
path.join(home, ".local", "bin", "codex"),
// npm global (only if nvm-windows is detected)
...(nvmNodePath ? [path.join(nvmNodePath, "codex.cmd")] : []),
...(appData ? [path.join(appData, "npm", "codex.cmd")] : []),
],
droid: [
path.join(home, ".local", "bin", "droid"),
// npm global (only if nvm-windows is detected)
...(nvmNodePath ? [path.join(nvmNodePath, "droid.cmd")] : []),
...(appData ? [path.join(appData, "npm", "droid.cmd")] : []),
],
openclaw: [
path.join(home, ".local", "bin", "openclaw"),
// npm global (only if nvm-windows is detected)
...(nvmNodePath ? [path.join(nvmNodePath, "openclaw.cmd")] : []),
...(appData ? [path.join(appData, "npm", "openclaw.cmd")] : []),
],
cursor: [
path.join(home, ".local", "bin", "agent"),
path.join(home, ".local", "bin", "cursor"),
// npm global (only if nvm-windows is detected)
...(nvmNodePath ? [path.join(nvmNodePath, "agent.cmd")] : []),
...(nvmNodePath ? [path.join(nvmNodePath, "cursor.cmd")] : []),
...(appData ? [path.join(appData, "npm", "agent.cmd")] : []),
...(appData ? [path.join(appData, "npm", "cursor.cmd")] : []),
],
cline: [
path.join(home, ".local", "bin", "cline"),
// npm global (only if nvm-windows is detected)
...(nvmNodePath ? [path.join(nvmNodePath, "cline.cmd")] : []),
...(appData ? [path.join(appData, "npm", "cline.cmd")] : []),
],
kilo: [
path.join(home, ".local", "bin", "kilocode"),
// npm global (only if nvm-windows is detected)
...(nvmNodePath ? [path.join(nvmNodePath, "kilocode.cmd")] : []),
...(appData ? [path.join(appData, "npm", "kilocode.cmd")] : []),
],
opencode: [
path.join(home, ".local", "bin", "opencode"),
// npm global (only if nvm-windows is detected)
...(nvmNodePath ? [path.join(nvmNodePath, "opencode.cmd")] : []),
...(appData ? [path.join(appData, "npm", "opencode.cmd")] : []),
],
// Add other tools as needed with their specific known paths
};
return knownPaths[toolId] || [];
};
/**
* Detect nvm-windows installation path dynamically from current Node.js executable.
* Returns the directory containing node.exe if nvm is detected, null otherwise.
*/
const getNvmNodePath = (): string | null => {
// Simple heuristic: if process.execPath includes "nvm", use its directory
if (process.execPath.toLowerCase().includes("nvm")) {
return path.dirname(process.execPath);
}
return null;
};
const getLookupEnv = () => {
const env = { ...process.env };
const extraPaths = getExtraPaths();
// Only add user-specified extra paths, NOT generic user directories
// This is more secure - user explicitly opts in via CLI_EXTRA_PATHS
if (extraPaths.length > 0) {
env.PATH = [...extraPaths, env.PATH || ""].filter(Boolean).join(path.delimiter);
}
@@ -223,20 +428,6 @@ const resolveToolCommands = (toolId: string): string[] => {
return tool.defaultCommand ? [tool.defaultCommand] : [];
};
/**
* T12: Validate a CLI executable path to prevent shell injection.
* Enforces: absolute path, no dangerous shell metacharacters, must exist and be a file.
* Inspired by Antigravity Manager commit 96732c2 (Mar 11, 2026).
*/
const DANGEROUS_PATH_CHARS = ["&", "|", ";", "<", ">", "(", ")", "`", "$", "^", "%", "!"];
const isSafePath = (execPath: string): boolean => {
if (!execPath || !path.isAbsolute(execPath)) return false;
if (DANGEROUS_PATH_CHARS.some((c) => execPath.includes(c))) return false;
// Allow path.sep and path.delimiter — no further character filtering needed
return true;
};
const checkExplicitPath = async (commandPath: string) => {
// Reject paths that look like injection attempts
if (!isSafePath(commandPath)) {
@@ -294,14 +485,93 @@ const locateCommand = async (command: string, env: Record<string, string | undef
return { installed: !!first, commandPath: first, reason: first ? null : "not_found" };
};
/**
* Check if a command exists at a specific absolute path.
* Used for known installation locations.
*
* Security hardening:
* - Resolves symlinks and verifies target stays within expected directories
* - Verifies file is a regular file (not directory, pipe, or device)
* - Checks file size bounds (1KB - 100MB) to detect suspicious binaries
*/
const checkKnownPath = async (commandPath: string) => {
if (!path.isAbsolute(commandPath)) {
return { installed: false, commandPath: null, reason: "not_absolute" };
}
if (!isSafePath(commandPath)) {
return { installed: false, commandPath: null, reason: "unsafe_path" };
}
try {
// Resolve symlinks to get the real path and detect symlink escapes
const realPath = await fs.realpath(commandPath);
// Verify the resolved path is still within expected directories
// Use pre-computed expected parent paths (cached at module startup for performance)
const isWithinExpected = EXPECTED_PARENT_PATHS.some((parent) => isPathWithin(realPath, parent));
if (!isWithinExpected) {
return { installed: false, commandPath: null, reason: "symlink_escape" };
}
// Verify it's a regular file with reasonable size
const stat = await fs.stat(realPath);
if (!stat.isFile()) {
return { installed: false, commandPath: null, reason: "not_file" };
}
// CLI binaries should be > 1KB and < 100MB
// This catches suspicious files while allowing for wrapper scripts
if (stat.size < 1024 || stat.size > 100 * 1024 * 1024) {
return { installed: false, commandPath: null, reason: "suspicious_size" };
}
} catch (error) {
const errorCode = (error as NodeJS.ErrnoException).code;
if (errorCode === "ENOENT") {
return { installed: false, commandPath: null, reason: "not_found" };
}
if (errorCode === "EINVAL") {
return { installed: false, commandPath: null, reason: "invalid_path" };
}
return { installed: false, commandPath: null, reason: "access_error" };
}
try {
await fs.access(commandPath, fs.constants.X_OK);
return { installed: true, commandPath, reason: null };
} catch {
return { installed: true, commandPath, reason: "not_executable" };
}
};
const locateCommandCandidate = async (
commands: string[],
env: Record<string, string | undefined>
env: Record<string, string | undefined>,
toolId?: string
) => {
if (!Array.isArray(commands) || commands.length === 0) {
return { command: null, installed: false, commandPath: null, reason: "missing_command" };
}
// SECURITY: First check known installation paths for this specific tool
// This avoids searching PATH and reduces attack surface
if (toolId && isWindows()) {
const knownPaths = getKnownToolPaths(toolId);
for (const knownPath of knownPaths) {
const result = await checkKnownPath(knownPath);
if (result.installed && result.reason === null) {
return {
command: commands[0],
installed: true,
commandPath: result.commandPath,
reason: null,
};
}
}
}
// Fallback: search PATH (user can set CLI_EXTRA_PATHS if needed)
for (const command of commands) {
const located = await locateCommand(command, env);
if (located.installed || located.reason !== "not_found") {
@@ -317,10 +587,18 @@ const checkRunnable = async (
env: Record<string, string | undefined>,
timeoutMs = 4000
) => {
// Minimal environment to prevent credential leakage to potentially malicious binaries
const minimalEnv: Record<string, string | undefined> = {
PATH: env.PATH,
HOME: env.HOME || env.USERPROFILE,
SystemRoot: env.SystemRoot, // Windows needs this
};
for (const args of [["--version"], ["-v"]]) {
const result = await runProcess(commandPath, args, { env, timeoutMs });
if (result.ok) {
return { runnable: true, reason: null };
const result = await runProcess(commandPath, args, { env: minimalEnv, timeoutMs });
// Validate output: must be non-empty and reasonable length (< 4KB)
if (result.ok && result.stdout.length > 0 && result.stdout.length < 4096) {
return { runnable: true, reason: null, version: result.stdout.trim() };
}
}
return { runnable: false, reason: "healthcheck_failed" };
@@ -334,12 +612,62 @@ export const ensureCliConfigWriteAllowed = () => {
return "CLI config writes are disabled (CLI_ALLOW_CONFIG_WRITES=false)";
};
export const getCliConfigHome = () =>
String(process.env.CLI_CONFIG_HOME || "").trim() || os.homedir();
export const getCliConfigHome = () => {
const override = String(process.env.CLI_CONFIG_HOME || "").trim();
if (!override) return os.homedir();
// Must be absolute
if (!path.isAbsolute(override)) return os.homedir();
// Must not contain dangerous characters
if (DANGEROUS_PATH_CHARS.some((c) => override.includes(c))) return os.homedir();
// Must not contain path traversal
if (path.normalize(override).includes("..")) return os.homedir();
// Must be within user's home directory (prevent reading from system dirs)
const home = os.homedir();
const normalized = path.normalize(override);
if (!isPathWithin(normalized, home)) {
return home; // Silently fall back to home
}
return normalized;
};
export const resolveOpencodeConfigDir = (
platform = process.platform,
env: NodeJS.ProcessEnv = process.env,
homeDir = os.homedir()
) => {
const isWin = platform === "win32";
if (isWin) {
const appData = String(env.APPDATA || "").trim();
return appData || path.join(homeDir, "AppData", "Roaming");
}
const xdgConfigHome = String(env.XDG_CONFIG_HOME || "").trim();
return xdgConfigHome || path.join(homeDir, ".config");
};
export const resolveOpencodeConfigPath = (
platform = process.platform,
env: NodeJS.ProcessEnv = process.env,
homeDir = os.homedir()
) => path.join(resolveOpencodeConfigDir(platform, env, homeDir), "opencode", "opencode.json");
export const getOpenCodeConfigPath = () => resolveOpencodeConfigPath();
export const getCliConfigPaths = (toolId: string) => {
const tool = CLI_TOOLS[toolId];
if (!tool) return null;
if (toolId === "opencode") {
return {
config: getOpenCodeConfigPath(),
};
}
const home = getCliConfigHome();
return Object.fromEntries(
Object.entries(tool.paths).map(([key, relativePath]) => [
@@ -387,7 +715,7 @@ export const getCliRuntimeStatus = async (toolId: string) => {
};
}
const located = await locateCommandCandidate(commands, env);
const located = await locateCommandCandidate(commands, env, toolId);
const command = located.command;
if (!located.installed) {

View File

@@ -0,0 +1,64 @@
type OpenCodeConfigInput = {
baseUrl?: string;
apiKey?: string;
model?: string;
};
type OpenCodeProviderConfig = {
name: string;
api: "openai";
baseURL: string;
apiKey: string;
models: string[];
};
const OPENCODE_DEFAULT_MODELS = [
"claude-opus-4-5-thinking",
"claude-sonnet-4-5-thinking",
"gemini-3.1-pro-high",
"gemini-3-flash",
] as const;
const normalizeValue = (value: unknown) =>
String(value || "")
.trim()
.replace(/^\/+/, "");
export const buildOpenCodeProviderConfig = ({
baseUrl,
apiKey,
model,
}: OpenCodeConfigInput): OpenCodeProviderConfig => {
const normalizedBaseUrl = String(baseUrl || "")
.trim()
.replace(/\/+$/, "");
const normalizedModel = normalizeValue(model);
const uniqueModels = [...new Set([normalizedModel, ...OPENCODE_DEFAULT_MODELS].filter(Boolean))];
return {
name: "OmniRoute",
api: "openai",
baseURL: normalizedBaseUrl,
apiKey: apiKey || "sk_omniroute",
models: uniqueModels,
};
};
export const mergeOpenCodeConfig = (
existingConfig: Record<string, any> | null | undefined,
input: OpenCodeConfigInput
) => {
const safeConfig =
existingConfig && typeof existingConfig === "object" && !Array.isArray(existingConfig)
? existingConfig
: {};
return {
...safeConfig,
providers: {
...((safeConfig as any).providers || {}),
omniroute: buildOpenCodeProviderConfig(input),
},
};
};

View File

@@ -37,6 +37,7 @@ export interface ApiKeyMetadata {
accessSchedule?: AccessSchedule | null;
maxRequestsPerDay?: number | null;
maxRequestsPerMinute?: number | null;
maxSessions?: number | null;
}
/**

View File

@@ -905,6 +905,7 @@ export const updateKeyPermissionsSchema = z
noLog: z.boolean().optional(),
autoResolve: z.boolean().optional(),
isActive: z.boolean().optional(),
maxSessions: z.number().int().min(0).max(10000).optional(),
accessSchedule: z.union([accessScheduleSchema, z.null()]).optional(),
})
.superRefine((value, ctx) => {
@@ -915,6 +916,7 @@ export const updateKeyPermissionsSchema = z
value.noLog === undefined &&
value.autoResolve === undefined &&
value.isActive === undefined &&
value.maxSessions === undefined &&
value.accessSchedule === undefined
) {
ctx.addIssue({
@@ -1028,6 +1030,7 @@ export const providersBatchTestSchema = z
export const validateProviderApiKeySchema = z.object({
provider: z.string().trim().min(1, "Provider and API key required"),
apiKey: z.string().trim().min(1, "Provider and API key required"),
validationModelId: z.string().trim().optional(),
});
const geminiPartSchema = z

View File

@@ -46,6 +46,14 @@ import {
applyTaskAwareRouting,
getTaskRoutingConfig,
} from "@omniroute/open-sse/services/taskAwareRouter.ts";
import {
generateSessionId as generateStableSessionId,
touchSession,
extractExternalSessionId,
checkSessionLimit,
registerKeySession,
isSessionRegisteredForKey,
} from "@omniroute/open-sse/services/sessionManager.ts";
import {
isFallbackDecision,
shouldUseFallback,
@@ -161,6 +169,13 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model");
}
// T04: client-provided external session header has priority over generated fingerprint.
const externalSessionId = extractExternalSessionId(request.headers);
const sessionId = externalSessionId || generateStableSessionId(body);
if (sessionId) {
touchSession(sessionId);
}
// Pipeline: API key policy enforcement (model restrictions + budget limits)
telemetry.startPhase("policy");
const policy = await enforceApiKeyPolicy(request, modelStr);
@@ -174,6 +189,25 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
const apiKeyInfo = policy.apiKeyInfo;
telemetry.endPhase();
// T08: per-key active session limit (0 = unlimited).
if (apiKeyInfo?.id && sessionId) {
const maxSessions =
typeof apiKeyInfo.maxSessions === "number" && apiKeyInfo.maxSessions > 0
? apiKeyInfo.maxSessions
: 0;
if (maxSessions > 0 && !isSessionRegisteredForKey(apiKeyInfo.id, sessionId)) {
const sessionViolation = checkSessionLimit(apiKeyInfo.id, maxSessions);
if (sessionViolation) {
return withSessionHeader(
errorResponse(HTTP_STATUS.RATE_LIMITED, sessionViolation.message),
sessionId
);
}
registerKeySession(apiKeyInfo.id, sessionId);
}
}
// T05 — Task-Aware Smart Routing
// Detect the semantic task type and optionally route to the optimal model
let resolvedModelStr = modelStr;
@@ -221,7 +255,8 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
const creds = await getProviderCredentials(
provider,
null,
apiKeyInfo?.allowedConnections ?? null
apiKeyInfo?.allowedConnections ?? null,
modelInfo.model || modelString
);
if (!creds || creds.allRateLimited) return false;
return true;
@@ -238,7 +273,9 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
body,
combo,
handleSingleModel: (b: any, m: string) =>
handleSingleModelChat(b, m, clientRawRequest, request, combo.name, apiKeyInfo, telemetry),
handleSingleModelChat(b, m, clientRawRequest, request, combo.name, apiKeyInfo, telemetry, {
sessionId,
}),
isModelAvailable: checkModelAvailable,
log,
settings,
@@ -247,7 +284,7 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
// Record telemetry
recordTelemetry(telemetry);
return response;
return withSessionHeader(response, sessionId);
}
telemetry.endPhase();
@@ -259,10 +296,11 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
request,
null,
apiKeyInfo,
telemetry
telemetry,
{ sessionId }
);
recordTelemetry(telemetry);
return response;
return withSessionHeader(response, sessionId);
}
/**
@@ -280,7 +318,7 @@ async function handleSingleModelChat(
comboName: string | null = null,
apiKeyInfo: any = null,
telemetry: any = null,
runtimeOptions: { emergencyFallbackTried?: boolean } = {}
runtimeOptions: { emergencyFallbackTried?: boolean; sessionId?: string | null } = {}
) {
// 1. Resolve model → provider/model
const resolved = await resolveModelOrError(modelStr, body);
@@ -310,7 +348,8 @@ async function handleSingleModelChat(
const credentials = await getProviderCredentials(
provider,
excludeConnectionId,
apiKeyInfo?.allowedConnections ?? null
apiKeyInfo?.allowedConnections ?? null,
model
);
if (!credentials || credentials.allRateLimited) {
@@ -333,6 +372,9 @@ async function handleSingleModelChat(
const accountId = credentials.connectionId.slice(0, 8);
log.info("AUTH", `Using ${provider} account: ${accountId}...`);
if (runtimeOptions.sessionId) {
touchSession(runtimeOptions.sessionId, credentials.connectionId);
}
const refreshedCredentials = await checkAndRefreshToken(provider, credentials);
const proxyInfo = await safeResolveProxy(credentials.connectionId);
@@ -604,6 +646,23 @@ async function executeChatWithBreaker({
tlsFingerprintUsed: false,
};
}
// T14: Proxy Fast-Fail should be converted into an upstream-unavailable result
// so account fallback logic can continue with another connection.
if (cbErr?.code === "PROXY_UNREACHABLE" || /proxy unreachable/i.test(cbErr?.message || "")) {
const detail = cbErr?.message || "Proxy unreachable";
log.warn("PROXY", detail);
return {
result: {
success: false,
response: (unavailableResponse as any)(HTTP_STATUS.SERVICE_UNAVAILABLE, detail, 2),
status: HTTP_STATUS.SERVICE_UNAVAILABLE,
error: detail,
},
tlsFingerprintUsed: false,
};
}
throw cbErr;
}
}
@@ -710,3 +769,20 @@ function safeLogEvents({
});
} catch {}
}
function withSessionHeader(response: Response, sessionId: string | null): Response {
if (!response || !sessionId) return response;
try {
response.headers.set("X-OmniRoute-Session-Id", sessionId);
return response;
} catch {
const cloned = new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
cloned.headers.set("X-OmniRoute-Session-Id", sessionId);
return cloned;
}
}

View File

@@ -16,6 +16,7 @@ import {
} from "@omniroute/open-sse/services/accountFallback.ts";
import { isLocalProvider } from "@omniroute/open-sse/config/providerRegistry.ts";
import { COOLDOWN_MS } from "@omniroute/open-sse/config/constants.ts";
import { getCodexModelScope } from "@omniroute/open-sse/executors/codex.ts";
import * as log from "../utils/logger";
import { fisherYatesShuffle, getNextFromDeckSync } from "@/shared/utils/shuffleDeck";
@@ -166,6 +167,56 @@ function applyCodexWindowPolicy(rawWindows: string[], providerSpecificData: Json
return uniqueWindows(windows);
}
function getCodexScopeRateLimitedUntil(
providerSpecificData: JsonRecord,
model: string | null
): string | null {
if (!model) return null;
const scope = getCodexModelScope(model);
const scopeMap = asRecord(providerSpecificData.codexScopeRateLimitedUntil);
const value = scopeMap[scope];
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
function isCodexScopeUnavailable(
connection: ProviderConnectionView,
model: string | null
): boolean {
const until = getCodexScopeRateLimitedUntil(connection.providerSpecificData, model);
if (!until) return false;
return new Date(until).getTime() > Date.now();
}
function getEarliestCodexScopeRateLimitedUntil(
connections: ProviderConnectionView[],
model: string | null
): string | null {
let earliest: string | null = null;
let earliestMs = Infinity;
for (const conn of connections) {
const until = getCodexScopeRateLimitedUntil(conn.providerSpecificData, model);
if (!until) continue;
const ms = new Date(until).getTime();
if (!Number.isFinite(ms) || ms <= Date.now()) continue;
if (ms < earliestMs) {
earliest = until;
earliestMs = ms;
}
}
return earliest;
}
function normalizeStatus(value: string | null): string {
return (value || "").trim().toLowerCase();
}
function isTerminalConnectionStatus(connection: ProviderConnectionView): boolean {
const status = normalizeStatus(connection.testStatus);
return status === "credits_exhausted" || status === "banned" || status === "expired";
}
export function resolveQuotaLimitPolicy(
provider: string,
providerSpecificData: JsonRecord
@@ -259,7 +310,8 @@ export { fisherYatesShuffle, getNextFromDeckSync as getNextFromDeck };
export async function getProviderCredentials(
provider: string,
excludeConnectionId: string | null = null,
allowedConnections: string[] | null = null
allowedConnections: string[] | null = null,
requestedModel: string | null = null
) {
// Acquire mutex to prevent race conditions
const currentMutex = selectionMutex;
@@ -320,6 +372,8 @@ export async function getProviderCredentials(
const availableConnections = connections.filter((c) => {
if (excludeConnectionId && c.id === excludeConnectionId) return false;
if (isAccountUnavailable(c.rateLimitedUntil)) return false;
if (isTerminalConnectionStatus(c)) return false;
if (provider === "codex" && isCodexScopeUnavailable(c, requestedModel)) return false;
return true;
});
@@ -330,16 +384,27 @@ export async function getProviderCredentials(
connections.forEach((c) => {
const excluded = excludeConnectionId && c.id === excludeConnectionId;
const rateLimited = isAccountUnavailable(c.rateLimitedUntil);
const terminalStatus = isTerminalConnectionStatus(c);
const codexScopeLimited = provider === "codex" && isCodexScopeUnavailable(c, requestedModel);
if (excluded || rateLimited) {
log.debug(
"AUTH",
`${c.id?.slice(0, 8)} | ${excluded ? "excluded" : ""} ${rateLimited ? `rateLimited until ${c.rateLimitedUntil}` : ""}`
);
} else if (terminalStatus) {
log.debug("AUTH", `${c.id?.slice(0, 8)} | skipped terminal status=${c.testStatus}`);
} else if (codexScopeLimited) {
const scopeUntil = getCodexScopeRateLimitedUntil(c.providerSpecificData, requestedModel);
log.debug("AUTH", `${c.id?.slice(0, 8)} | codex scope-limited until ${scopeUntil}`);
}
});
if (availableConnections.length === 0) {
const earliest = getEarliestRateLimitedUntil(connections);
const earliest =
getEarliestRateLimitedUntil(connections) ||
(provider === "codex"
? getEarliestCodexScopeRateLimitedUntil(connections, requestedModel)
: null);
if (earliest) {
// Find the connection with the earliest rateLimitedUntil to get its error info
const rateLimitedConns = connections.filter(
@@ -618,6 +683,15 @@ export async function markAccountUnavailable(
const conn = connections.find((connection) => connection.id === connectionId);
const backoffLevel = conn?.backoffLevel || 0;
// T06/T10/T36: terminal statuses should not be overwritten by transient cooldown state.
if (conn && isTerminalConnectionStatus(conn)) {
log.info(
"AUTH",
`${connectionId.slice(0, 8)} terminal status=${conn.testStatus}, skipping cooldown overwrite`
);
return { shouldFallback: true, cooldownMs: 0 };
}
// ─── Anti-Thundering Herd Guard ─────────────────────────────────
// If this connection was ALREADY marked unavailable by a prior concurrent
// request (within the mutex window), skip re-marking to avoid resetting
@@ -633,6 +707,24 @@ export async function markAccountUnavailable(
};
}
// T09: Codex scope-aware lockout guard (codex vs spark independent pools).
if (provider === "codex" && model) {
const scopeRateLimitedUntil = getCodexScopeRateLimitedUntil(
conn?.providerSpecificData || {},
model
);
if (scopeRateLimitedUntil && new Date(scopeRateLimitedUntil).getTime() > Date.now()) {
log.info(
"AUTH",
`${connectionId.slice(0, 8)} already scope-limited for ${getCodexModelScope(model)} (until ${scopeRateLimitedUntil}), skipping duplicate mark`
);
return {
shouldFallback: true,
cooldownMs: new Date(scopeRateLimitedUntil).getTime() - Date.now(),
};
}
}
const { shouldFallback, cooldownMs, newBackoffLevel, reason } = checkFallbackError(
status,
errorText,
@@ -662,6 +754,40 @@ export async function markAccountUnavailable(
const rateLimitedUntil = getUnavailableUntil(cooldownMs);
const errorMsg = typeof errorText === "string" ? errorText.slice(0, 100) : "Provider error";
// T09: Codex per-scope lockout (do not block the whole account globally).
if (provider === "codex" && status === 429 && model && conn) {
const scope = getCodexModelScope(model);
const existingScopeMap = asRecord(conn.providerSpecificData.codexScopeRateLimitedUntil);
const persistedScopeUntil = getCodexScopeRateLimitedUntil(conn.providerSpecificData, model);
const scopeRateLimitedUntil = persistedScopeUntil || rateLimitedUntil;
const scopeCooldownMs = Math.max(new Date(scopeRateLimitedUntil).getTime() - Date.now(), 0);
await updateProviderConnection(connectionId, {
testStatus: "unavailable",
lastError: errorMsg,
errorCode: status,
lastErrorAt: new Date().toISOString(),
backoffLevel: newBackoffLevel ?? backoffLevel,
providerSpecificData: {
...conn.providerSpecificData,
codexScopeRateLimitedUntil: {
...existingScopeMap,
[scope]: scopeRateLimitedUntil,
},
},
});
if (scopeCooldownMs > 0) {
lockModel(provider, connectionId, model, reason || "unknown", scopeCooldownMs);
}
if (status && errorMsg) {
console.error(`${provider} [${status}] (${scope}): ${errorMsg}`);
}
return { shouldFallback: true, cooldownMs: scopeCooldownMs };
}
await updateProviderConnection(connectionId, {
rateLimitedUntil,
testStatus: "unavailable",

View File

@@ -0,0 +1,90 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-auth-terminal-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const auth = await import("../../src/sse/services/auth.ts");
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("getProviderCredentials skips credits_exhausted connections", async () => {
await resetStorage();
const exhausted = await providersDb.createProviderConnection({
provider: "openai",
authType: "apikey",
apiKey: "sk-exhausted",
isActive: true,
testStatus: "credits_exhausted",
});
const healthy = await providersDb.createProviderConnection({
provider: "openai",
authType: "apikey",
apiKey: "sk-healthy",
isActive: true,
testStatus: "active",
});
const selected = await auth.getProviderCredentials("openai");
assert.ok(selected);
assert.equal(selected.connectionId, healthy.id);
assert.notEqual(selected.connectionId, exhausted.id);
});
test("getProviderCredentials returns null when all active connections are terminal", async () => {
await resetStorage();
await providersDb.createProviderConnection({
provider: "openai",
authType: "apikey",
apiKey: "sk-only-exhausted",
isActive: true,
testStatus: "credits_exhausted",
});
const selected = await auth.getProviderCredentials("openai");
assert.equal(selected, null);
});
test("markAccountUnavailable does not overwrite terminal status", async () => {
await resetStorage();
const conn = await providersDb.createProviderConnection({
provider: "openai",
authType: "apikey",
apiKey: "sk-terminal",
isActive: true,
testStatus: "credits_exhausted",
lastError: "insufficient_quota",
});
const result = await auth.markAccountUnavailable(
conn.id,
503,
"temporary upstream error",
"openai",
"gpt-4.1"
);
assert.equal(result.shouldFallback, true);
assert.equal(result.cooldownMs, 0);
const after = await providersDb.getProviderConnectionById(conn.id);
assert.equal(after.testStatus, "credits_exhausted");
});

View File

@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
const {
isBackgroundTask,
getBackgroundTaskReason,
getDegradedModel,
setBackgroundDegradationConfig,
getBackgroundDegradationConfig,
@@ -68,6 +69,26 @@ test("isBackgroundTask: detects X-Request-Priority header", () => {
assert.equal(isBackgroundTask(body, headers), true);
});
test("isBackgroundTask: detects X-Task-Type header", () => {
const body = {
model: "claude-sonnet-4",
messages: [{ role: "user", content: "hello" }],
};
const headers = { "x-task-type": "background" };
assert.equal(isBackgroundTask(body, headers), true);
assert.equal(getBackgroundTaskReason(body, headers), "header_background");
});
test("isBackgroundTask: detects low max_tokens requests", () => {
const body = {
model: "claude-sonnet-4",
max_tokens: 32,
messages: [{ role: "user", content: "hello" }],
};
assert.equal(isBackgroundTask(body), true);
assert.equal(getBackgroundTaskReason(body), "low_max_tokens");
});
test("isBackgroundTask: returns false for null/undefined body", () => {
assert.equal(isBackgroundTask(null), false);
assert.equal(isBackgroundTask(undefined), false);
@@ -81,8 +102,8 @@ test("isBackgroundTask: returns false for empty messages", () => {
test("getDegradedModel: returns cheaper model from map", () => {
resetStats();
assert.equal(getDegradedModel("claude-opus-4-6"), "gemini-2.5-flash");
assert.equal(getDegradedModel("gemini-2.5-pro"), "gemini-2.5-flash");
assert.equal(getDegradedModel("claude-opus-4-6"), "gemini-3-flash");
assert.equal(getDegradedModel("gemini-2.5-pro"), "gemini-3-flash");
assert.equal(getDegradedModel("gpt-4o"), "gpt-4o-mini");
});

View File

@@ -0,0 +1,52 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-calllogs-rm-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const callLogs = await import("../../src/lib/usage/callLogs.ts");
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.beforeEach(async () => {
await resetStorage();
});
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("call logs persist requestedModel and allow filtering by requested model", async () => {
await callLogs.saveCallLog({
method: "POST",
path: "/v1/chat/completions",
status: 200,
model: "openai/gpt-5.2-mini",
requestedModel: "openai/gpt-5.2-codex",
provider: "openai",
duration: 123,
requestBody: { messages: [{ role: "user", content: "hello" }] },
responseBody: { id: "resp_1" },
});
const all = await callLogs.getCallLogs({ limit: 10 });
assert.equal(all.length, 1);
assert.equal(all[0].model, "openai/gpt-5.2-mini");
assert.equal(all[0].requestedModel, "openai/gpt-5.2-codex");
const byRequested = await callLogs.getCallLogs({ model: "gpt-5.2-codex", limit: 10 });
assert.equal(byRequested.length, 1);
assert.equal(byRequested[0].requestedModel, "openai/gpt-5.2-codex");
const detail = await callLogs.getCallLogById(all[0].id);
assert.equal(detail?.requestedModel, "openai/gpt-5.2-codex");
});

View File

@@ -0,0 +1,35 @@
import test from "node:test";
import assert from "node:assert/strict";
const { classifyProviderError, PROVIDER_ERROR_TYPES } =
await import("../../open-sse/services/errorClassifier.ts");
test("classifyProviderError: 401 + account_deactivated => ACCOUNT_DEACTIVATED", () => {
const body = JSON.stringify({
error: { message: "account_deactivated: this account has been disabled" },
});
const result = classifyProviderError(401, body);
assert.equal(result, PROVIDER_ERROR_TYPES.ACCOUNT_DEACTIVATED);
});
test("classifyProviderError: plain 401 => UNAUTHORIZED", () => {
const result = classifyProviderError(401, { error: { message: "token expired" } });
assert.equal(result, PROVIDER_ERROR_TYPES.UNAUTHORIZED);
});
test("classifyProviderError: 402 => QUOTA_EXHAUSTED", () => {
const result = classifyProviderError(402, { error: { message: "payment required" } });
assert.equal(result, PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED);
});
test("classifyProviderError: 400 + billing signal => QUOTA_EXHAUSTED", () => {
const result = classifyProviderError(400, {
error: { message: "insufficient_quota: exceeded your current quota" },
});
assert.equal(result, PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED);
});
test("classifyProviderError: 429 without billing signal => RATE_LIMITED", () => {
const result = classifyProviderError(429, { error: { message: "too many requests" } });
assert.equal(result, PROVIDER_ERROR_TYPES.RATE_LIMITED);
});

View File

@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import net from "node:net";
const isWindows = process.platform === "win32";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-fixes-"));
@@ -342,11 +343,29 @@ test("proxy fetch rejects socks5 context when feature flag is disabled", async (
test("proxy fetch accepts socks5 context when feature flag is enabled", async () => {
await withEnv("ENABLE_SOCKS5_PROXY", "true", async () => {
const result = await proxyFetch.runWithProxyContext(
{ type: "socks5", host: "127.0.0.1", port: "1080" },
async () => "ok"
);
assert.equal(result, "ok");
const server = net.createServer();
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
assert.ok(address && typeof address === "object");
try {
const result = await proxyFetch.runWithProxyContext(
{ type: "socks5", host: "127.0.0.1", port: String(address.port) },
async () => "ok"
);
assert.equal(result, "ok");
} finally {
await new Promise((resolve, reject) => {
server.close((err) => {
if (err) reject(err);
else resolve();
});
});
}
});
});

View File

@@ -11,6 +11,7 @@ const {
addToWhitelist,
removeFromWhitelist,
getIPFilterConfig,
checkRequestIP,
resetIPFilter,
} = await import("../../open-sse/services/ipFilter.ts");
@@ -111,6 +112,48 @@ test("normalizes ::ffff: prefix", () => {
assert.equal(checkIP("::ffff:1.2.3.4").allowed, false);
});
// ─── T07: X-Forwarded-For validation ───────────────────────────────────────
test("checkRequestIP: skips invalid XFF entries and uses next valid IP", () => {
configureIPFilter({ enabled: true, mode: "whitelist", whitelist: ["1.2.3.4"] });
const req = {
headers: {
get(name) {
if (name === "x-forwarded-for") return "unknown, 1.2.3.4";
return null;
},
},
};
assert.equal(checkRequestIP(req).allowed, true);
});
test("checkRequestIP: all-invalid XFF falls back to x-real-ip", () => {
configureIPFilter({ enabled: true, mode: "whitelist", whitelist: ["9.9.9.9"] });
const req = {
headers: {
get(name) {
if (name === "x-forwarded-for") return "unknown, -, not_an_ip";
if (name === "x-real-ip") return "9.9.9.9";
return null;
},
},
};
assert.equal(checkRequestIP(req).allowed, true);
});
test("checkRequestIP: empty headers fall back to request.ip", () => {
configureIPFilter({ enabled: true, mode: "whitelist", whitelist: ["7.7.7.7"] });
const req = {
headers: {
get() {
return null;
},
},
ip: "7.7.7.7",
};
assert.equal(checkRequestIP(req).allowed, true);
});
// ─── Config API ─────────────────────────────────────────────────────────────
test("getIPFilterConfig: returns serializable config", () => {

View File

@@ -13,7 +13,7 @@ test("handleImageGeneration(nanobanana): async submit+poll returns URL payload",
if (u.includes("/generate-pro")) {
const body = JSON.parse(options.body);
assert.equal(body.prompt, "galaxy test");
assert.equal(body.aspectRatio, "4:5");
assert.equal(body.aspectRatio, "1:1");
assert.equal(body.resolution, "2K");
return new Response(

View File

@@ -0,0 +1,107 @@
import test from "node:test";
import assert from "node:assert/strict";
const { stripEmptyTextBlocks, openaiToClaudeRequest, normalizeContentToString } =
await import("../../open-sse/translator/request/openai-to-claude.ts");
test("stripEmptyTextBlocks removes empty text recursively inside tool_result content", () => {
const input = [
{ type: "text", text: "" },
{ type: "text", text: "keep-top-level" },
{
type: "tool_result",
content: [
{ type: "text", text: "" },
{ type: "text", text: "keep-nested" },
{
type: "tool_result",
content: [
{ type: "text", text: "" },
{ type: "text", text: "keep-deep" },
],
},
],
},
];
const out = stripEmptyTextBlocks(input);
assert.deepEqual(out, [
{ type: "text", text: "keep-top-level" },
{
type: "tool_result",
content: [
{ type: "text", text: "keep-nested" },
{
type: "tool_result",
content: [{ type: "text", text: "keep-deep" }],
},
],
},
]);
});
test("openaiToClaudeRequest applies strip to tool message array content", () => {
const request = {
messages: [
{ role: "user", content: "run tool" },
{
role: "assistant",
content: "",
tool_calls: [
{
id: "call_1",
type: "function",
function: { name: "demo_tool", arguments: "{}" },
},
],
},
{
role: "tool",
tool_call_id: "call_1",
content: [
{ type: "text", text: "" },
{ type: "text", text: "tool ok" },
],
},
],
};
const translated = openaiToClaudeRequest("claude-sonnet-4", request, false);
const toolMessage = translated.messages.find(
(m) => Array.isArray(m.content) && m.content.some((b) => b.type === "tool_result")
);
assert.ok(toolMessage, "expected a translated tool_result user message");
const toolResult = toolMessage.content.find((b) => b.type === "tool_result");
assert.deepEqual(toolResult.content, [{ type: "text", text: "tool ok" }]);
});
test("T15: normalizeContentToString supports array-form content blocks", () => {
const text = normalizeContentToString([
{ type: "text", text: "line 1" },
{ type: "image_url", image_url: { url: "data:image/png;base64,abc" } },
{ type: "text", text: "line 2" },
]);
assert.equal(text, "line 1\nline 2");
});
test("T15: openaiToClaudeRequest converts system array content into a Claude system text block", () => {
const request = {
messages: [
{
role: "system",
content: [
{ type: "text", text: "System rules A" },
{ type: "image_url", image_url: { url: "data:image/png;base64,abc" } },
{ type: "text", text: "System rules B" },
],
},
{ role: "user", content: "hello" },
],
};
const translated = openaiToClaudeRequest("claude-sonnet-4", request, false);
assert.ok(Array.isArray(translated.system));
// system[0] is the injected Claude prompt; user-provided system content is system[1].
assert.equal(translated.system[1].text, "System rules A\nSystem rules B");
});

View File

@@ -11,7 +11,10 @@ import { DefaultExecutor } from "../../open-sse/executors/default.ts";
import { CodexExecutor, setDefaultFastServiceTierEnabled } from "../../open-sse/executors/codex.ts";
import { translateNonStreamingResponse } from "../../open-sse/handlers/responseTranslator.ts";
import { extractUsageFromResponse } from "../../open-sse/handlers/usageExtractor.ts";
import { parseSSEToResponsesOutput } from "../../open-sse/handlers/sseParser.ts";
import {
parseSSEToOpenAIResponse,
parseSSEToResponsesOutput,
} from "../../open-sse/handlers/sseParser.ts";
test("getModelInfoCore resolves unique non-openai unprefixed model", async () => {
const info = await getModelInfoCore("claude-haiku-4-5-20251001", {});
@@ -382,3 +385,56 @@ test("parseSSEToResponsesOutput returns null for invalid payload", () => {
const parsed = parseSSEToResponsesOutput("data: not-json\n\ndata: [DONE]\n", "fallback-model");
assert.equal(parsed, null);
});
test("parseSSEToOpenAIResponse merges split tool call chunks by id without duplication", () => {
const rawSSE = [
`data: ${JSON.stringify({
id: "chatcmpl_1",
object: "chat.completion.chunk",
choices: [
{
index: 0,
delta: {
tool_calls: [
{
id: "call_abc",
index: 0,
type: "function",
function: { name: "sum", arguments: '{"a":' },
},
],
},
},
],
})}`,
`data: ${JSON.stringify({
id: "chatcmpl_1",
object: "chat.completion.chunk",
choices: [
{
index: 0,
delta: {
tool_calls: [
{
id: "call_abc",
index: 0,
type: "function",
function: { arguments: "1}" },
},
],
},
finish_reason: "tool_calls",
},
],
})}`,
"data: [DONE]",
].join("\n");
const parsed = parseSSEToOpenAIResponse(rawSSE, "gpt-5.1-codex");
assert.ok(parsed);
assert.equal(parsed.choices[0].finish_reason, "tool_calls");
assert.equal(parsed.choices[0].message.tool_calls.length, 1);
assert.equal(parsed.choices[0].message.tool_calls[0].id, "call_abc");
assert.equal(parsed.choices[0].message.tool_calls[0].function.name, "sum");
assert.equal(parsed.choices[0].message.tool_calls[0].function.arguments, '{"a":1}');
});

View File

@@ -8,6 +8,11 @@ const {
getSessionConnection,
getActiveSessionCount,
getActiveSessions,
extractExternalSessionId,
checkSessionLimit,
registerKeySession,
unregisterKeySession,
isSessionRegisteredForKey,
clearSessions,
} = await import("../../open-sse/services/sessionManager.ts");
@@ -39,11 +44,17 @@ test("generateSessionId: different model = different ID", () => {
test("generateSessionId: different system prompt = different ID", () => {
const body1 = {
model: "claude-sonnet-4-20250514",
messages: [{ role: "system", content: "A" }, { role: "user", content: "hi" }],
messages: [
{ role: "system", content: "A" },
{ role: "user", content: "hi" },
],
};
const body2 = {
model: "claude-sonnet-4-20250514",
messages: [{ role: "system", content: "B" }, { role: "user", content: "hi" }],
messages: [
{ role: "system", content: "B" },
{ role: "user", content: "hi" },
],
};
assert.notEqual(generateSessionId(body1), generateSessionId(body2));
});
@@ -147,3 +158,25 @@ test("touchSession with null sessionId: no-op", () => {
touchSession(null);
assert.equal(getActiveSessionCount(), 0);
});
test("extractExternalSessionId accepts hyphen and underscore variants", () => {
const h1 = new Headers({ "x-session-id": "abc-123" });
const h2 = new Headers({ x_session_id: "def-456" });
assert.equal(extractExternalSessionId(h1), "ext:abc-123");
assert.equal(extractExternalSessionId(h2), "ext:def-456");
});
test("checkSessionLimit enforces max_sessions for new sessions only", () => {
const keyId = "key-1";
registerKeySession(keyId, "ext:sess-a");
assert.equal(isSessionRegisteredForKey(keyId, "ext:sess-a"), true);
const violation = checkSessionLimit(keyId, 1);
assert.ok(violation);
assert.equal(violation.code, "SESSION_LIMIT_EXCEEDED");
unregisterKeySession(keyId, "ext:sess-a");
assert.equal(isSessionRegisteredForKey(keyId, "ext:sess-a"), false);
assert.equal(checkSessionLimit(keyId, 1), null);
});

View File

@@ -59,6 +59,11 @@ test("updateKeyPermissionsSchema accepts noLog-only updates and rejects empty pa
const noLogOnly = schemas.validateBody(schemas.updateKeyPermissionsSchema, { noLog: true });
assert.equal(noLogOnly.success, true);
const maxSessionsOnly = schemas.validateBody(schemas.updateKeyPermissionsSchema, {
maxSessions: 3,
});
assert.equal(maxSessionsOnly.success, true);
const emptyPayload = schemas.validateBody(schemas.updateKeyPermissionsSchema, {});
assert.equal(emptyPayload.success, false);
});

View File

@@ -0,0 +1,34 @@
import test from "node:test";
import assert from "node:assert/strict";
import { getDefaultPricing } from "../../src/shared/constants/pricing.ts";
import { REGISTRY } from "../../open-sse/config/providerRegistry.ts";
test("T12: pricing table includes MiniMax, GLM, Kimi and gpt-5.4 mini entries", () => {
const pricing = getDefaultPricing();
assert.ok(pricing.cx["gpt-5.4"], "missing cx/gpt-5.4");
assert.ok(pricing.cx["gpt-5.4-mini"], "missing cx/gpt-5.4-mini");
assert.ok(pricing.minimax["minimax-m2.5"], "missing minimax/minimax-m2.5");
assert.ok(pricing.minimax["minimax-m2.7"], "missing minimax/minimax-m2.7");
assert.equal(pricing.minimax["minimax-m2.5"].input, 0.27);
assert.equal(pricing.minimax["minimax-m2.5"].output, 0.95);
assert.ok(pricing.glm["glm-4.7"], "missing glm/glm-4.7");
assert.ok(pricing.glm["glm-5"], "missing glm/glm-5");
assert.equal(pricing.glm["glm-4.7"].input, 0.38);
assert.equal(pricing.glm["glm-4.7"].output, 1.98);
assert.ok(pricing.kimi["kimi-k2.5"], "missing kimi/kimi-k2.5");
assert.ok(pricing.kimi["kimi-k2.5-thinking"], "missing kimi/kimi-k2.5-thinking");
assert.ok(pricing.kimi["kimi-for-coding"], "missing kimi/kimi-for-coding");
});
test("T12: minimax default model list starts with M2.7", () => {
const minimaxModels = REGISTRY.minimax.models.map((m) => m.id);
const minimaxCnModels = REGISTRY["minimax-cn"].models.map((m) => m.id);
assert.equal(minimaxModels[0], "minimax-m2.7");
assert.equal(minimaxCnModels[0], "minimax-m2.7");
});

View File

@@ -0,0 +1,31 @@
import test from "node:test";
import assert from "node:assert/strict";
import { parseQuotaData } from "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx";
test("T13: parseQuotaData zeroes usage when resetAt is already in the past", () => {
const past = new Date(Date.now() - 60_000).toISOString();
const parsed = parseQuotaData("codex", {
quotas: {
session: { used: 83, total: 100, resetAt: past },
},
});
assert.equal(parsed.length, 1);
assert.equal(parsed[0].used, 0);
assert.equal(parsed[0].staleAfterReset, true);
assert.equal(parsed[0].remainingPercentage, 100);
});
test("T13: parseQuotaData keeps usage unchanged when resetAt is in the future", () => {
const future = new Date(Date.now() + 60_000).toISOString();
const parsed = parseQuotaData("codex", {
quotas: {
session: { used: 42, total: 100, resetAt: future },
},
});
assert.equal(parsed.length, 1);
assert.equal(parsed[0].used, 42);
assert.equal(parsed[0].staleAfterReset, false);
});

View File

@@ -0,0 +1,35 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
isProxyReachable,
getCachedProxyHealth,
invalidateProxyHealth,
} from "../../src/lib/proxyHealth.ts";
import { runWithProxyContext } from "../../open-sse/utils/proxyFetch.ts";
test("T14: isProxyReachable caches unreachable proxy result", async () => {
const proxyUrl = "http://127.0.0.1:1";
invalidateProxyHealth(proxyUrl);
const healthy = await isProxyReachable(proxyUrl, 120, 2_000);
assert.equal(healthy, false);
assert.equal(getCachedProxyHealth(proxyUrl), false);
});
test("T14: runWithProxyContext fast-fails when proxy is unreachable", async () => {
const proxyUrl = "http://127.0.0.1:1";
invalidateProxyHealth(proxyUrl);
let executed = false;
await assert.rejects(
() =>
runWithProxyContext(proxyUrl, async () => {
executed = true;
return "ok";
}),
(err) => err?.code === "PROXY_UNREACHABLE"
);
assert.equal(executed, false);
});

View File

@@ -0,0 +1,53 @@
import test from "node:test";
import assert from "node:assert/strict";
const { cleanJSONSchemaForAntigravity } =
await import("../../open-sse/translator/helpers/geminiHelper.ts");
test("T16: enum-only fields gain type:string after Gemini schema cleanup", () => {
const schema = {
type: "object",
properties: {
mode: {
enum: ["fast", "balanced", "slow"],
},
},
required: ["mode"],
};
const cleaned = cleanJSONSchemaForAntigravity(schema);
assert.equal(cleaned.properties.mode.type, "string");
assert.deepEqual(cleaned.properties.mode.enum, ["fast", "balanced", "slow"]);
});
test("T16: existing explicit type:string is preserved", () => {
const schema = {
type: "object",
properties: {
mode: {
type: "string",
enum: ["auto", "manual"],
},
},
};
const cleaned = cleanJSONSchemaForAntigravity(schema);
assert.equal(cleaned.properties.mode.type, "string");
assert.deepEqual(cleaned.properties.mode.enum, ["auto", "manual"]);
});
test("T16: schemas without enum are not forced to string", () => {
const schema = {
type: "object",
properties: {
retries: {
type: "number",
minimum: 0,
},
},
};
const cleaned = cleanJSONSchemaForAntigravity(schema);
assert.equal(cleaned.properties.retries.type, "number");
assert.equal(cleaned.properties.retries.enum, undefined);
});

View File

@@ -0,0 +1,66 @@
import test from "node:test";
import assert from "node:assert/strict";
const { translateNonStreamingResponse } =
await import("../../open-sse/handlers/responseTranslator.ts");
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
test("T19: picks the last non-empty message content from Responses output", () => {
const responseBody = {
object: "response",
id: "resp_t19",
model: "gpt-5.2-codex",
created_at: 1710000000,
output: [
{
type: "message",
content: [{ type: "output_text", text: "" }],
},
{
type: "reasoning",
summary: [{ type: "summary_text", text: "thinking..." }],
},
{
type: "message",
content: [{ type: "output_text", text: "Resposta final" }],
},
],
usage: { input_tokens: 10, output_tokens: 5 },
};
const translated = translateNonStreamingResponse(
responseBody,
FORMATS.OPENAI_RESPONSES,
FORMATS.OPENAI
);
assert.equal(translated.choices[0].message.content, "Resposta final");
});
test("T19: falls back to last message block when all message texts are empty", () => {
const responseBody = {
object: "response",
id: "resp_t19_empty",
model: "gpt-5.2-codex",
created_at: 1710000001,
output: [
{
type: "message",
content: [{ type: "output_text", text: "" }],
},
{
type: "message",
content: [{ type: "output_text", text: "" }],
},
],
};
const translated = translateNonStreamingResponse(
responseBody,
FORMATS.OPENAI_RESPONSES,
FORMATS.OPENAI
);
assert.equal(translated.choices[0].message.content, "");
assert.equal(translated.choices[0].finish_reason, "stop");
});

View File

@@ -0,0 +1,31 @@
import test from "node:test";
import assert from "node:assert/strict";
import { platform, arch } from "node:os";
const { REGISTRY } = await import("../../open-sse/config/providerRegistry.ts");
test("T20: antigravity config has updated User-Agent and sandbox fallback URL", () => {
const antigravity = REGISTRY.antigravity;
assert.ok(Array.isArray(antigravity.baseUrls));
assert.ok(antigravity.baseUrls.includes("https://daily-cloudcode-pa.sandbox.googleapis.com"));
assert.match(
antigravity.headers["User-Agent"],
new RegExp(`^antigravity/1\\.107\\.0\\s+${platform()}\\/${arch()}$`)
);
});
test("T22: github headers include updated editor/plugin versions and required fields", () => {
const github = REGISTRY.github;
assert.equal(github.headers["editor-version"], "vscode/1.110.0");
assert.equal(github.headers["editor-plugin-version"], "copilot-chat/0.38.0");
assert.equal(github.headers["user-agent"], "GitHubCopilotChat/0.38.0");
assert.equal(github.headers["x-github-api-version"], "2025-04-01");
assert.equal(github.headers["x-vscode-user-agent-library-version"], "electron-fetch");
assert.equal(github.headers["X-Initiator"], "user");
});
test("T22: github config exposes dedicated responses endpoint", () => {
const github = REGISTRY.github;
assert.equal(github.responsesBaseUrl, "https://api.githubcopilot.com/responses");
assert.equal(github.baseUrl, "https://api.githubcopilot.com/chat/completions");
});

View File

@@ -0,0 +1,141 @@
import test from "node:test";
import assert from "node:assert/strict";
const { checkFallbackError } = await import("../../open-sse/services/accountFallback.ts");
const { handleComboChat } = await import("../../open-sse/services/combo.ts");
const { resetAllCircuitBreakers } = await import("../../src/shared/utils/circuitBreaker.ts");
test.beforeEach(() => {
resetAllCircuitBreakers();
});
function createLog() {
const entries = [];
return {
info: (tag, msg) => entries.push({ level: "info", tag, msg }),
warn: (tag, msg) => entries.push({ level: "warn", tag, msg }),
error: (tag, msg) => entries.push({ level: "error", tag, msg }),
entries,
};
}
function createStatusSequenceHandler(sequence) {
let idx = 0;
return async () => {
const step = sequence[idx++] || { status: 200 };
if (step.status === 200) {
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}
return new Response(
JSON.stringify({
error: { message: step.message || `Error ${step.status}` },
}),
{
status: step.status,
headers: step.headers || { "content-type": "application/json" },
}
);
};
}
test("T23: 429 with long Retry-After uses real reset cooldown instead of short exponential backoff", () => {
const headers = new Headers({ "retry-after": "3600" });
const result = checkFallbackError(429, "Rate limit exceeded", 2, null, "groq", headers);
assert.equal(result.shouldFallback, true);
assert.equal(result.reason, "rate_limit_exceeded");
assert.equal(result.newBackoffLevel, 0);
assert.ok(result.cooldownMs > 3_590_000);
});
test("T24: combo awaits short 503 cooldown before falling through to next model", async () => {
const log = createLog();
const result = await handleComboChat({
body: {},
combo: {
name: "t24-short-cooldown",
strategy: "priority",
models: [
{ model: "groq/model-a", weight: 0 },
{ model: "groq/model-b", weight: 0 },
],
},
// Two transient failures on first model, then success on fallback model.
handleSingleModel: createStatusSequenceHandler([
{ status: 503 },
{ status: 503 },
{ status: 200 },
]),
isModelAvailable: () => true,
log,
settings: null,
allCombos: null,
});
assert.equal(result.ok, true);
const waitLog = log.entries.find((e) => e.msg.includes("Waiting") && e.msg.includes("fallback"));
assert.ok(waitLog);
});
test("T24: combo skips wait when 503 cooldown is long (>5s)", async () => {
const log = createLog();
const result = await handleComboChat({
body: {},
combo: {
name: "t24-long-cooldown",
strategy: "priority",
models: [
{ model: "groq/model-a", weight: 0 },
{ model: "groq/model-b", weight: 0 },
],
},
handleSingleModel: createStatusSequenceHandler([
{
status: 503,
message: "rate limit exceeded",
headers: { "content-type": "application/json", "retry-after": "120" },
},
{
status: 503,
message: "rate limit exceeded",
headers: { "content-type": "application/json", "retry-after": "120" },
},
{ status: 200 },
]),
isModelAvailable: () => true,
log,
settings: null,
allCombos: null,
});
assert.equal(result.ok, true);
const waitLog = log.entries.find((e) => e.msg.includes("Waiting") && e.msg.includes("fallback"));
assert.equal(waitLog, undefined);
});
test("T24: all inactive accounts return 503 service_unavailable (not 406)", async () => {
const result = await handleComboChat({
body: {},
combo: {
name: "t24-all-inactive",
strategy: "priority",
models: [
{ model: "groq/model-a", weight: 0 },
{ model: "groq/model-b", weight: 0 },
],
},
handleSingleModel: async () => {
throw new Error("handleSingleModel should not be called when all models are unavailable");
},
isModelAvailable: () => false,
log: createLog(),
settings: null,
allCombos: null,
});
assert.equal(result.status, 503);
const body = await result.json();
assert.equal(body.error?.code, "ALL_ACCOUNTS_INACTIVE");
});

View File

@@ -0,0 +1,116 @@
import test from "node:test";
import assert from "node:assert/strict";
const { validateProviderApiKey } = await import("../../src/lib/providers/validation.ts");
test("T25: openai-compatible validation succeeds directly when /models works", async () => {
const originalFetch = globalThis.fetch;
const calls = [];
globalThis.fetch = async (url) => {
calls.push(String(url));
return new Response(JSON.stringify({ data: [] }), { status: 200 });
};
try {
const result = await validateProviderApiKey({
provider: "openai-compatible-chat-t25-models-ok",
apiKey: "sk-test",
providerSpecificData: { baseUrl: "https://api.example.com/v1" },
});
assert.equal(result.valid, true);
assert.equal(result.method, "models_endpoint");
assert.equal(calls.length, 1);
assert.equal(calls[0], "https://api.example.com/v1/models");
} finally {
globalThis.fetch = originalFetch;
}
});
test("T25: /models unavailable without Model ID returns actionable guidance", async () => {
const originalFetch = globalThis.fetch;
let callCount = 0;
globalThis.fetch = async () => {
callCount += 1;
return new Response(JSON.stringify({ error: "Not Found" }), { status: 404 });
};
try {
const result = await validateProviderApiKey({
provider: "openai-compatible-chat-t25-no-model-id",
apiKey: "sk-test",
providerSpecificData: { baseUrl: "https://api.example.com/v1" },
});
assert.equal(result.valid, false);
assert.match(result.error, /Provide a Model ID/i);
// Must stop after /models when no custom model was provided.
assert.equal(callCount, 1);
} finally {
globalThis.fetch = originalFetch;
}
});
test("T25: fallback chat probe detects invalid credentials with custom Model ID", async () => {
const originalFetch = globalThis.fetch;
const calls = [];
globalThis.fetch = async (url) => {
calls.push(String(url));
if (String(url).endsWith("/models")) {
return new Response(JSON.stringify({ error: "Not Found" }), { status: 404 });
}
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
};
try {
const result = await validateProviderApiKey({
provider: "openai-compatible-chat-t25-auth",
apiKey: "bad-key",
providerSpecificData: {
baseUrl: "https://api.example.com/v1",
validationModelId: "grok-3",
},
});
assert.equal(result.valid, false);
assert.equal(result.error, "Invalid API key");
assert.deepEqual(calls, [
"https://api.example.com/v1/models",
"https://api.example.com/v1/chat/completions",
]);
} finally {
globalThis.fetch = originalFetch;
}
});
test("T25: fallback chat probe treats 429 as valid credentials with warning", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async (url) => {
if (String(url).endsWith("/models")) {
throw new Error("connect ECONNREFUSED");
}
return new Response(JSON.stringify({ error: "Rate limited" }), { status: 429 });
};
try {
const result = await validateProviderApiKey({
provider: "openai-compatible-chat-t25-rate-limit",
apiKey: "sk-test",
providerSpecificData: {
baseUrl: "https://api.example.com/v1",
validationModelId: "meta-llama/Llama-3.1-8B-Instruct",
},
});
assert.equal(result.valid, true);
assert.equal(result.error, null);
assert.equal(result.method, "chat_completions");
assert.match(result.warning, /Rate limited/i);
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -0,0 +1,30 @@
import test from "node:test";
import assert from "node:assert/strict";
const { clientWantsJsonResponse, resolveStreamFlag, stripMarkdownCodeFence } =
await import("../../open-sse/utils/aiSdkCompat.ts");
test("T26: Accept application/json disables SSE stream mode", () => {
assert.equal(clientWantsJsonResponse("application/json"), true);
assert.equal(resolveStreamFlag(true, "application/json"), false);
});
test("T26: text/event-stream keeps SSE behavior", () => {
assert.equal(clientWantsJsonResponse("text/event-stream"), false);
assert.equal(resolveStreamFlag(true, "text/event-stream"), true);
});
test("T26: mixed Accept header prefers SSE only when text/event-stream is present", () => {
assert.equal(clientWantsJsonResponse("application/json, text/event-stream"), false);
assert.equal(resolveStreamFlag(true, "application/json, text/event-stream"), true);
});
test("T26: markdown code fence stripping unwraps Claude JSON blocks", () => {
const wrapped = '```json\n{"name":"omniroute"}\n```';
assert.equal(stripMarkdownCodeFence(wrapped), '{"name":"omniroute"}');
});
test("T26: non-fenced content is returned unchanged", () => {
const plain = '{"name":"omniroute"}';
assert.equal(stripMarkdownCodeFence(plain), plain);
});

View File

@@ -0,0 +1,84 @@
import test from "node:test";
import assert from "node:assert/strict";
const { GithubExecutor } = await import("../../open-sse/executors/github.ts");
const { BaseExecutor } = await import("../../open-sse/executors/base.ts");
function streamFromChunks(chunks) {
const encoder = new TextEncoder();
return new ReadableStream({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk));
}
controller.close();
},
});
}
test("T27: Claude + response_format=json_object injects system instruction and strips response_format field", () => {
const executor = new GithubExecutor();
const request = {
messages: [{ role: "user", content: "return json" }],
response_format: { type: "json_object" },
};
const transformed = executor.transformRequest("claude-sonnet-4.5", request, false, {});
assert.equal(transformed.response_format, undefined);
assert.equal(transformed.messages[0].role, "system");
assert.match(
transformed.messages[0].content,
/Respond only with valid JSON\. Do not include any text/i
);
});
test("T27: non-Claude models keep response_format untouched", () => {
const executor = new GithubExecutor();
const request = {
messages: [{ role: "user", content: "hello" }],
response_format: { type: "json_object" },
};
const transformed = executor.transformRequest("gpt-4o", request, false, {});
assert.deepEqual(transformed.response_format, { type: "json_object" });
});
test("T27: SSE [DONE] guard applies only in streaming mode", async () => {
const executor = new GithubExecutor();
const originalExecute = BaseExecutor.prototype.execute;
BaseExecutor.prototype.execute = async () => ({
response: new Response(
streamFromChunks(['data: {"delta":"hello"}\n\n', "data: [DONE]\n\n", "data: tail\n\n"]),
{
status: 200,
headers: { "content-type": "text/event-stream" },
}
),
url: "https://api.githubcopilot.com/chat/completions",
});
try {
const streamingResult = await executor.execute({
model: "claude-sonnet-4.5",
body: { messages: [] },
stream: true,
credentials: { accessToken: "token" },
});
const streamingText = await streamingResult.response.text();
assert.equal(streamingText.includes("data: [DONE]"), false);
assert.equal(streamingText.includes("data: tail"), true);
const nonStreamingResult = await executor.execute({
model: "claude-sonnet-4.5",
body: { messages: [] },
stream: false,
credentials: { accessToken: "token" },
});
const nonStreamingText = await nonStreamingResult.response.text();
assert.equal(nonStreamingText.includes("data: [DONE]"), true);
} finally {
BaseExecutor.prototype.execute = originalExecute;
}
});

View File

@@ -0,0 +1,41 @@
import test from "node:test";
import assert from "node:assert/strict";
import { getModelInfoCore } from "../../open-sse/services/model.ts";
import { REGISTRY } from "../../open-sse/config/providerRegistry.ts";
test("T28: gemini catalog includes preview models from 9router", () => {
const geminiIds = REGISTRY.gemini.models.map((m) => m.id);
const geminiCliIds = REGISTRY["gemini-cli"].models.map((m) => m.id);
assert.ok(geminiIds.includes("gemini-3.1-flash-lite-preview"));
assert.ok(geminiIds.includes("gemini-3-flash-preview"));
assert.ok(geminiCliIds.includes("gemini-3.1-flash-lite-preview"));
assert.ok(geminiCliIds.includes("gemini-3-flash-preview"));
});
test("T28: vertex catalog includes partner models when vertex executor is available", () => {
const vertexIds = REGISTRY.vertex.models.map((m) => m.id);
assert.ok(vertexIds.includes("deepseek-v3.2"));
assert.ok(vertexIds.includes("qwen3-next-80b"));
assert.ok(vertexIds.includes("glm-5"));
});
test("T28: new catalog models resolve through getModelInfoCore", async () => {
const minimax = await getModelInfoCore("minimax/minimax-m2.7", {});
assert.equal(minimax.provider, "minimax");
assert.equal(minimax.model, "minimax-m2.7");
const flashLite = await getModelInfoCore("gemini/gemini-3.1-flash-lite-preview", {});
assert.equal(flashLite.provider, "gemini");
assert.equal(flashLite.model, "gemini-3.1-flash-lite-preview");
const flashPreview = await getModelInfoCore("gemini/gemini-3-flash-preview", {});
assert.equal(flashPreview.provider, "gemini");
assert.equal(flashPreview.model, "gemini-3-flash-preview");
const vertexPartner = await getModelInfoCore("vertex/qwen3-next-80b", {});
assert.equal(vertexPartner.provider, "vertex");
assert.equal(vertexPartner.model, "qwen3-next-80b");
});

View File

@@ -0,0 +1,71 @@
import test from "node:test";
import assert from "node:assert/strict";
const { VertexExecutor } = await import("../../open-sse/executors/vertex.ts");
const MIN_SA_JSON = JSON.stringify({
project_id: "vertex-project-123",
});
test("T29: Vertex executor builds regional Gemini URL from Service Account project", () => {
const executor = new VertexExecutor();
const url = executor.buildUrl("gemini-3.1-pro-preview", true, 0, {
apiKey: MIN_SA_JSON,
providerSpecificData: { region: "europe-west4" },
});
assert.equal(
url,
"https://aiplatform.googleapis.com/v1/projects/vertex-project-123/locations/europe-west4/publishers/google/models/gemini-3.1-pro-preview:streamGenerateContent?alt=sse"
);
});
test("T29: Vertex executor routes partner models to global openapi endpoint", () => {
const executor = new VertexExecutor();
const url = executor.buildUrl("deepseek-v3.2", false, 0, {
apiKey: MIN_SA_JSON,
providerSpecificData: { region: "us-central1" },
});
assert.equal(
url,
"https://aiplatform.googleapis.com/v1/projects/vertex-project-123/locations/global/endpoints/openapi/chat/completions"
);
});
test("T29: Vertex executor defaults region to us-central1 when not configured", () => {
const executor = new VertexExecutor();
const url = executor.buildUrl("gemini-2.5-flash", false, 0, {
apiKey: MIN_SA_JSON,
providerSpecificData: {},
});
assert.equal(
url,
"https://aiplatform.googleapis.com/v1/projects/vertex-project-123/locations/us-central1/publishers/google/models/gemini-2.5-flash:generateContent"
);
});
test("T29: Vertex executor headers include Bearer token and SSE Accept when streaming", () => {
const executor = new VertexExecutor();
const headers = executor.buildHeaders({ accessToken: "ya29.test-token" }, true);
assert.equal(headers["Content-Type"], "application/json");
assert.equal(headers.Authorization, "Bearer ya29.test-token");
assert.equal(headers.Accept, "text/event-stream");
});
test("T29: Vertex executor rejects invalid Service Account JSON clearly", async () => {
const executor = new VertexExecutor();
await assert.rejects(
() =>
executor.execute({
model: "gemini-2.5-flash",
body: { contents: [] },
stream: false,
credentials: { apiKey: "not-json" },
}),
/Service Account JSON/i
);
});

View File

@@ -0,0 +1,29 @@
import test from "node:test";
import assert from "node:assert/strict";
const { isModelUnavailableError, getNextFamilyFallback } =
await import("../../open-sse/services/modelFamilyFallback.ts");
test("T30: Kiro 'improperly formed request' 400 is treated as model-unavailable", () => {
const unavailable = isModelUnavailableError(
400,
"Bad Request: improperly formed request for selected model"
);
assert.equal(unavailable, true);
});
test("T30: generic 400 without model-unavailable signal is not treated as unavailable", () => {
const unavailable = isModelUnavailableError(400, "Bad Request: malformed JSON body");
assert.equal(unavailable, false);
});
test("T30: 404 still maps to model-unavailable", () => {
const unavailable = isModelUnavailableError(404, "not found");
assert.equal(unavailable, true);
});
test("T30: model family helper returns a sibling candidate when available", () => {
const next = getNextFamilyFallback("gemini-3.1-pro-high", new Set(["gemini-3.1-pro-high"]));
assert.equal(typeof next, "string");
assert.notEqual(next, "gemini-3.1-pro-high");
});

View File

@@ -0,0 +1,53 @@
import test from "node:test";
import assert from "node:assert/strict";
const { REGISTRY } = await import("../../open-sse/config/providerRegistry.ts");
const { resolveModelAlias: resolveDeprecatedAlias } =
await import("../../open-sse/services/modelDeprecation.ts");
const { normalizeThinkingLevel } = await import("../../open-sse/services/thinkingBudget.ts");
const {
MODEL_SPECS,
getModelSpec,
capMaxOutputTokens,
resolveModelAlias,
getDefaultThinkingBudget,
capThinkingBudget,
} = await import("../../src/shared/constants/modelSpecs.ts");
test("T31: registry exposes Gemini 3.1 Pro High/Low model IDs", () => {
const geminiIds = REGISTRY.gemini.models.map((m) => m.id);
assert.ok(geminiIds.includes("gemini-3.1-pro-high"));
assert.ok(geminiIds.includes("gemini-3.1-pro-low"));
});
test("T31: legacy Gemini aliases resolve to Gemini 3.1 IDs", () => {
assert.equal(resolveDeprecatedAlias("gemini-3-pro-high"), "gemini-3.1-pro-high");
assert.equal(resolveDeprecatedAlias("gemini-3-pro-low"), "gemini-3.1-pro-low");
});
test("T33: thinkingLevel string is converted into numeric thinkingBudget", () => {
const converted = normalizeThinkingLevel({
model: "gemini-3.1-pro-high",
generationConfig: {
thinkingConfig: { thinkingLevel: "HIGH" },
},
});
assert.equal(converted.generationConfig.thinkingConfig.thinkingBudget, 24576);
assert.equal(converted.generationConfig.thinkingConfig.thinkingLevel, undefined);
});
test("T34: max output tokens are capped by model spec", () => {
assert.equal(capMaxOutputTokens("gemini-3-flash", 131072), 65536);
assert.equal(capMaxOutputTokens("gemini-3-flash"), 65536);
assert.equal(capMaxOutputTokens("gemini-3.1-pro-high", 131072), 131072);
});
test("T38: modelSpecs exposes centralized helpers with alias and prefix lookup", () => {
assert.equal(typeof MODEL_SPECS["gemini-3.1-pro-high"], "object");
assert.equal(getModelSpec("gemini-3-pro-high").maxOutputTokens, 131072);
assert.equal(getModelSpec("gemini-3-flash-preview").maxOutputTokens, 65536);
assert.equal(resolveModelAlias("gemini-3-pro-low"), "gemini-3.1-pro-low");
assert.equal(getDefaultThinkingBudget("gemini-3.1-pro-high"), 24576);
assert.equal(capThinkingBudget("gemini-3.1-pro-low", 50000), 16000);
});

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