Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d9ff0035f5 | |||
| 7a7f3be0d2 | |||
| 91e45fbe95 | |||
| 7d7e9da28c | |||
| 24a9739604 | |||
| 4fb9687782 | |||
| 95ffc21b60 | |||
| f3c5e55b26 | |||
| 40183c6a5c | |||
| 457c59e38a | |||
| aa93a3f2e2 | |||
| 8b9abcb6cc | |||
| 1ecc1908c7 | |||
| 6a2c7b467d | |||
| 0acef57865 | |||
| 43046ee649 | |||
| a15fda0c08 | |||
| e5988764ce | |||
| 9c9d9b5a8d | |||
| 44dc564d85 | |||
| 83e367afab | |||
| 8b7e7c2669 | |||
| 53474021b7 | |||
| da1ed1b5b2 | |||
| e08d661600 | |||
| 1aa1bc7a26 | |||
| 47634e942e | |||
| 15466cbf1a | |||
| 2a749db427 | |||
| ecccce86e4 |
@@ -89,6 +89,7 @@ docs/*
|
||||
!docs/MCP-SERVER.md
|
||||
!docs/CLI-TOOLS.md
|
||||
|
||||
|
||||
# open-sse tests
|
||||
open-sse/test/*
|
||||
|
||||
|
||||
+219
@@ -2,6 +2,225 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
> **Coming next** — see [3.0.0-rc branch](https://github.com/diegosouzapw/OmniRoute/tree/3.0.0-rc).
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0-rc.6] — 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 · 10 sub2api gap improvements.
|
||||
|
||||
### 🆕 New Providers
|
||||
|
||||
| Provider | Alias | Tier | Notes |
|
||||
| ---------------- | -------------- | ---- | -------------------------------------------------------------- |
|
||||
| **OpenCode Zen** | `opencode-zen` | Free | 3 models via `opencode.ai/zen/v1` (PR #530 by @kang-heewon) |
|
||||
| **OpenCode Go** | `opencode-go` | Paid | 4 models via `opencode.ai/zen/go/v1` (PR #530 by @kang-heewon) |
|
||||
|
||||
Both providers use the new `OpencodeExecutor` with multi-format routing (`/chat/completions`, `/messages`, `/responses`, `/models/{model}:generateContent`).
|
||||
|
||||
---
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
#### 🔑 Registered Keys Provisioning API (#464)
|
||||
|
||||
Auto-generate and issue OmniRoute API keys programmatically with per-provider and per-account quota enforcement.
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
| ------------------------------------- | --------- | ------------------------------------------------ |
|
||||
| `/api/v1/registered-keys` | `POST` | Issue a new key — raw key returned **once only** |
|
||||
| `/api/v1/registered-keys` | `GET` | List registered keys (masked) |
|
||||
| `/api/v1/registered-keys/{id}` | `GET` | Get key metadata |
|
||||
| `/api/v1/registered-keys/{id}` | `DELETE` | Revoke a key |
|
||||
| `/api/v1/registered-keys/{id}/revoke` | `POST` | Revoke (for clients without DELETE support) |
|
||||
| `/api/v1/quotas/check` | `GET` | Pre-validate quota before issuing |
|
||||
| `/api/v1/providers/{id}/limits` | `GET/PUT` | Configure per-provider issuance limits |
|
||||
| `/api/v1/accounts/{id}/limits` | `GET/PUT` | Configure per-account issuance limits |
|
||||
| `/api/v1/issues/report` | `POST` | Report quota events to GitHub Issues |
|
||||
|
||||
**DB — Migration 008:** Three new tables: `registered_keys`, `provider_key_limits`, `account_key_limits`.
|
||||
**Security:** Keys stored as SHA-256 hashes. Raw key shown once on creation, never retrievable again.
|
||||
**Quota types:** `maxActiveKeys`, `dailyIssueLimit`, `hourlyIssueLimit` per provider and per account.
|
||||
**Idempotency:** `idempotency_key` field prevents duplicate issuance. Returns `409 IDEMPOTENCY_CONFLICT` if key was already used.
|
||||
**Budget per key:** `dailyBudget` / `hourlyBudget` — limits how many requests a key can route per window.
|
||||
**GitHub reporting:** Optional. Set `GITHUB_ISSUES_REPO` + `GITHUB_ISSUES_TOKEN` to auto-create GitHub issues on quota exceeded or issuance failures.
|
||||
|
||||
#### 🎨 Provider Icons — @lobehub/icons (#529)
|
||||
|
||||
All provider icons in the dashboard now use `@lobehub/icons` React components (130+ providers with SVG).
|
||||
Fallback chain: **Lobehub SVG → existing `/providers/{id}.png` → generic icon**. Uses a proper React `ErrorBoundary` pattern.
|
||||
|
||||
#### 🔄 Model Auto-Sync Scheduler (#488)
|
||||
|
||||
OmniRoute now automatically refreshes model lists for connected providers every **24 hours**.
|
||||
|
||||
- Runs on server startup via the existing `/api/sync/initialize` hook
|
||||
- Configurable via `MODEL_SYNC_INTERVAL_HOURS` environment variable
|
||||
- Covers 16 major providers
|
||||
- Records last sync time in the settings database
|
||||
|
||||
---
|
||||
|
||||
### 🔧 Bug Fixes
|
||||
|
||||
#### OAuth & Auth
|
||||
|
||||
- **#537 — Gemini CLI OAuth:** Clear actionable error when `GEMINI_OAUTH_CLIENT_SECRET` is missing in Docker/self-hosted deployments. Previously showed cryptic `client_secret is missing` from Google. Now provides specific `docker-compose.yml` and `~/.omniroute/.env` instructions.
|
||||
|
||||
#### Providers & Routing
|
||||
|
||||
- **#536 — LongCat AI:** Fixed `baseUrl` (`api.longcat.chat/openai`) and `authHeader` (`Authorization: Bearer`).
|
||||
- **#535 — Pinned model override:** `body.model` is now correctly set to `pinnedModel` when context-cache protection is active.
|
||||
- **#532 — OpenCode Go key validation:** Now uses the `zen/v1` test endpoint (`testKeyBaseUrl`) — same key works for both tiers.
|
||||
|
||||
#### CLI & Tools
|
||||
|
||||
- **#527 — Claude Code + Codex loop:** `tool_result` blocks are now converted to text instead of dropped, stopping infinite tool-result loops.
|
||||
- **#524 — OpenCode config save:** Added `saveOpenCodeConfig()` handler (XDG_CONFIG_HOME aware, writes TOML).
|
||||
- **#521 — Login stuck:** Login no longer freezes after skipping password setup — redirects correctly to onboarding.
|
||||
- **#522 — API Manager:** Removed misleading "Copy masked key" button (replaced with a lock icon tooltip).
|
||||
- **#532 — OpenCode Go config:** Guide settings handler now handles `opencode` toolId.
|
||||
|
||||
#### Developer Experience
|
||||
|
||||
- **#489 — Antigravity:** Missing `googleProjectId` returns a structured 422 error with reconnect guidance instead of a cryptic crash.
|
||||
- **#510 — Windows paths:** MSYS2/Git-Bash paths (`/c/Program Files/...`) are now normalized to `C:\\Program Files\\...` automatically.
|
||||
- **#492 — CLI startup:** `omniroute` CLI now detects `mise`/`nvm`-managed Node when `app/server.js` is missing and shows targeted fix instructions.
|
||||
|
||||
---
|
||||
|
||||
### 📖 Documentation Updates
|
||||
|
||||
- **#513** — Docker password reset: `INITIAL_PASSWORD` env var workaround documented
|
||||
- **#520** — pnpm: `pnpm approve-builds better-sqlite3` step documented
|
||||
|
||||
---
|
||||
|
||||
### ✅ Issues Resolved in v3.0.0
|
||||
|
||||
`#464` `#488` `#489` `#492` `#510` `#513` `#520` `#521` `#522` `#524` `#527` `#529` `#532` `#535` `#536` `#537`
|
||||
|
||||
---
|
||||
|
||||
### 🔀 Community PRs Merged
|
||||
|
||||
| PR | Author | Summary |
|
||||
| -------- | ------------ | ---------------------------------------------------------------------- |
|
||||
| **#530** | @kang-heewon | OpenCode Zen + Go providers with `OpencodeExecutor` and improved tests |
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0-rc.6] - 2026-03-23
|
||||
|
||||
### 🔧 Bug Fixes & Improvements (sub2api Gap Analysis — T01–T15)
|
||||
|
||||
- **T01** — `requested_model` column in `call_logs` (migration 009): track which model the client originally requested vs the actual routed model. Enables fallback rate analytics.
|
||||
- **T02** — Strip empty text blocks from nested `tool_result.content`: prevents Anthropic 400 errors (`text content blocks must be non-empty`) when Claude Code chains tool results.
|
||||
- **T03** — Parse `x-codex-5h-*` / `x-codex-7d-*` headers: `parseCodexQuotaHeaders()` + `getCodexResetTime()` extract Codex quota windows for precise cooldown scheduling instead of generic 5-min fallback.
|
||||
- **T04** — `X-Session-Id` header for external sticky routing: `extractExternalSessionId()` in `sessionManager.ts` reads `x-session-id` / `x-omniroute-session` headers with `ext:` prefix to avoid collision with internal SHA-256 session IDs. Nginx-compatible (hyphenated header).
|
||||
- **T06** — Account deactivated → permanent block: `isAccountDeactivated()` in `accountFallback.ts` detects 401 deactivation signals and applies a 1-year cooldown to prevent retrying permanently dead accounts.
|
||||
- **T07** — X-Forwarded-For IP validation: new `src/lib/ipUtils.ts` with `extractClientIp()` and `getClientIpFromRequest()` — skips `unknown`/non-IP entries in `X-Forwarded-For` chains (Nginx/proxy-forwarded requests).
|
||||
- **T10** — Credits exhausted → distinct fallback: `isCreditsExhausted()` in `accountFallback.ts` returns 1h cooldown with `creditsExhausted` flag, distinct from generic 429 rate limiting.
|
||||
- **T11** — `max` reasoning effort → 131072 budget tokens: `EFFORT_BUDGETS` and `THINKING_LEVEL_MAP` updated; reverse mapping now returns `"max"` for full-budget responses. Unit test updated.
|
||||
- **T12** — MiniMax M2.7 pricing entries added: `minimax-m2.7`, `MiniMax-M2.7`, `minimax-m2.7-highspeed` added to pricing table (sub2api PR #1120). M2.5/GLM-4.7/GLM-5/Kimi pricing already existed.
|
||||
- **T15** — Array content normalization: `normalizeContentToString()` helper in `openai-to-claude.ts` correctly collapses array-formatted system/tool messages to string before sending to Anthropic.
|
||||
|
||||
### 🧪 Tests
|
||||
|
||||
- Test suite: **832 tests, 0 failures** (unchanged from rc.5)
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0-rc.5] - 2026-03-22
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **#464** — Registered Keys Provisioning API: auto-issue API keys with per-provider & per-account quota enforcement
|
||||
- `POST /api/v1/registered-keys` — issue keys with idempotency support
|
||||
- `GET /api/v1/registered-keys` — list (masked) registered keys
|
||||
- `GET /api/v1/registered-keys/{id}` — get key metadata
|
||||
- `DELETE /api/v1/registered-keys/{id}` / `POST ../{id}/revoke` — revoke keys
|
||||
- `GET /api/v1/quotas/check` — pre-validate before issuing
|
||||
- `PUT /api/v1/providers/{id}/limits` — set provider issuance limits
|
||||
- `PUT /api/v1/accounts/{id}/limits` — set account issuance limits
|
||||
- `POST /api/v1/issues/report` — optional GitHub issue reporting
|
||||
- DB migration 008: `registered_keys`, `provider_key_limits`, `account_key_limits` tables
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0-rc.4] - 2026-03-22
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **#530 (PR)** — OpenCode Zen and OpenCode Go providers added (by @kang-heewon)
|
||||
- New `OpencodeExecutor` with multi-format routing (`/chat/completions`, `/messages`, `/responses`)
|
||||
- 7 models across both tiers
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0-rc.3] - 2026-03-22
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **#529** — Provider icons now use [@lobehub/icons](https://github.com/lobehub/lobe-icons) with graceful PNG fallback and a `ProviderIcon` component (130+ providers supported)
|
||||
- **#488** — Auto-update model lists every 24h via `modelSyncScheduler` (configurable via `MODEL_SYNC_INTERVAL_HOURS`)
|
||||
|
||||
### 🔧 Bug Fixes
|
||||
|
||||
- **#537** — Gemini CLI OAuth: now shows clear actionable error when `GEMINI_OAUTH_CLIENT_SECRET` is missing in Docker/self-hosted deployments
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0-rc.2] - 2026-03-22
|
||||
|
||||
### 🔧 Bug Fixes
|
||||
|
||||
- **#536** — LongCat AI key validation: fixed baseUrl (`api.longcat.chat/openai`) and authHeader (`Authorization: Bearer`)
|
||||
- **#535** — Pinned model override: `body.model` is now set to `pinnedModel` when context-cache protection detects a pinned model
|
||||
- **#524** — OpenCode config now saved correctly: added `saveOpenCodeConfig()` handler (XDG_CONFIG_HOME aware, writes TOML)
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0-rc.1] - 2026-03-22
|
||||
|
||||
### 🔧 Bug Fixes
|
||||
|
||||
- **#521** — Login no longer gets stuck after skipping password setup (redirects to onboarding)
|
||||
- **#522** — API Manager: Removed misleading "Copy masked key" button (replaced with lock icon tooltip)
|
||||
- **#527** — Claude Code + Codex superpowers loop: `tool_result` blocks now converted to text instead of dropped
|
||||
- **#532** — OpenCode GO API key validation now uses the correct `zen/v1` endpoint (`testKeyBaseUrl`)
|
||||
- **#489** — Antigravity: missing `googleProjectId` returns structured 422 error with reconnect guidance
|
||||
- **#510** — Windows: MSYS2/Git-Bash paths (`/c/Program Files/...`) are now normalized to `C:\\Program Files\\...`
|
||||
- **#492** — `omniroute` CLI now detects `mise`/`nvm` when `app/server.js` is missing and shows targeted fix
|
||||
|
||||
### 📖 Documentation
|
||||
|
||||
- **#513** — Docker password reset: `INITIAL_PASSWORD` env var workaround documented
|
||||
- **#520** — pnpm: `pnpm approve-builds better-sqlite3` documented
|
||||
|
||||
### ✅ Closed Issues
|
||||
|
||||
#489, #492, #510, #513, #520, #521, #522, #525, #527, #532
|
||||
|
||||
---
|
||||
|
||||
## [2.9.5] — 2026-03-22
|
||||
|
||||
> Sprint: New OpenCode providers, embedding credentials fix, CLI masked key bug, CACHE_TAG_PATTERN fix.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **CLI tools save masked API key to config files** — `claude-settings`, `cline-settings`, and `openclaw-settings` POST routes now accept a `keyId` param and resolve the real API key from DB before writing to disk. `ClaudeToolCard` updated to send `keyId` instead of the masked display string. Fixes #523, #526.
|
||||
- **Custom embedding providers: `No credentials` error** — `/v1/embeddings` now tracks `credentialsProviderId` separately from the routing prefix, so credentials are fetched from the matching provider node ID rather than the public prefix string. Fixes a regression where `google/gemini-embedding-001` and similar custom-provider models would always fail with a credentials error. Fixes #532-related. (PR #528 by @jacob2826)
|
||||
- **Context cache protection regex misses `\n` prefix** — `CACHE_TAG_PATTERN` in `comboAgentMiddleware.ts` updated to match both literal `\n` (backslash-n) and actual newline U+000A that `combo.ts` streaming injects around the `<omniModel>` tag after fix #515. Fixes #531.
|
||||
|
||||
### ✨ New Providers
|
||||
|
||||
- **OpenCode Zen** — Free tier gateway at `opencode.ai/zen/v1` with 3 models: `minimax-m2.5-free`, `big-pickle`, `gpt-5-nano`
|
||||
- **OpenCode Go** — Subscription service at `opencode.ai/zen/go/v1` with 4 models: `glm-5`, `kimi-k2.5`, `minimax-m2.7` (Claude format), `minimax-m2.5` (Claude format)
|
||||
- Both providers use the new `OpencodeExecutor` which routes dynamically to `/chat/completions`, `/messages`, `/responses`, or `/models/{model}:generateContent` based on the requested model. (PR #530 by @kang-heewon)
|
||||
|
||||
---
|
||||
|
||||
## [2.9.4] — 2026-03-21
|
||||
|
||||
@@ -26,6 +26,25 @@ _Your universal API proxy — one endpoint, 44+ providers, zero downtime. Now wi
|
||||
|
||||
---
|
||||
|
||||
## 🆕 What's New in v3.0.0
|
||||
|
||||
> **Upgrading from v2.9.5?** — See the [full CHANGELOG](CHANGELOG.md#300--2026-03-22-release-candidate--not-yet-merged-to-main) for all changes.
|
||||
|
||||
| Area | Change |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🔑 **Registered Keys API** | Auto-provision API keys via `POST /api/v1/registered-keys` with per-provider/account quota enforcement, idempotency, SHA-256 storage, and optional GitHub issue reporting |
|
||||
| 🎨 **Provider Icons** | 130+ provider logos via `@lobehub/icons` (SVG) with PNG → generic fallback chain |
|
||||
| 🔄 **Model Auto-Sync** | 24h scheduler refreshes model lists for 16 providers on startup — configurable via `MODEL_SYNC_INTERVAL_HOURS` |
|
||||
| 🌐 **OpenCode Zen/Go** | Two new providers from @kang-heewon via PR #530: free tier + subscription tier via `OpencodeExecutor` |
|
||||
| 🐛 **Gemini CLI OAuth** | Actionable error when `GEMINI_OAUTH_CLIENT_SECRET` is missing in Docker (was cryptic Google error) |
|
||||
| 🐛 **OpenCode config** | `saveOpenCodeConfig()` now correctly writes TOML to `XDG_CONFIG_HOME` |
|
||||
| 🐛 **Pinned model override** | `body.model` correctly set to `pinnedModel` on context-cache protection |
|
||||
| 🐛 **Codex/Claude loop** | `tool_result` blocks now converted to text to stop infinite loops |
|
||||
| 🐛 **Login redirect** | Login no longer freezes after skipping password setup |
|
||||
| 🐛 **Windows paths** | MSYS2/Git-Bash paths (`/c/...`) normalized to `C:\...` automatically |
|
||||
|
||||
---
|
||||
|
||||
## 🖼️ Main Dashboard
|
||||
|
||||
<div align="center">
|
||||
|
||||
+21
-2
@@ -189,8 +189,27 @@ const serverJs = join(APP_DIR, "server.js");
|
||||
|
||||
if (!existsSync(serverJs)) {
|
||||
console.error("\x1b[31m✖ Server not found at:\x1b[0m", serverJs);
|
||||
console.error(" This usually means the package was not built correctly.");
|
||||
console.error(" Try reinstalling: npm install -g omniroute");
|
||||
console.error(" The package may not have been built correctly.");
|
||||
console.error("");
|
||||
// (#492) Detect common non-standard Node managers that cause this issue
|
||||
const nodeExec = process.execPath || "";
|
||||
const isMise = nodeExec.includes("mise") || nodeExec.includes(".local/share/mise");
|
||||
const isNvm = nodeExec.includes(".nvm") || nodeExec.includes("nvm");
|
||||
if (isMise) {
|
||||
console.error(
|
||||
" \x1b[33m⚠ mise detected:\x1b[0m If you installed via `npm install -g omniroute`,"
|
||||
);
|
||||
console.error(" try: \x1b[36mnpx omniroute@latest\x1b[0m (downloads a fresh copy)");
|
||||
console.error(" or: \x1b[36mmise exec -- npx omniroute\x1b[0m");
|
||||
} else if (isNvm) {
|
||||
console.error(
|
||||
" \x1b[33m⚠ nvm detected:\x1b[0m Try reinstalling after loading the correct Node version:"
|
||||
);
|
||||
console.error(" \x1b[36mnvm use --lts && npm install -g omniroute\x1b[0m");
|
||||
} else {
|
||||
console.error(" Try: \x1b[36mnpm install -g omniroute\x1b[0m (reinstall)");
|
||||
console.error(" Or: \x1b[36mnpx omniroute@latest\x1b[0m");
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: OmniRoute API
|
||||
version: 2.9.4
|
||||
version: 3.0.0-rc.6
|
||||
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,
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface EmbeddingProvider {
|
||||
}
|
||||
|
||||
export interface EmbeddingProviderNodeRow {
|
||||
id?: string;
|
||||
prefix: string;
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
|
||||
@@ -47,6 +47,8 @@ export interface RegistryEntry {
|
||||
executor: string;
|
||||
baseUrl?: string;
|
||||
baseUrls?: string[];
|
||||
/** Override base URL used only for API key validation (e.g., opencode-go validates on zen/v1) */
|
||||
testKeyBaseUrl?: string;
|
||||
responsesBaseUrl?: string;
|
||||
urlSuffix?: string;
|
||||
urlBuilder?: (base: string, model: string, stream: boolean) => string;
|
||||
@@ -495,6 +497,41 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
],
|
||||
},
|
||||
|
||||
"opencode-go": {
|
||||
id: "opencode-go",
|
||||
alias: "opencode-go",
|
||||
format: "openai",
|
||||
executor: "opencode",
|
||||
baseUrl: "https://opencode.ai/zen/go/v1",
|
||||
// (#532) Key validation must hit the main zen endpoint (same key works for both tiers)
|
||||
testKeyBaseUrl: "https://opencode.ai/zen/v1",
|
||||
authType: "apikey",
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer",
|
||||
models: [
|
||||
{ id: "glm-5", name: "GLM-5" },
|
||||
{ id: "kimi-k2.5", name: "Kimi K2.5" },
|
||||
{ id: "minimax-m2.7", name: "MiniMax M2.7", targetFormat: "claude" },
|
||||
{ id: "minimax-m2.5", name: "MiniMax M2.5", targetFormat: "claude" },
|
||||
],
|
||||
},
|
||||
|
||||
"opencode-zen": {
|
||||
id: "opencode-zen",
|
||||
alias: "opencode-zen",
|
||||
format: "openai",
|
||||
executor: "opencode",
|
||||
baseUrl: "https://opencode.ai/zen/v1",
|
||||
authType: "apikey",
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer",
|
||||
models: [
|
||||
{ id: "minimax-m2.5-free", name: "MiniMax M2.5 Free" },
|
||||
{ id: "big-pickle", name: "Big Pickle" },
|
||||
{ id: "gpt-5-nano", name: "GPT 5 Nano" },
|
||||
],
|
||||
},
|
||||
|
||||
openrouter: {
|
||||
id: "openrouter",
|
||||
alias: "openrouter",
|
||||
@@ -1168,9 +1205,13 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
alias: "lc",
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
baseUrl: "https://longcat.chat/api/v1/chat/completions",
|
||||
// (#536) Correct OpenAI-compatible base URL — was longcat.chat/api/v1/chat/completions
|
||||
// which is the chat endpoint directly, not the base. Key validation and routing must
|
||||
// use https://api.longcat.chat/openai which resolves /v1/models and /v1/chat/completions
|
||||
baseUrl: "https://api.longcat.chat/openai",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer",
|
||||
// Free tier: 50M tokens/day (Flash-Lite) + 500K/day (Chat/Thinking) — 100% free while public beta
|
||||
models: [
|
||||
{ id: "LongCat-Flash-Lite", name: "LongCat Flash-Lite (50M tok/day 🆓)" },
|
||||
@@ -1200,6 +1241,68 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
],
|
||||
},
|
||||
|
||||
puter: {
|
||||
id: "puter",
|
||||
alias: "pu",
|
||||
format: "openai",
|
||||
executor: "puter",
|
||||
// OpenAI-compatible gateway with 500+ models (GPT, Claude, Gemini, Grok, DeepSeek, Qwen…)
|
||||
// Auth: Bearer <puter_auth_token> from puter.com/dashboard → Copy Auth Token
|
||||
// Model IDs use provider/model-name format for non-OpenAI models.
|
||||
// Only chat completions (incl. streaming) are available via REST.
|
||||
// Image gen, TTS, STT, video are puter.js SDK-only (browser).
|
||||
baseUrl: "https://api.puter.com/puterai/openai/v1/chat/completions",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: [
|
||||
// OpenAI — use bare IDs
|
||||
{ id: "gpt-4o-mini", name: "GPT-4o Mini (🆓 Puter)" },
|
||||
{ id: "gpt-4o", name: "GPT-4o (Puter)" },
|
||||
{ id: "gpt-4.1", name: "GPT-4.1 (Puter)" },
|
||||
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini (Puter)" },
|
||||
{ id: "gpt-5-nano", name: "GPT-5 Nano (Puter)" },
|
||||
{ id: "gpt-5-mini", name: "GPT-5 Mini (Puter)" },
|
||||
{ id: "gpt-5", name: "GPT-5 (Puter)" },
|
||||
{ id: "o3-mini", name: "OpenAI o3-mini (Puter)" },
|
||||
{ id: "o3", name: "OpenAI o3 (Puter)" },
|
||||
{ id: "o4-mini", name: "OpenAI o4-mini (Puter)" },
|
||||
// Anthropic Claude — use bare IDs (confirmed working)
|
||||
{ id: "claude-haiku-4-5", name: "Claude Haiku 4.5 (Puter)" },
|
||||
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5 (Puter)" },
|
||||
{ id: "claude-opus-4-5", name: "Claude Opus 4.5 (Puter)" },
|
||||
{ id: "claude-sonnet-4", name: "Claude Sonnet 4 (Puter)" },
|
||||
{ id: "claude-opus-4", name: "Claude Opus 4 (Puter)" },
|
||||
// Google Gemini — use google/ prefix (confirmed working)
|
||||
{ id: "google/gemini-2.0-flash", name: "Gemini 2.0 Flash (Puter)" },
|
||||
{ id: "google/gemini-2.5-flash", name: "Gemini 2.5 Flash (Puter)" },
|
||||
{ id: "google/gemini-2.5-pro", name: "Gemini 2.5 Pro (Puter)" },
|
||||
{ id: "google/gemini-3-flash", name: "Gemini 3 Flash (Puter)" },
|
||||
{ id: "google/gemini-3-pro", name: "Gemini 3 Pro (Puter)" },
|
||||
// DeepSeek — use deepseek/ prefix (confirmed working)
|
||||
{ id: "deepseek/deepseek-chat", name: "DeepSeek Chat (Puter)" },
|
||||
{ id: "deepseek/deepseek-r1", name: "DeepSeek R1 (Puter)" },
|
||||
{ id: "deepseek/deepseek-v3.2", name: "DeepSeek V3.2 (Puter)" },
|
||||
// xAI Grok — use x-ai/ prefix
|
||||
{ id: "x-ai/grok-3", name: "Grok 3 (Puter)" },
|
||||
{ id: "x-ai/grok-3-mini", name: "Grok 3 Mini (Puter)" },
|
||||
{ id: "x-ai/grok-4", name: "Grok 4 (Puter)" },
|
||||
{ id: "x-ai/grok-4-fast", name: "Grok 4 Fast (Puter)" },
|
||||
// Meta Llama — bare IDs (confirmed ✅)
|
||||
{ id: "llama-4-scout", name: "Llama 4 Scout (Puter)" },
|
||||
{ id: "llama-4-maverick", name: "Llama 4 Maverick (Puter)" },
|
||||
{ id: "llama-3.3-70b-instruct", name: "Llama 3.3 70B (Puter)" },
|
||||
// Mistral — bare IDs (confirmed ✅)
|
||||
{ id: "mistral-small-latest", name: "Mistral Small (Puter)" },
|
||||
{ id: "mistral-medium-latest", name: "Mistral Medium (Puter)" },
|
||||
{ id: "open-mistral-nemo", name: "Mistral Nemo (Puter)" },
|
||||
// Qwen — use qwen/ prefix (confirmed ✅)
|
||||
{ id: "qwen/qwen3-235b-a22b", name: "Qwen3 235B (Puter)" },
|
||||
{ id: "qwen/qwen3-32b", name: "Qwen3 32B (Puter)" },
|
||||
{ id: "qwen/qwen3-coder", name: "Qwen3 Coder 480B (Puter)" },
|
||||
],
|
||||
passthroughModels: true, // 500+ models available — users can type any Puter model ID
|
||||
},
|
||||
|
||||
"cloudflare-ai": {
|
||||
id: "cloudflare-ai",
|
||||
alias: "cf",
|
||||
|
||||
@@ -44,12 +44,28 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
// stale/wrong client-side values causing 404/403 from Cloud Code endpoints.
|
||||
// Opt-in escape hatch: set OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE=1.
|
||||
const projectId =
|
||||
allowBodyProjectOverride && bodyProjectId ? bodyProjectId : credentialsProjectId || bodyProjectId;
|
||||
allowBodyProjectOverride && bodyProjectId
|
||||
? bodyProjectId
|
||||
: credentialsProjectId || bodyProjectId;
|
||||
|
||||
if (!projectId) {
|
||||
throw new Error(
|
||||
"Missing Google projectId for Antigravity account. Please reconnect OAuth so OmniRoute can fetch your real Cloud Code project (loadCodeAssist)."
|
||||
);
|
||||
// (#489) Return a structured error instead of throwing — gives the client a clear signal
|
||||
// to show a "Reconnect OAuth" prompt rather than an opaque "Internal Server Error".
|
||||
const errorMsg =
|
||||
"Missing Google projectId for Antigravity account. Please reconnect OAuth in Providers → Antigravity so OmniRoute can fetch your Cloud Code project.";
|
||||
const errorBody = {
|
||||
error: {
|
||||
message: errorMsg,
|
||||
type: "oauth_missing_project_id",
|
||||
code: "missing_project_id",
|
||||
},
|
||||
};
|
||||
const resp = new Response(JSON.stringify(errorBody), {
|
||||
status: 422,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
// Returning a Response object signals the executor to stop and forward it
|
||||
return resp as unknown as never;
|
||||
}
|
||||
|
||||
// Fix contents for Claude models via Antigravity
|
||||
|
||||
@@ -3,6 +3,72 @@ import { CODEX_DEFAULT_INSTRUCTIONS } from "../config/codexInstructions.ts";
|
||||
import { PROVIDERS } from "../config/constants.ts";
|
||||
import { refreshCodexToken } from "../services/tokenRefresh.ts";
|
||||
|
||||
/**
|
||||
* T03: Parsed quota snapshot from Codex response headers.
|
||||
* Codex includes per-account usage windows that allow precise reset scheduling.
|
||||
* Ref: sub2api PR #357 (feat(oauth): persist usage snapshots and window cooldown)
|
||||
*/
|
||||
export interface CodexQuotaSnapshot {
|
||||
usage5h: number; // tokens used in 5h window
|
||||
limit5h: number; // token limit for 5h window
|
||||
resetAt5h: string | null; // ISO timestamp when 5h window resets
|
||||
usage7d: number; // tokens used in 7d window
|
||||
limit7d: number; // token limit for 7d window
|
||||
resetAt7d: string | null; // ISO timestamp when 7d window resets
|
||||
}
|
||||
|
||||
/**
|
||||
* T03: Parse Codex-specific quota headers from a provider response.
|
||||
* Returns null if none of the relevant headers are present.
|
||||
*
|
||||
* Extracts:
|
||||
* x-codex-5h-usage / x-codex-5h-limit / x-codex-5h-reset-at
|
||||
* x-codex-7d-usage / x-codex-7d-limit / x-codex-7d-reset-at
|
||||
*/
|
||||
export function parseCodexQuotaHeaders(headers: Headers): CodexQuotaSnapshot | null {
|
||||
const usage5h = headers.get("x-codex-5h-usage");
|
||||
const limit5h = headers.get("x-codex-5h-limit");
|
||||
const resetAt5h = headers.get("x-codex-5h-reset-at");
|
||||
const usage7d = headers.get("x-codex-7d-usage");
|
||||
const limit7d = headers.get("x-codex-7d-limit");
|
||||
const resetAt7d = headers.get("x-codex-7d-reset-at");
|
||||
|
||||
// Return null if none of the quota headers are present (not a quota-aware response)
|
||||
if (!usage5h && !limit5h && !resetAt5h && !usage7d && !limit7d && !resetAt7d) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
usage5h: usage5h ? parseFloat(usage5h) : 0,
|
||||
limit5h: limit5h ? parseFloat(limit5h) : Infinity,
|
||||
resetAt5h: resetAt5h ?? null,
|
||||
usage7d: usage7d ? parseFloat(usage7d) : 0,
|
||||
limit7d: limit7d ? parseFloat(limit7d) : Infinity,
|
||||
resetAt7d: resetAt7d ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* T03: Get the soonest quota reset time from a CodexQuotaSnapshot.
|
||||
* 7d window takes priority (wider window, harder limit) but we use whichever
|
||||
* is further in the future to avoid releasing the block too early.
|
||||
*
|
||||
* @returns Unix timestamp (ms) of the soonest effective reset, or null
|
||||
*/
|
||||
export function getCodexResetTime(quota: CodexQuotaSnapshot): number | null {
|
||||
const times: number[] = [];
|
||||
if (quota.resetAt7d) {
|
||||
const t = new Date(quota.resetAt7d).getTime();
|
||||
if (!isNaN(t) && t > Date.now()) times.push(t);
|
||||
}
|
||||
if (quota.resetAt5h) {
|
||||
const t = new Date(quota.resetAt5h).getTime();
|
||||
if (!isNaN(t) && t > Date.now()) times.push(t);
|
||||
}
|
||||
if (times.length === 0) return null;
|
||||
return Math.max(...times); // Use furthest-out reset to avoid premature unblock
|
||||
}
|
||||
|
||||
// Ordered list of effort levels from lowest to highest
|
||||
const EFFORT_ORDER = ["none", "low", "medium", "high", "xhigh"] as const;
|
||||
type EffortLevel = (typeof EFFORT_ORDER)[number];
|
||||
|
||||
@@ -8,6 +8,8 @@ import { CursorExecutor } from "./cursor.ts";
|
||||
import { DefaultExecutor } from "./default.ts";
|
||||
import { PollinationsExecutor } from "./pollinations.ts";
|
||||
import { CloudflareAIExecutor } from "./cloudflare-ai.ts";
|
||||
import { OpencodeExecutor } from "./opencode.ts";
|
||||
import { PuterExecutor } from "./puter.ts";
|
||||
|
||||
const executors = {
|
||||
antigravity: new AntigravityExecutor(),
|
||||
@@ -22,6 +24,10 @@ const executors = {
|
||||
pol: new PollinationsExecutor(), // Alias
|
||||
"cloudflare-ai": new CloudflareAIExecutor(),
|
||||
cf: new CloudflareAIExecutor(), // Alias
|
||||
"opencode-zen": new OpencodeExecutor("opencode-zen"),
|
||||
"opencode-go": new OpencodeExecutor("opencode-go"),
|
||||
puter: new PuterExecutor(),
|
||||
pu: new PuterExecutor(), // Alias
|
||||
};
|
||||
|
||||
const defaultCache = new Map();
|
||||
@@ -47,3 +53,5 @@ export { CursorExecutor } from "./cursor.ts";
|
||||
export { DefaultExecutor } from "./default.ts";
|
||||
export { PollinationsExecutor } from "./pollinations.ts";
|
||||
export { CloudflareAIExecutor } from "./cloudflare-ai.ts";
|
||||
export { OpencodeExecutor } from "./opencode.ts";
|
||||
export { PuterExecutor } from "./puter.ts";
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { BaseExecutor, type ExecuteInput, type ProviderCredentials } from "./base.ts";
|
||||
import { PROVIDERS } from "../config/constants.ts";
|
||||
import { getModelTargetFormat } from "../config/providerModels.ts";
|
||||
|
||||
export class OpencodeExecutor extends BaseExecutor {
|
||||
_requestFormat: string | null = null;
|
||||
|
||||
constructor(provider: string) {
|
||||
super(provider, PROVIDERS[provider] || PROVIDERS.openai);
|
||||
}
|
||||
|
||||
async execute(input: ExecuteInput) {
|
||||
this._requestFormat = getModelTargetFormat(this.provider, input.model) || "openai";
|
||||
try {
|
||||
return await super.execute(input);
|
||||
} finally {
|
||||
this._requestFormat = null;
|
||||
}
|
||||
}
|
||||
|
||||
buildUrl(
|
||||
model: string,
|
||||
stream: boolean,
|
||||
urlIndex = 0,
|
||||
credentials: ProviderCredentials | null = null
|
||||
) {
|
||||
void urlIndex;
|
||||
void credentials;
|
||||
|
||||
const base = this.config.baseUrl;
|
||||
switch (this._requestFormat) {
|
||||
case "claude":
|
||||
return `${base}/messages`;
|
||||
case "openai-responses":
|
||||
return `${base}/responses`;
|
||||
case "gemini":
|
||||
return `${base}/models/${model}:${stream ? "streamGenerateContent?alt=sse" : "generateContent"}`;
|
||||
default:
|
||||
return `${base}/chat/completions`;
|
||||
}
|
||||
}
|
||||
|
||||
buildHeaders(credentials: ProviderCredentials | null, stream = true) {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
const key = credentials?.apiKey || credentials?.accessToken;
|
||||
|
||||
if (key) {
|
||||
headers["Authorization"] = `Bearer ${key}`;
|
||||
}
|
||||
|
||||
if (this._requestFormat === "claude") {
|
||||
headers["anthropic-version"] = "2023-06-01";
|
||||
}
|
||||
|
||||
if (stream) {
|
||||
headers["Accept"] = "text/event-stream";
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { BaseExecutor } from "./base.ts";
|
||||
import { PROVIDERS } from "../config/constants.ts";
|
||||
|
||||
/**
|
||||
* PuterExecutor — OpenAI-compatible proxy for Puter AI.
|
||||
*
|
||||
* Puter exposes 500+ models (GPT, Claude, Gemini, Grok, DeepSeek, Qwen, Mistral...)
|
||||
* through a single OpenAI-compatible REST endpoint.
|
||||
*
|
||||
* Endpoint: https://api.puter.com/puterai/openai/v1/chat/completions
|
||||
* Auth: Bearer <puter_auth_token> (from puter.com/dashboard → Copy Auth Token)
|
||||
* Docs: https://docs.puter.com/AI/
|
||||
*
|
||||
* Model ID examples:
|
||||
* OpenAI: "gpt-4o-mini", "gpt-4o", "gpt-4.1"
|
||||
* Claude: "claude-sonnet-4-5", "claude-opus-4", "claude-haiku-4-5"
|
||||
* Gemini: "google/gemini-2.0-flash", "google/gemini-2.5-pro"
|
||||
* DeepSeek: "deepseek/deepseek-chat", "deepseek/deepseek-r1"
|
||||
* Grok: "x-ai/grok-3", "x-ai/grok-4"
|
||||
* Mistral: "mistralai/mistral-small-3.2"
|
||||
* Meta: "meta-llama/llama-3.3-70b-instruct"
|
||||
*
|
||||
* Note: Image generation, TTS, STT, and video are puter.js SDK-only features.
|
||||
* Only text chat completions (with streaming SSE) are available via REST.
|
||||
*/
|
||||
export class PuterExecutor extends BaseExecutor {
|
||||
constructor() {
|
||||
super("puter", PROVIDERS["puter"] || { format: "openai" });
|
||||
}
|
||||
|
||||
buildUrl(_model: string, _stream: boolean, _urlIndex = 0, _credentials = null): string {
|
||||
return "https://api.puter.com/puterai/openai/v1/chat/completions";
|
||||
}
|
||||
|
||||
buildHeaders(credentials: any, stream = true): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
const key = credentials?.apiKey || credentials?.accessToken;
|
||||
if (key) {
|
||||
headers["Authorization"] = `Bearer ${key}`;
|
||||
}
|
||||
|
||||
if (stream) {
|
||||
headers["Accept"] = "text/event-stream";
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
transformRequest(model: string, body: any, _stream: boolean, _credentials: any): any {
|
||||
// Puter accepts model IDs directly from its catalog.
|
||||
// No transformation required — model string is passed as-is.
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
export default PuterExecutor;
|
||||
@@ -308,6 +308,27 @@ export async function handleChatCore({
|
||||
}
|
||||
return [];
|
||||
}
|
||||
// (#527) tool_result → convert to text instead of dropping.
|
||||
// When Claude Code + superpowers routes through Codex, it sends tool_result
|
||||
// blocks in user messages. Silently dropping them causes Codex to loop
|
||||
// because it never receives the tool response and keeps re-requesting it.
|
||||
if (block.type === "tool_result") {
|
||||
const toolId = block.tool_use_id ?? block.id ?? "unknown";
|
||||
const resultContent = block.content ?? block.text ?? block.output ?? "";
|
||||
const resultText =
|
||||
typeof resultContent === "string"
|
||||
? resultContent
|
||||
: Array.isArray(resultContent)
|
||||
? resultContent
|
||||
.filter((c: Record<string, unknown>) => c.type === "text")
|
||||
.map((c: Record<string, unknown>) => c.text)
|
||||
.join("\n")
|
||||
: JSON.stringify(resultContent);
|
||||
if (resultText.length > 0) {
|
||||
return [{ type: "text", text: `[Tool Result: ${toolId}]\n${resultText}` }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
// Unknown types: drop silently
|
||||
log?.debug?.("CONTENT", `Dropped unsupported content part type="${block.type}"`);
|
||||
return [];
|
||||
|
||||
@@ -8,6 +8,46 @@ import {
|
||||
} from "../config/constants.ts";
|
||||
import { getProviderCategory } from "../config/providerRegistry.ts";
|
||||
|
||||
// T06 (sub2api PR #1037): Signals that indicate permanent account deactivation.
|
||||
// When a 401 body contains these strings, the account is permanently dead
|
||||
// and should NOT be retried after token refresh.
|
||||
export const ACCOUNT_DEACTIVATED_SIGNALS = [
|
||||
"account_deactivated",
|
||||
"account has been deactivated",
|
||||
"account has been disabled",
|
||||
"your account has been suspended",
|
||||
"this account is deactivated",
|
||||
];
|
||||
|
||||
// T10 (sub2api PR #1169): Signals that indicate billing credits are exhausted.
|
||||
// Distinct from rate-limit 429 — the account won't recover until credits are added.
|
||||
export const CREDITS_EXHAUSTED_SIGNALS = [
|
||||
"insufficient_quota",
|
||||
"billing_hard_limit_reached",
|
||||
"exceeded your current quota",
|
||||
"credit_balance_too_low",
|
||||
"your credit balance is too low",
|
||||
"credits exhausted",
|
||||
"out of credits",
|
||||
"payment required",
|
||||
];
|
||||
|
||||
/**
|
||||
* T06: Returns true if response body indicates the account is permanently deactivated.
|
||||
*/
|
||||
export function isAccountDeactivated(errorText: string): boolean {
|
||||
const lower = String(errorText || "").toLowerCase();
|
||||
return ACCOUNT_DEACTIVATED_SIGNALS.some((sig) => lower.includes(sig));
|
||||
}
|
||||
|
||||
/**
|
||||
* T10: Returns true if response body indicates credits/quota are permanently exhausted.
|
||||
*/
|
||||
export function isCreditsExhausted(errorText: string): boolean {
|
||||
const lower = String(errorText || "").toLowerCase();
|
||||
return CREDITS_EXHAUSTED_SIGNALS.some((sig) => lower.includes(sig));
|
||||
}
|
||||
|
||||
// ─── Provider Profile Helper ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -201,6 +241,14 @@ export function classifyErrorText(errorText) {
|
||||
) {
|
||||
return RateLimitReason.QUOTA_EXHAUSTED;
|
||||
}
|
||||
// T10: credits_exhausted signals
|
||||
if (isCreditsExhausted(errorText)) {
|
||||
return RateLimitReason.QUOTA_EXHAUSTED;
|
||||
}
|
||||
// T06: account_deactivated signals
|
||||
if (isAccountDeactivated(errorText)) {
|
||||
return RateLimitReason.AUTH_ERROR;
|
||||
}
|
||||
if (
|
||||
lower.includes("rate limit") ||
|
||||
lower.includes("too many requests") ||
|
||||
@@ -301,6 +349,26 @@ export function checkFallbackError(
|
||||
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
|
||||
if (isAccountDeactivated(errorStr)) {
|
||||
return {
|
||||
shouldFallback: true,
|
||||
cooldownMs: 365 * 24 * 60 * 60 * 1000, // 1 year = effectively permanent
|
||||
reason: RateLimitReason.AUTH_ERROR,
|
||||
permanent: true,
|
||||
};
|
||||
}
|
||||
|
||||
// T10 (sub2api #1169): Credits/quota exhausted — long cooldown, distinct from rate limit
|
||||
if (isCreditsExhausted(errorStr)) {
|
||||
return {
|
||||
shouldFallback: true,
|
||||
cooldownMs: COOLDOWN_MS.paymentRequired ?? 3600 * 1000, // 1h cooldown
|
||||
reason: RateLimitReason.QUOTA_EXHAUSTED,
|
||||
creditsExhausted: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (lowerError.includes("no credentials")) {
|
||||
return {
|
||||
shouldFallback: true,
|
||||
|
||||
@@ -34,7 +34,11 @@ interface Message {
|
||||
|
||||
// ── Context Caching Tag ─────────────────────────────────────────────────────
|
||||
|
||||
const CACHE_TAG_PATTERN = /<omniModel>([^<]+)<\/omniModel>/;
|
||||
// Handles both actual newlines (U+000A) and literal \n sequences injected
|
||||
// by combo.ts streaming around the <omniModel> tag (#531). Non-global so that
|
||||
// .exec() and .test() stay stateless; callers that need full replacement use
|
||||
// String.prototype.replace() which replaces all non-overlapping matches.
|
||||
const CACHE_TAG_PATTERN = /(?:\\n|\n)?<omniModel>([^<]+)<\/omniModel>(?:\\n|\n)?/;
|
||||
|
||||
/**
|
||||
* Inject the model tag into the last assistant message (or append a new one).
|
||||
@@ -165,7 +169,11 @@ export function applyComboAgentMiddleware(
|
||||
if (comboConfig.context_cache_protection) {
|
||||
pinnedModel = extractPinnedModel(messages);
|
||||
if (pinnedModel) {
|
||||
// Model is pinned — caller should override model selection
|
||||
// (#535) Model is pinned via <omniModel> tag — override body.model so the combo
|
||||
// router uses exactly this model instead of picking a different one. Without this,
|
||||
// the extracted pinnedModel is returned but body.model is unchanged, breaking
|
||||
// context cache sessions by sending subsequent turns to a different model.
|
||||
body = { ...body, model: pinnedModel };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -175,6 +175,32 @@ export function clearSessions(): void {
|
||||
sessions.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* T04: Extract an external session ID from request headers.
|
||||
* Accepts both hyphenated and underscore forms for Nginx compatibility.
|
||||
* Nginx drops headers with underscores by default — use `underscores_in_headers on`
|
||||
* in nginx.conf, or use X-Session-Id (hyphenated) which passes cleanly.
|
||||
*
|
||||
* Ref: sub2api README + PR #634
|
||||
*
|
||||
* @param headers - Request headers (Headers object or plain object with .get())
|
||||
* @returns External session ID with "ext:" prefix, or null
|
||||
*/
|
||||
export function extractExternalSessionId(
|
||||
headers: Headers | { get?: (n: string) => string | null } | null | undefined
|
||||
): string | null {
|
||||
if (!headers || typeof (headers as Headers).get !== "function") return null;
|
||||
const h = headers as Headers;
|
||||
const raw =
|
||||
h.get("x-session-id") ?? // Preferred: hyphenated (passes through Nginx)
|
||||
h.get("x-omniroute-session") ?? // OmniRoute-specific form
|
||||
h.get("session-id") ?? // Bare session-id
|
||||
null;
|
||||
if (!raw || !raw.trim()) return null;
|
||||
// Prefix "ext:" to ensure no collision with internal SHA-256 hash IDs
|
||||
return `ext:${raw.trim().slice(0, 64)}`; // max 64 chars to avoid abuse
|
||||
}
|
||||
|
||||
// ─── Internal Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function hashShort(text: string): string {
|
||||
|
||||
@@ -19,6 +19,8 @@ export const EFFORT_BUDGETS = {
|
||||
low: 1024,
|
||||
medium: 10240,
|
||||
high: 131072,
|
||||
max: 131072, // T11: Claude "max" / "xhigh" — full budget
|
||||
xhigh: 131072, // T11: explicit alias used internally
|
||||
};
|
||||
|
||||
// thinkingLevel string → budget token mapping
|
||||
@@ -28,6 +30,8 @@ export const THINKING_LEVEL_MAP = {
|
||||
low: 1024,
|
||||
medium: 10240,
|
||||
high: 131072,
|
||||
max: 131072, // T11: max = full Claude budget (sub2api: xhigh)
|
||||
xhigh: 131072, // T11: explicit xhigh alias
|
||||
};
|
||||
|
||||
// Default config (passthrough = backward compatible)
|
||||
@@ -198,7 +202,7 @@ function setCustomBudget(body, budget) {
|
||||
};
|
||||
}
|
||||
|
||||
// OpenAI reasoning_effort mapping
|
||||
// OpenAI reasoning_effort mapping (T11: add 'max' tier for full budget)
|
||||
if (result.reasoning_effort !== undefined || result.reasoning !== undefined) {
|
||||
if (budget <= 0) {
|
||||
delete result.reasoning_effort;
|
||||
@@ -207,8 +211,10 @@ function setCustomBudget(body, budget) {
|
||||
result.reasoning_effort = "low";
|
||||
} else if (budget <= 10240) {
|
||||
result.reasoning_effort = "medium";
|
||||
} else {
|
||||
} else if (budget < 131072) {
|
||||
result.reasoning_effort = "high";
|
||||
} else {
|
||||
result.reasoning_effort = "max"; // T11: full budget → "max"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,60 @@ type ClaudeTool = {
|
||||
defer_loading?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* T02: Recursively strips empty text blocks from content arrays.
|
||||
* Anthropic returns 400 "text content blocks must be non-empty" if any
|
||||
* text block has text: "". Must also recurse into nested tool_result.content.
|
||||
* Ref: sub2api PR #1212
|
||||
*/
|
||||
export function stripEmptyTextBlocks(content: unknown[] | undefined): unknown[] {
|
||||
if (!Array.isArray(content)) return content ?? [];
|
||||
return content
|
||||
.filter((block: unknown) => {
|
||||
if (
|
||||
block &&
|
||||
typeof block === "object" &&
|
||||
(block as Record<string, unknown>).type === "text"
|
||||
) {
|
||||
const text = (block as Record<string, unknown>).text;
|
||||
if (text === "" || text == null) return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((block: unknown) => {
|
||||
if (
|
||||
block &&
|
||||
typeof block === "object" &&
|
||||
(block as Record<string, unknown>).type === "tool_result" &&
|
||||
Array.isArray((block as Record<string, unknown>).content)
|
||||
) {
|
||||
// Recurse into nested tool_result.content
|
||||
return {
|
||||
...(block as Record<string, unknown>),
|
||||
content: stripEmptyTextBlocks((block as Record<string, unknown>).content as unknown[]),
|
||||
};
|
||||
}
|
||||
return block;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* T15: Normalize content to string form.
|
||||
* Handles both string and array-of-blocks forms (Cursor, Codex 2.x, etc.).
|
||||
* Ref: sub2api PR #1197
|
||||
*/
|
||||
export function normalizeContentToString(content: string | unknown[] | null | undefined): string {
|
||||
if (!content) return "";
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
return (content as Array<Record<string, unknown>>)
|
||||
.filter((b) => b.type === "text")
|
||||
.map((b) => String(b.text ?? ""))
|
||||
.join("\n");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// Convert OpenAI request to Claude format
|
||||
export function openaiToClaudeRequest(model, body, stream) {
|
||||
// Check if tool prefix should be disabled (configured per-provider or global)
|
||||
@@ -61,11 +115,11 @@ export function openaiToClaudeRequest(model, body, stream) {
|
||||
const systemParts = [];
|
||||
|
||||
if (body.messages && Array.isArray(body.messages)) {
|
||||
// Extract system messages
|
||||
// Extract system messages (T15: handle both string and array content)
|
||||
for (const msg of body.messages) {
|
||||
if (msg.role === "system") {
|
||||
systemParts.push(
|
||||
typeof msg.content === "string" ? msg.content : extractTextContent(msg.content)
|
||||
typeof msg.content === "string" ? msg.content : normalizeContentToString(msg.content)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -270,10 +324,14 @@ function getContentBlocksFromMessage(msg, toolNameMap = new Map(), disableToolPr
|
||||
const blocks = [];
|
||||
|
||||
if (msg.role === "tool") {
|
||||
// T02: Strip empty text blocks from nested tool_result content to avoid Anthropic 400
|
||||
const toolContent = Array.isArray(msg.content)
|
||||
? stripEmptyTextBlocks(msg.content)
|
||||
: msg.content;
|
||||
blocks.push({
|
||||
type: "tool_result",
|
||||
tool_use_id: msg.tool_call_id,
|
||||
content: msg.content,
|
||||
content: toolContent,
|
||||
});
|
||||
} else if (msg.role === "user") {
|
||||
if (typeof msg.content === "string") {
|
||||
@@ -287,10 +345,14 @@ function getContentBlocksFromMessage(msg, toolNameMap = new Map(), disableToolPr
|
||||
} else if (part.type === "tool_result") {
|
||||
// Skip tool_result with no tool_use_id (would be useless and may cause errors)
|
||||
if (!part.tool_use_id) continue;
|
||||
// T02: strip empty text blocks from nested content before passing to Anthropic
|
||||
const resultContent = Array.isArray(part.content)
|
||||
? stripEmptyTextBlocks(part.content)
|
||||
: part.content;
|
||||
blocks.push({
|
||||
type: "tool_result",
|
||||
tool_use_id: part.tool_use_id,
|
||||
content: part.content,
|
||||
content: resultContent,
|
||||
...(part.is_error && { is_error: part.is_error }),
|
||||
});
|
||||
} else if (part.type === "image_url") {
|
||||
|
||||
Generated
+7525
-40
File diff suppressed because it is too large
Load Diff
+4
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "2.9.4",
|
||||
"version": "3.0.0-rc.6",
|
||||
"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": {
|
||||
@@ -81,8 +81,10 @@
|
||||
"system-info": "node scripts/system-info.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lobehub/icons": "^5.0.1",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@swc/helpers": "0.5.19",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"bottleneck": "^2.19.5",
|
||||
@@ -110,8 +112,7 @@
|
||||
"uuid": "^13.0.0",
|
||||
"wreq-js": "^2.0.1",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.10",
|
||||
"@swc/helpers": "0.5.19"
|
||||
"zustand": "^5.0.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
|
||||
@@ -48,7 +48,8 @@ function extractChangelogSections(content) {
|
||||
}
|
||||
|
||||
function isSemver(value) {
|
||||
return /^\d+\.\d+\.\d+$/.test(value);
|
||||
// Accept X.Y.Z and X.Y.Z-prerelease.N (e.g. 3.0.0-rc.1, 3.0.0-beta.2)
|
||||
return /^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/.test(value);
|
||||
}
|
||||
|
||||
let hasFailure = false;
|
||||
|
||||
@@ -523,15 +523,12 @@ export default function ApiManagerPageClient() {
|
||||
</div>
|
||||
<div className="col-span-3 flex items-center gap-1.5">
|
||||
<code className="text-sm text-text-muted font-mono truncate">{key.key}</code>
|
||||
<button
|
||||
onClick={() => copy(key.key, key.id)}
|
||||
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary opacity-0 group-hover:opacity-100 transition-all shrink-0"
|
||||
title={t("copyMaskedKey")}
|
||||
<span
|
||||
className="p-1 text-text-muted/40 opacity-0 group-hover:opacity-100 transition-all shrink-0 cursor-help"
|
||||
title={t("keyOnlyAvailableAtCreation")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{copied === key.id ? "check" : "content_copy"}
|
||||
</span>
|
||||
</button>
|
||||
<span className="material-symbols-outlined text-[14px]">lock</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="col-span-2 flex items-center">
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
|
||||
@@ -58,8 +58,10 @@ export default function ClaudeToolCard({
|
||||
const effectiveConfigStatus = configStatus || batchStatus?.configStatus || null;
|
||||
|
||||
useEffect(() => {
|
||||
// (#523) Store the key *id* (not the masked string) so the backend can
|
||||
// resolve the real secret from DB before writing to settings.json.
|
||||
if (apiKeys?.length > 0 && !selectedApiKey) {
|
||||
setSelectedApiKey(apiKeys[0].key);
|
||||
setSelectedApiKey(apiKeys[0].id);
|
||||
}
|
||||
}, [apiKeys, selectedApiKey]);
|
||||
|
||||
@@ -95,10 +97,11 @@ export default function ClaudeToolCard({
|
||||
}
|
||||
}
|
||||
});
|
||||
// Only set selectedApiKey if it exists in apiKeys list
|
||||
// Restore selected key from file: match token stored in file against known keys
|
||||
const tokenFromFile = env.ANTHROPIC_AUTH_TOKEN;
|
||||
if (tokenFromFile && apiKeys?.some((k) => k.key === tokenFromFile)) {
|
||||
setSelectedApiKey(tokenFromFile);
|
||||
if (tokenFromFile) {
|
||||
const matchedKey = apiKeys?.find((k) => k.key === tokenFromFile);
|
||||
if (matchedKey) setSelectedApiKey(matchedKey.id);
|
||||
}
|
||||
}
|
||||
}, [claudeStatus, apiKeys, tool.defaultModels, onModelMappingChange]);
|
||||
@@ -132,24 +135,27 @@ export default function ClaudeToolCard({
|
||||
try {
|
||||
const env: any = { ANTHROPIC_BASE_URL: getEffectiveBaseUrl() };
|
||||
|
||||
// Get key from dropdown, fallback to first key or sk_omniroute for localhost
|
||||
const keyToUse =
|
||||
selectedApiKey?.trim() ||
|
||||
(apiKeys?.length > 0 ? apiKeys[0].key : null) ||
|
||||
(!cloudEnabled ? "sk_omniroute" : null);
|
||||
// (#523) Prefer keyId lookup so the backend writes the real key to disk.
|
||||
// Fall back to sk_omniroute for localhost-only setups without a key.
|
||||
const selectedKeyId = selectedApiKey?.trim() || (apiKeys?.length > 0 ? apiKeys[0].id : null);
|
||||
const skOmnirouteFallback = !cloudEnabled ? "sk_omniroute" : null;
|
||||
|
||||
if (keyToUse) {
|
||||
env.ANTHROPIC_AUTH_TOKEN = keyToUse;
|
||||
if (!selectedKeyId && skOmnirouteFallback) {
|
||||
env.ANTHROPIC_AUTH_TOKEN = skOmnirouteFallback;
|
||||
}
|
||||
|
||||
tool.defaultModels.forEach((model) => {
|
||||
const targetModel = modelMappings[model.alias];
|
||||
if (targetModel && model.envKey) env[model.envKey] = targetModel;
|
||||
});
|
||||
|
||||
const postBody: Record<string, unknown> = { env };
|
||||
if (selectedKeyId) postBody.keyId = selectedKeyId;
|
||||
|
||||
const res = await fetch("/api/cli-tools/claude-settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ env }),
|
||||
body: JSON.stringify(postBody),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
@@ -412,7 +418,7 @@ export default function ClaudeToolCard({
|
||||
className="flex-1 px-2 py-1.5 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||
>
|
||||
{apiKeys.map((key) => (
|
||||
<option key={key.id} value={key.key}>
|
||||
<option key={key.id} value={key.id}>
|
||||
{key.key}
|
||||
</option>
|
||||
))}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Image from "next/image";
|
||||
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
import PropTypes from "prop-types";
|
||||
import {
|
||||
Card,
|
||||
@@ -490,16 +491,8 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
|
||||
const t = useTranslations("providers");
|
||||
const tc = useTranslations("common");
|
||||
const { connected, error, errorCode, errorTime, allDisabled } = stats;
|
||||
const [imgSrc, setImgSrc] = useState(`/providers/${provider.id}.png`);
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
const handleImgError = () => {
|
||||
if (imgSrc.endsWith(".png")) {
|
||||
setImgSrc(`/providers/${provider.id}.svg`);
|
||||
} else {
|
||||
setImgError(true);
|
||||
}
|
||||
};
|
||||
// (#529) Icon state replaced by ProviderIcon component (Lobehub + PNG + generic fallback)
|
||||
|
||||
const dotColors = {
|
||||
free: "bg-green-500",
|
||||
@@ -526,21 +519,8 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
|
||||
className="size-8 rounded-lg flex items-center justify-center"
|
||||
style={{ backgroundColor: `${provider.color}15` }}
|
||||
>
|
||||
{imgError ? (
|
||||
<span className="text-xs font-bold" style={{ color: provider.color }}>
|
||||
{provider.textIcon || provider.id.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
) : (
|
||||
<Image
|
||||
src={imgSrc}
|
||||
alt={provider.name}
|
||||
width={30}
|
||||
height={30}
|
||||
className="object-contain rounded-lg max-w-[32px] max-h-[32px]"
|
||||
sizes="32px"
|
||||
onError={handleImgError}
|
||||
/>
|
||||
)}
|
||||
{/* (#529) ProviderIcon: Lobehub icons → PNG fallback → generic icon */}
|
||||
<ProviderIcon providerId={provider.id} size={28} type="color" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold flex items-center gap-1.5">
|
||||
@@ -633,28 +613,15 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
|
||||
compatible: t("compatibleLabel"),
|
||||
};
|
||||
|
||||
// Determine icon path: OpenAI Compatible providers use specialized icons
|
||||
const getIconPath = () => {
|
||||
// (#529) Icon state replaced by ProviderIcon component
|
||||
// For compatible/anthropic providers, continue using static PNGs via the icon path
|
||||
const staticIconPath = (() => {
|
||||
if (isCompatible) {
|
||||
return provider.apiType === "responses" ? "/providers/oai-r.png" : "/providers/oai-cc.png";
|
||||
}
|
||||
if (isAnthropicCompatible) {
|
||||
return "/providers/anthropic-m.png"; // Use Anthropic icon as base
|
||||
}
|
||||
return `/providers/${provider.id}.png`;
|
||||
};
|
||||
|
||||
const [imgSrc, setImgSrc] = useState<string>(() => getIconPath());
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
const handleImgError = () => {
|
||||
const basePath = getIconPath();
|
||||
if (imgSrc.endsWith(".png") && !isCompatible && !isAnthropicCompatible) {
|
||||
setImgSrc(`/providers/${provider.id}.svg`);
|
||||
} else {
|
||||
setImgError(true);
|
||||
}
|
||||
};
|
||||
if (isAnthropicCompatible) return "/providers/anthropic-m.png";
|
||||
return null; // ProviderIcon will handle it
|
||||
})();
|
||||
|
||||
return (
|
||||
<Link href={`/dashboard/providers/${providerId}`} className="group">
|
||||
@@ -668,20 +635,18 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
|
||||
className="size-8 rounded-lg flex items-center justify-center"
|
||||
style={{ backgroundColor: `${provider.color}15` }}
|
||||
>
|
||||
{imgError ? (
|
||||
<span className="text-xs font-bold" style={{ color: provider.color }}>
|
||||
{provider.textIcon || provider.id.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
) : (
|
||||
{/* (#529) ProviderIcon with static override for compatible providers */}
|
||||
{staticIconPath ? (
|
||||
<Image
|
||||
src={imgSrc || getIconPath()}
|
||||
src={staticIconPath}
|
||||
alt={provider.name}
|
||||
width={30}
|
||||
height={30}
|
||||
className="object-contain rounded-lg max-w-[30px] max-h-[30px]"
|
||||
sizes="30px"
|
||||
onError={handleImgError}
|
||||
/>
|
||||
) : (
|
||||
<ProviderIcon providerId={provider.id} size={28} type="color" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { createBackup } from "@/shared/services/backupService";
|
||||
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
|
||||
import { cliSettingsEnvSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { getApiKeyById } from "@/lib/localDb";
|
||||
|
||||
// Get claude settings path based on OS
|
||||
const getClaudeSettingsPath = () => getCliPrimaryConfigPath("claude");
|
||||
@@ -100,6 +101,22 @@ export async function POST(request: Request) {
|
||||
}
|
||||
const { env } = validation.data;
|
||||
|
||||
// (#523/#526) If a keyId was provided, resolve the real API key from DB.
|
||||
// The /api/keys list endpoint returns masked key strings — sending those to
|
||||
// disk would save an unusable half-hidden token. 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) {
|
||||
env.ANTHROPIC_AUTH_TOKEN = keyRecord.key as string;
|
||||
}
|
||||
} catch {
|
||||
// Non-critical: fall back to whatever value was in env (e.g. sk_omniroute)
|
||||
}
|
||||
}
|
||||
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
const claudeDir = path.dirname(settingsPath);
|
||||
|
||||
|
||||
@@ -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 CLINE_DATA_DIR = path.join(os.homedir(), ".cline", "data");
|
||||
const GLOBAL_STATE_PATH = path.join(CLINE_DATA_DIR, "globalState.json");
|
||||
@@ -125,7 +126,18 @@ export async function POST(request: Request) {
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const { baseUrl, apiKey, model } = validation.data;
|
||||
let { baseUrl, apiKey, model } = validation.data;
|
||||
|
||||
// (#526) 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 */
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
await fs.mkdir(CLINE_DATA_DIR, { recursive: true });
|
||||
|
||||
@@ -39,6 +39,10 @@ export async function POST(request, { params }) {
|
||||
switch (toolId) {
|
||||
case "continue":
|
||||
return await saveContinueConfig({ baseUrl, apiKey, model });
|
||||
case "opencode":
|
||||
// (#524) OpenCode config was never saved because only 'continue' was handled here.
|
||||
// opencode reads ~/.config/opencode/config.toml — write the OmniRoute settings there.
|
||||
return await saveOpenCodeConfig({ baseUrl, apiKey, model });
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ error: `Direct config save not supported for: ${toolId}` },
|
||||
@@ -125,3 +129,56 @@ async function saveContinueConfig({ baseUrl, apiKey, model }) {
|
||||
configPath,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save OpenCode config to ~/.config/opencode/config.toml (XDG_CONFIG_HOME aware).
|
||||
* (#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 configDir = path.dirname(configPath);
|
||||
|
||||
// Ensure ~/.config/opencode/ 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 = "";
|
||||
try {
|
||||
existingContent = await fs.readFile(configPath, "utf-8");
|
||||
} catch {
|
||||
// File doesn't exist yet — 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}"
|
||||
`;
|
||||
|
||||
// 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");
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `OpenCode config saved to ${configPath}`,
|
||||
configPath,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 getOpenClawSettingsPath = () => getCliPrimaryConfigPath("openclaw");
|
||||
const getOpenClawDir = () => path.dirname(getOpenClawSettingsPath());
|
||||
@@ -101,7 +102,18 @@ export async function POST(request: Request) {
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const { baseUrl, apiKey, model } = validation.data;
|
||||
let { baseUrl, apiKey, model } = validation.data;
|
||||
|
||||
// (#526) 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 */
|
||||
}
|
||||
}
|
||||
|
||||
const openclawDir = getOpenClawDir();
|
||||
const settingsPath = getOpenClawSettingsPath();
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import initializeCloudSync from "@/shared/services/initializeCloudSync";
|
||||
import { startModelSyncScheduler } from "@/shared/services/modelSyncScheduler";
|
||||
|
||||
let syncInitialized = false;
|
||||
let modelSyncInitialized = false;
|
||||
|
||||
// POST /api/sync/initialize - Initialize cloud sync scheduler
|
||||
export async function POST(request) {
|
||||
@@ -15,9 +17,17 @@ export async function POST(request) {
|
||||
await initializeCloudSync();
|
||||
syncInitialized = true;
|
||||
|
||||
// (#488) Start model auto-sync scheduler (24h, configurable via MODEL_SYNC_INTERVAL_HOURS)
|
||||
if (!modelSyncInitialized) {
|
||||
const origin = request.headers.get("origin") || "http://localhost:20128";
|
||||
startModelSyncScheduler(origin);
|
||||
modelSyncInitialized = true;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Cloud sync initialized successfully",
|
||||
modelSyncEnabled: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error initializing cloud sync:", error);
|
||||
@@ -34,6 +44,7 @@ export async function POST(request) {
|
||||
export async function GET(request) {
|
||||
return NextResponse.json({
|
||||
initialized: syncInitialized,
|
||||
modelSyncInitialized,
|
||||
message: syncInitialized ? "Cloud sync is running" : "Cloud sync not initialized",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { setAccountKeyLimit, getAccountKeyLimit } from "@/lib/db/registeredKeys";
|
||||
|
||||
const limitsSchema = z.object({
|
||||
maxActiveKeys: z.number().int().positive().nullable().optional(),
|
||||
dailyIssueLimit: z.number().int().positive().nullable().optional(),
|
||||
hourlyIssueLimit: z.number().int().positive().nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/accounts/[id]/limits
|
||||
* Get the current issuance limits for an account.
|
||||
*/
|
||||
export async function GET(request: Request, { params }: { params: { id: string } }) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
|
||||
}
|
||||
|
||||
const limits = getAccountKeyLimit(params.id);
|
||||
return NextResponse.json({ accountId: params.id, limits: limits ?? null });
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/accounts/[id]/limits
|
||||
* Configure issuance limits for an account.
|
||||
*/
|
||||
export async function PUT(request: Request, { params }: { params: { id: string } }) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = limitsSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
}
|
||||
|
||||
setAccountKeyLimit(params.id, parsed.data);
|
||||
const updated = getAccountKeyLimit(params.id);
|
||||
return NextResponse.json({ accountId: params.id, limits: updated });
|
||||
}
|
||||
@@ -160,6 +160,7 @@ export async function POST(request) {
|
||||
// Resolve provider config — dynamic first (local override), then hardcoded
|
||||
let providerConfig: EmbeddingProvider | null =
|
||||
dynamicProviders.find((dp) => dp.id === provider) || getEmbeddingProvider(provider) || null;
|
||||
let credentialsProviderId = provider;
|
||||
|
||||
// #496: Fallback — resolve from ALL provider_nodes (not just localhost)
|
||||
// This enables custom embedding models (e.g. google/gemini-embedding-001) whose
|
||||
@@ -180,6 +181,7 @@ export async function POST(request) {
|
||||
authHeader: "bearer",
|
||||
models: [],
|
||||
};
|
||||
credentialsProviderId = matchingNode.id || provider;
|
||||
log.info(
|
||||
"EMBED",
|
||||
`Resolved custom embedding provider: ${provider} → ${providerConfig.baseUrl}`
|
||||
@@ -200,7 +202,7 @@ export async function POST(request) {
|
||||
// Get credentials — skip for local providers (authType: "none")
|
||||
let credentials = null;
|
||||
if (providerConfig && providerConfig.authType !== "none") {
|
||||
credentials = await getProviderCredentials(provider);
|
||||
credentials = await getProviderCredentials(credentialsProviderId);
|
||||
if (!credentials) {
|
||||
return errorResponse(
|
||||
HTTP_STATUS.BAD_REQUEST,
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
|
||||
const reportSchema = z.object({
|
||||
title: z.string().min(1).max(300),
|
||||
provider: z.string().max(80).optional(),
|
||||
accountId: z.string().max(120).optional(),
|
||||
requestId: z.string().max(200).optional(),
|
||||
errorCode: z.string().max(100).optional(),
|
||||
details: z.record(z.unknown()).optional(),
|
||||
labels: z.array(z.string().max(50)).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/issues/report
|
||||
*
|
||||
* Optionally report a quota-exceeded or key-issuance failure event to GitHub.
|
||||
*
|
||||
* Requires GITHUB_ISSUES_REPO (format: owner/repo) and GITHUB_ISSUES_TOKEN
|
||||
* environment variables to be set. If not configured, returns 202 (accepted but
|
||||
* logged only).
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = reportSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
}
|
||||
|
||||
const { title, provider, accountId, requestId, errorCode, details, labels = [] } = parsed.data;
|
||||
|
||||
const repo = process.env.GITHUB_ISSUES_REPO;
|
||||
const token = process.env.GITHUB_ISSUES_TOKEN;
|
||||
|
||||
// ── Structured body for the GitHub issue ──
|
||||
const issueBody = [
|
||||
`## ${errorCode ?? "Key Issuance Event"}`,
|
||||
"",
|
||||
"| Field | Value |",
|
||||
"|-------|-------|",
|
||||
provider ? `| Provider | \`${provider}\` |` : null,
|
||||
accountId ? `| Account ID | \`${accountId}\` |` : null,
|
||||
requestId ? `| Request ID | \`${requestId}\` |` : null,
|
||||
errorCode ? `| Error Code | \`${errorCode}\` |` : null,
|
||||
`| Reported At | ${new Date().toISOString()} |`,
|
||||
"",
|
||||
details ? "### Details\n```json\n" + JSON.stringify(details, null, 2) + "\n```" : null,
|
||||
"",
|
||||
"_Auto-reported by OmniRoute Registered Key Issuer_",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
// ── Log locally regardless ──
|
||||
console.log(
|
||||
`[issues/report] title="${title}" errorCode=${errorCode ?? "—"} provider=${provider ?? "—"} accountId=${accountId ?? "—"}`
|
||||
);
|
||||
|
||||
if (!repo || !token) {
|
||||
// No GitHub config — log only
|
||||
return NextResponse.json(
|
||||
{
|
||||
logged: true,
|
||||
githubIssueCreated: false,
|
||||
reason: !repo ? "GITHUB_ISSUES_REPO not configured" : "GITHUB_ISSUES_TOKEN not configured",
|
||||
},
|
||||
{ status: 202 }
|
||||
);
|
||||
}
|
||||
|
||||
// ── Create GitHub issue ──
|
||||
try {
|
||||
const [owner, repoName] = repo.split("/");
|
||||
const ghRes = await fetch(`https://api.github.com/repos/${owner}/${repoName}/issues`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: `[Key Issuer] ${title}`,
|
||||
body: issueBody,
|
||||
labels: ["key-issuer", "automated", ...labels],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!ghRes.ok) {
|
||||
const errText = await ghRes.text();
|
||||
console.error(`[issues/report] GitHub API error ${ghRes.status}: ${errText}`);
|
||||
return NextResponse.json(
|
||||
{ logged: true, githubIssueCreated: false, githubError: ghRes.status },
|
||||
{ status: 207 }
|
||||
);
|
||||
}
|
||||
|
||||
const ghData = await ghRes.json();
|
||||
return NextResponse.json({
|
||||
logged: true,
|
||||
githubIssueCreated: true,
|
||||
githubIssueUrl: ghData.html_url,
|
||||
githubIssueNumber: ghData.number,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[issues/report] GitHub fetch failed:", err);
|
||||
return NextResponse.json(
|
||||
{ logged: true, githubIssueCreated: false, error: "GitHub request failed" },
|
||||
{ status: 207 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { setProviderKeyLimit, getProviderKeyLimit } from "@/lib/db/registeredKeys";
|
||||
|
||||
const limitsSchema = z.object({
|
||||
maxActiveKeys: z.number().int().positive().nullable().optional(),
|
||||
dailyIssueLimit: z.number().int().positive().nullable().optional(),
|
||||
hourlyIssueLimit: z.number().int().positive().nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/providers/[id]/limits
|
||||
* Get the current issuance limits for a provider.
|
||||
*/
|
||||
export async function GET(request: Request, { params }: { params: { id: string } }) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
|
||||
}
|
||||
|
||||
const limits = getProviderKeyLimit(params.id);
|
||||
return NextResponse.json({ provider: params.id, limits: limits ?? null });
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/providers/[id]/limits
|
||||
* Configure issuance limits for a provider.
|
||||
*/
|
||||
export async function PUT(request: Request, { params }: { params: { id: string } }) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = limitsSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
}
|
||||
|
||||
setProviderKeyLimit(params.id, parsed.data);
|
||||
const updated = getProviderKeyLimit(params.id);
|
||||
return NextResponse.json({ provider: params.id, limits: updated });
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { checkQuota } from "@/lib/db/registeredKeys";
|
||||
|
||||
/**
|
||||
* GET /api/v1/quotas/check?provider=&accountId=
|
||||
*
|
||||
* Check if a new registered key can be issued for the given provider/account
|
||||
* without actually issuing one. Use this to pre-validate before POST /registered-keys.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const provider = searchParams.get("provider") ?? "";
|
||||
const accountId = searchParams.get("accountId") ?? "";
|
||||
|
||||
try {
|
||||
const result = checkQuota(provider, accountId);
|
||||
return NextResponse.json({
|
||||
allowed: result.allowed,
|
||||
...(result.errorCode ? { errorCode: result.errorCode, reason: result.errorMessage } : {}),
|
||||
provider: provider || null,
|
||||
accountId: accountId || null,
|
||||
checkedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[quotas/check] error:", err);
|
||||
return NextResponse.json({ error: "Quota check failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { revokeRegisteredKey } from "@/lib/db/registeredKeys";
|
||||
|
||||
/**
|
||||
* POST /api/v1/registered-keys/[id]/revoke
|
||||
*
|
||||
* Explicit revoke endpoint (supports clients that cannot issue DELETE requests).
|
||||
*/
|
||||
export async function POST(request: Request, { params }: { params: { id: string } }) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
|
||||
}
|
||||
|
||||
const revoked = revokeRegisteredKey(params.id);
|
||||
if (!revoked) {
|
||||
return NextResponse.json({ error: "Key not found or already revoked" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, id: params.id, revokedAt: new Date().toISOString() });
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { getRegisteredKey, revokeRegisteredKey } from "@/lib/db/registeredKeys";
|
||||
|
||||
// ─── GET /api/v1/registered-keys/[id] ────────────────────────────────────────
|
||||
|
||||
export async function GET(request: Request, { params }: { params: { id: string } }) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
|
||||
}
|
||||
|
||||
const key = getRegisteredKey(params.id);
|
||||
if (!key) {
|
||||
return NextResponse.json({ error: "Key not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ key });
|
||||
}
|
||||
|
||||
// ─── DELETE /api/v1/registered-keys/[id] ─────────────────────────────────────
|
||||
|
||||
export async function DELETE(request: Request, { params }: { params: { id: string } }) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
|
||||
}
|
||||
|
||||
const revoked = revokeRegisteredKey(params.id);
|
||||
if (!revoked) {
|
||||
return NextResponse.json({ error: "Key not found or already revoked" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, id: params.id, revokedAt: new Date().toISOString() });
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { issueRegisteredKey, checkQuota, listRegisteredKeys } from "@/lib/db/registeredKeys";
|
||||
|
||||
// ─── Validation ───────────────────────────────────────────────────────────────
|
||||
|
||||
const issueKeySchema = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
provider: z.string().max(80).optional().default(""),
|
||||
accountId: z.string().max(120).optional().default(""),
|
||||
idempotencyKey: z.string().max(256).optional(),
|
||||
expiresAt: z.string().datetime({ offset: true }).optional(),
|
||||
dailyBudget: z.number().int().positive().optional(),
|
||||
hourlyBudget: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
// ─── GET /api/v1/registered-keys ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* List registered keys (masked — no raw key material returned after creation).
|
||||
* Optional query params: ?provider=&accountId=
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const provider = searchParams.get("provider") ?? undefined;
|
||||
const accountId = searchParams.get("accountId") ?? undefined;
|
||||
|
||||
try {
|
||||
const keys = listRegisteredKeys({ provider, accountId });
|
||||
return NextResponse.json({ keys, total: keys.length });
|
||||
} catch (err) {
|
||||
console.error("[registered-keys] GET failed:", err);
|
||||
return NextResponse.json({ error: "Failed to list registered keys" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── POST /api/v1/registered-keys ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Issue a new registered key.
|
||||
*
|
||||
* Checks provider + account quotas before issuing.
|
||||
* Returns the raw key ONCE — it is never stored in plain text.
|
||||
* Subsequent fetches will only return the masked prefix.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = issueKeySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
}
|
||||
|
||||
const { provider, accountId } = parsed.data;
|
||||
|
||||
// ── Quota check ──
|
||||
try {
|
||||
const quota = checkQuota(provider, accountId);
|
||||
if (!quota.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: quota.errorMessage, errorCode: quota.errorCode },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[registered-keys] quota check failed:", err);
|
||||
return NextResponse.json({ error: "Quota check failed" }, { status: 500 });
|
||||
}
|
||||
|
||||
// ── Issue ──
|
||||
try {
|
||||
const result = issueRegisteredKey(parsed.data);
|
||||
|
||||
if ("idempotencyConflict" in result) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Idempotency key already used",
|
||||
errorCode: "IDEMPOTENCY_CONFLICT",
|
||||
existing: result.existing,
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const { rawKey, ...keyMeta } = result;
|
||||
return NextResponse.json(
|
||||
{
|
||||
key: rawKey, // ← shown ONCE only
|
||||
keyId: keyMeta.id,
|
||||
keyPrefix: keyMeta.keyPrefix,
|
||||
name: keyMeta.name,
|
||||
provider: keyMeta.provider,
|
||||
accountId: keyMeta.accountId,
|
||||
expiresAt: keyMeta.expiresAt,
|
||||
createdAt: keyMeta.createdAt,
|
||||
warning: "Store this key securely — it will not be shown again.",
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("[registered-keys] issue failed:", err);
|
||||
return NextResponse.json({ error: "Failed to issue key" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,11 @@ export default function LoginPage() {
|
||||
router.refresh();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
// (#521) If no password is set, redirect to onboarding instead of showing an error
|
||||
if (data.needsSetup) {
|
||||
router.push("/dashboard/onboarding");
|
||||
return;
|
||||
}
|
||||
setError(data.error || t("invalidPassword"));
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -281,6 +281,7 @@
|
||||
"failedUpdatePermissionsRetry": "Failed to update permissions. Please try again.",
|
||||
"unknownProvider": "unknown",
|
||||
"copyMaskedKey": "Copy masked key",
|
||||
"keyOnlyAvailableAtCreation": "Full key available only at creation time — copy it when you first create the key",
|
||||
"modelsCount": "{count, plural, one {# model} other {# models}}",
|
||||
"lastUsedOn": "Last: {date}",
|
||||
"editPermissions": "Edit permissions",
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
-- Migration 008: Registered Keys Provisioning API (#464)
|
||||
--
|
||||
-- Adds three tables:
|
||||
-- registered_keys — auto-provisioned API keys with quota metadata
|
||||
-- provider_key_limits — per-provider issuance limits
|
||||
-- account_key_limits — per-account issuance limits
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- Table: registered_keys
|
||||
-- --------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS registered_keys (
|
||||
id TEXT PRIMARY KEY, -- UUID
|
||||
key TEXT NOT NULL UNIQUE, -- hashed key material (sha256)
|
||||
key_prefix TEXT NOT NULL, -- first 8 chars for display (e.g. "ork_abc1")
|
||||
name TEXT NOT NULL,
|
||||
provider TEXT NOT NULL DEFAULT '', -- associated provider (optional)
|
||||
account_id TEXT NOT NULL DEFAULT '', -- account/tenant identifier
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
revoked_at TEXT, -- ISO timestamp, null if active
|
||||
expires_at TEXT, -- ISO timestamp, null = no expiry
|
||||
idempotency_key TEXT UNIQUE, -- prevents duplicate issue requests
|
||||
daily_budget INTEGER, -- max requests per day (null = unlimited)
|
||||
hourly_budget INTEGER, -- max requests per hour (null = unlimited)
|
||||
daily_used INTEGER NOT NULL DEFAULT 0,
|
||||
hourly_used INTEGER NOT NULL DEFAULT 0,
|
||||
last_reset_day TEXT NOT NULL DEFAULT '', -- YYYY-MM-DD for daily reset tracking
|
||||
last_reset_hour TEXT NOT NULL DEFAULT '', -- YYYY-MM-DDTHH for hourly reset tracking
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_registered_keys_provider ON registered_keys(provider);
|
||||
CREATE INDEX IF NOT EXISTS idx_registered_keys_account ON registered_keys(account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_registered_keys_active ON registered_keys(is_active);
|
||||
CREATE INDEX IF NOT EXISTS idx_registered_keys_idempotency ON registered_keys(idempotency_key);
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- Table: provider_key_limits (per-provider issuance limits)
|
||||
-- --------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS provider_key_limits (
|
||||
provider TEXT PRIMARY KEY,
|
||||
max_active_keys INTEGER, -- null = unlimited
|
||||
daily_issue_limit INTEGER, -- max keys per day
|
||||
hourly_issue_limit INTEGER, -- max keys per hour
|
||||
daily_issued INTEGER NOT NULL DEFAULT 0,
|
||||
hourly_issued INTEGER NOT NULL DEFAULT 0,
|
||||
last_reset_day TEXT NOT NULL DEFAULT '',
|
||||
last_reset_hour TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- Table: account_key_limits (per-account issuance limits)
|
||||
-- --------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS account_key_limits (
|
||||
account_id TEXT PRIMARY KEY,
|
||||
max_active_keys INTEGER,
|
||||
daily_issue_limit INTEGER,
|
||||
hourly_issue_limit INTEGER,
|
||||
daily_issued INTEGER NOT NULL DEFAULT 0,
|
||||
hourly_issued INTEGER NOT NULL DEFAULT 0,
|
||||
last_reset_day TEXT NOT NULL DEFAULT '',
|
||||
last_reset_hour TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Migration 009: Add requested_model to call_logs for billing transparency
|
||||
-- Tracks the model the client *asked* for vs the model that was *actually routed*.
|
||||
-- Needed when a combo falls back: requested_model ≠ model in call_logs.
|
||||
-- Ref: sub2api commits 0b845c25 + 4edcfe1f (T01 sub2api gap analysis)
|
||||
ALTER TABLE call_logs ADD COLUMN requested_model TEXT DEFAULT NULL;
|
||||
|
||||
-- Index for filtering/aggregating by requested_model in Analytics
|
||||
CREATE INDEX IF NOT EXISTS idx_call_logs_requested_model
|
||||
ON call_logs(requested_model);
|
||||
@@ -0,0 +1,531 @@
|
||||
/**
|
||||
* db/registeredKeys.ts — Registered Keys Provisioning (#464)
|
||||
*
|
||||
* Handles:
|
||||
* - Issuing registered keys with idempotency
|
||||
* - Per-provider and per-account quota enforcement
|
||||
* - Key revocation
|
||||
* - Quota status queries for rate-limiting decisions
|
||||
*/
|
||||
|
||||
import { createHash, randomBytes } from "crypto";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { getDbInstance, rowToCamel } from "./core";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface RegisteredKey {
|
||||
id: string;
|
||||
keyPrefix: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
accountId: string;
|
||||
isActive: boolean;
|
||||
revokedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
idempotencyKey: string | null;
|
||||
dailyBudget: number | null;
|
||||
hourlyBudget: number | null;
|
||||
dailyUsed: number;
|
||||
hourlyUsed: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface RegisteredKeyWithSecret extends RegisteredKey {
|
||||
/** Raw key material — only returned once on creation */
|
||||
rawKey: string;
|
||||
}
|
||||
|
||||
export interface ProviderKeyLimit {
|
||||
provider: string;
|
||||
maxActiveKeys: number | null;
|
||||
dailyIssueLimit: number | null;
|
||||
hourlyIssueLimit: number | null;
|
||||
dailyIssued: number;
|
||||
hourlyIssued: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AccountKeyLimit {
|
||||
accountId: string;
|
||||
maxActiveKeys: number | null;
|
||||
dailyIssueLimit: number | null;
|
||||
hourlyIssueLimit: number | null;
|
||||
dailyIssued: number;
|
||||
hourlyIssued: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface QuotaCheckResult {
|
||||
allowed: boolean;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
provider?: string;
|
||||
accountId?: string;
|
||||
providerActiveKeys?: number;
|
||||
accountActiveKeys?: number;
|
||||
}
|
||||
|
||||
export interface IssueKeyParams {
|
||||
name: string;
|
||||
provider?: string;
|
||||
accountId?: string;
|
||||
idempotencyKey?: string;
|
||||
expiresAt?: string;
|
||||
dailyBudget?: number;
|
||||
hourlyBudget?: number;
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function nowDay(): string {
|
||||
return new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
}
|
||||
|
||||
function nowHour(): string {
|
||||
return new Date().toISOString().slice(0, 13); // YYYY-MM-DDTHH
|
||||
}
|
||||
|
||||
function hashKey(raw: string): string {
|
||||
return createHash("sha256").update(raw).digest("hex");
|
||||
}
|
||||
|
||||
function generateRawKey(): string {
|
||||
// ork_ prefix so users can easily identify these keys
|
||||
return "ork_" + randomBytes(24).toString("base64url");
|
||||
}
|
||||
|
||||
/** Reset window counters if the tracking period has changed. */
|
||||
function maybeResetWindow(
|
||||
db: ReturnType<typeof getDbInstance>,
|
||||
table: string,
|
||||
idField: string,
|
||||
idValue: string
|
||||
): void {
|
||||
const today = nowDay();
|
||||
const hour = nowHour();
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE ${table}
|
||||
SET daily_issued = CASE WHEN last_reset_day <> ? THEN 0 ELSE daily_issued END,
|
||||
hourly_issued = CASE WHEN last_reset_hour <> ? THEN 0 ELSE hourly_issued END,
|
||||
last_reset_day = ?,
|
||||
last_reset_hour = ?
|
||||
WHERE ${idField} = ?
|
||||
`
|
||||
).run(today, hour, today, hour, idValue);
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if a new registered key can be issued for the given provider/account.
|
||||
* Returns { allowed: true } or { allowed: false, errorCode, errorMessage }.
|
||||
*/
|
||||
export function checkQuota(provider = "", accountId = ""): QuotaCheckResult {
|
||||
const db = getDbInstance();
|
||||
const today = nowDay();
|
||||
const hour = nowHour();
|
||||
|
||||
// ── provider-level check ──
|
||||
if (provider) {
|
||||
maybeResetWindow(db, "provider_key_limits", "provider", provider);
|
||||
|
||||
const limits = db
|
||||
.prepare("SELECT * FROM provider_key_limits WHERE provider = ?")
|
||||
.get(provider) as ProviderKeyLimitRow | undefined;
|
||||
|
||||
if (limits) {
|
||||
if (limits.hourly_issue_limit !== null && limits.hourly_issued >= limits.hourly_issue_limit) {
|
||||
return {
|
||||
allowed: false,
|
||||
errorCode: "PROVIDER_QUOTA_EXCEEDED",
|
||||
errorMessage: `Hourly issue limit (${limits.hourly_issue_limit}) reached for provider '${provider}'`,
|
||||
provider,
|
||||
};
|
||||
}
|
||||
if (limits.daily_issue_limit !== null && limits.daily_issued >= limits.daily_issue_limit) {
|
||||
return {
|
||||
allowed: false,
|
||||
errorCode: "PROVIDER_QUOTA_EXCEEDED",
|
||||
errorMessage: `Daily issue limit (${limits.daily_issue_limit}) reached for provider '${provider}'`,
|
||||
provider,
|
||||
};
|
||||
}
|
||||
if (limits.max_active_keys !== null) {
|
||||
const { activeCount } = db
|
||||
.prepare(
|
||||
"SELECT COUNT(*) as activeCount FROM registered_keys WHERE provider = ? AND is_active = 1"
|
||||
)
|
||||
.get(provider) as { activeCount: number };
|
||||
if (activeCount >= limits.max_active_keys) {
|
||||
return {
|
||||
allowed: false,
|
||||
errorCode: "MAX_ACTIVE_KEYS_EXCEEDED",
|
||||
errorMessage: `Max active keys (${limits.max_active_keys}) reached for provider '${provider}'`,
|
||||
provider,
|
||||
providerActiveKeys: activeCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── account-level check ──
|
||||
if (accountId) {
|
||||
maybeResetWindow(db, "account_key_limits", "account_id", accountId);
|
||||
|
||||
const limits = db
|
||||
.prepare("SELECT * FROM account_key_limits WHERE account_id = ?")
|
||||
.get(accountId) as AccountKeyLimitRow | undefined;
|
||||
|
||||
if (limits) {
|
||||
if (limits.hourly_issue_limit !== null && limits.hourly_issued >= limits.hourly_issue_limit) {
|
||||
return {
|
||||
allowed: false,
|
||||
errorCode: "ACCOUNT_QUOTA_EXCEEDED",
|
||||
errorMessage: `Hourly issue limit (${limits.hourly_issue_limit}) reached for account '${accountId}'`,
|
||||
accountId,
|
||||
};
|
||||
}
|
||||
if (limits.daily_issue_limit !== null && limits.daily_issued >= limits.daily_issue_limit) {
|
||||
return {
|
||||
allowed: false,
|
||||
errorCode: "ACCOUNT_QUOTA_EXCEEDED",
|
||||
errorMessage: `Daily issue limit (${limits.daily_issue_limit}) reached for account '${accountId}'`,
|
||||
accountId,
|
||||
};
|
||||
}
|
||||
if (limits.max_active_keys !== null) {
|
||||
const { activeCount } = db
|
||||
.prepare(
|
||||
"SELECT COUNT(*) as activeCount FROM registered_keys WHERE account_id = ? AND is_active = 1"
|
||||
)
|
||||
.get(accountId) as { activeCount: number };
|
||||
if (activeCount >= limits.max_active_keys) {
|
||||
return {
|
||||
allowed: false,
|
||||
errorCode: "MAX_ACTIVE_KEYS_EXCEEDED",
|
||||
errorMessage: `Max active keys (${limits.max_active_keys}) reached for account '${accountId}'`,
|
||||
accountId,
|
||||
accountActiveKeys: activeCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue a new registered key.
|
||||
* Returns the key with rawKey (only on creation) or null if idempotency_key already exists.
|
||||
*/
|
||||
export function issueRegisteredKey(
|
||||
params: IssueKeyParams
|
||||
): RegisteredKeyWithSecret | { idempotencyConflict: true; existing: RegisteredKey } {
|
||||
const db = getDbInstance();
|
||||
const {
|
||||
name,
|
||||
provider = "",
|
||||
accountId = "",
|
||||
idempotencyKey,
|
||||
expiresAt,
|
||||
dailyBudget,
|
||||
hourlyBudget,
|
||||
} = params;
|
||||
|
||||
// ── idempotency check ──
|
||||
if (idempotencyKey) {
|
||||
const existing = db
|
||||
.prepare("SELECT * FROM registered_keys WHERE idempotency_key = ?")
|
||||
.get(idempotencyKey) as RegisteredKeyRow | undefined;
|
||||
if (existing) {
|
||||
return {
|
||||
idempotencyConflict: true,
|
||||
existing: rowToCamel(existing) as unknown as RegisteredKey,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const rawKey = generateRawKey();
|
||||
const id = uuidv4();
|
||||
const keyHash = hashKey(rawKey);
|
||||
const keyPrefix = rawKey.slice(0, 12); // "ork_" + 8 chars
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO registered_keys
|
||||
(id, key, key_prefix, name, provider, account_id, idempotency_key, expires_at, daily_budget, hourly_budget, last_reset_day, last_reset_hour)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
).run(
|
||||
id,
|
||||
keyHash,
|
||||
keyPrefix,
|
||||
name,
|
||||
provider,
|
||||
accountId,
|
||||
idempotencyKey ?? null,
|
||||
expiresAt ?? null,
|
||||
dailyBudget ?? null,
|
||||
hourlyBudget ?? null,
|
||||
nowDay(),
|
||||
nowHour()
|
||||
);
|
||||
|
||||
// Increment provider/account issuance counters
|
||||
if (provider) {
|
||||
maybeResetWindow(db, "provider_key_limits", "provider", provider);
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO provider_key_limits (provider, daily_issued, hourly_issued, last_reset_day, last_reset_hour)
|
||||
VALUES (?, 1, 1, ?, ?)
|
||||
ON CONFLICT(provider) DO UPDATE SET
|
||||
daily_issued = daily_issued + 1,
|
||||
hourly_issued = hourly_issued + 1,
|
||||
updated_at = datetime('now')
|
||||
`
|
||||
).run(provider, nowDay(), nowHour());
|
||||
}
|
||||
if (accountId) {
|
||||
maybeResetWindow(db, "account_key_limits", "account_id", accountId);
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO account_key_limits (account_id, daily_issued, hourly_issued, last_reset_day, last_reset_hour)
|
||||
VALUES (?, 1, 1, ?, ?)
|
||||
ON CONFLICT(account_id) DO UPDATE SET
|
||||
daily_issued = daily_issued + 1,
|
||||
hourly_issued = hourly_issued + 1,
|
||||
updated_at = datetime('now')
|
||||
`
|
||||
).run(accountId, nowDay(), nowHour());
|
||||
}
|
||||
|
||||
const created = db
|
||||
.prepare("SELECT * FROM registered_keys WHERE id = ?")
|
||||
.get(id) as RegisteredKeyRow;
|
||||
return { ...(rowToCamel(created) as unknown as RegisteredKey), rawKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a registered key by ID (without the raw key — only prefix is returned).
|
||||
*/
|
||||
export function getRegisteredKey(id: string): RegisteredKey | null {
|
||||
const db = getDbInstance();
|
||||
const row = db.prepare("SELECT * FROM registered_keys WHERE id = ?").get(id) as
|
||||
| RegisteredKeyRow
|
||||
| undefined;
|
||||
return row ? (rowToCamel(row) as unknown as RegisteredKey) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all registered keys (optionally filtered by provider/accountId).
|
||||
*/
|
||||
export function listRegisteredKeys(
|
||||
opts: { provider?: string; accountId?: string } = {}
|
||||
): RegisteredKey[] {
|
||||
const db = getDbInstance();
|
||||
let sql = "SELECT * FROM registered_keys WHERE 1=1";
|
||||
const args: string[] = [];
|
||||
if (opts.provider) {
|
||||
sql += " AND provider = ?";
|
||||
args.push(opts.provider);
|
||||
}
|
||||
if (opts.accountId) {
|
||||
sql += " AND account_id = ?";
|
||||
args.push(opts.accountId);
|
||||
}
|
||||
sql += " ORDER BY created_at DESC LIMIT 500";
|
||||
const rows = db.prepare(sql).all(...args) as RegisteredKeyRow[];
|
||||
return rows.map((r) => rowToCamel(r) as unknown as RegisteredKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a registered key by ID.
|
||||
*/
|
||||
export function revokeRegisteredKey(id: string): boolean {
|
||||
const db = getDbInstance();
|
||||
const result = db
|
||||
.prepare(
|
||||
`
|
||||
UPDATE registered_keys
|
||||
SET is_active = 0, revoked_at = datetime('now'), updated_at = datetime('now')
|
||||
WHERE id = ? AND is_active = 1
|
||||
`
|
||||
)
|
||||
.run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a raw registered key against stored hashes.
|
||||
* Returns the key metadata if valid, null otherwise.
|
||||
*/
|
||||
export function validateRegisteredKey(rawKey: string): RegisteredKey | null {
|
||||
const db = getDbInstance();
|
||||
const hash = hashKey(rawKey);
|
||||
const row = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT * FROM registered_keys
|
||||
WHERE key = ? AND is_active = 1
|
||||
AND (expires_at IS NULL OR expires_at > datetime('now'))
|
||||
`
|
||||
)
|
||||
.get(hash) as RegisteredKeyRow | undefined;
|
||||
if (!row) return null;
|
||||
|
||||
// Auto-reset budget windows if needed
|
||||
const today = nowDay();
|
||||
const hour = nowHour();
|
||||
if (row.last_reset_day !== today || row.last_reset_hour !== hour) {
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE registered_keys
|
||||
SET daily_used = CASE WHEN last_reset_day <> ? THEN 0 ELSE daily_used END,
|
||||
hourly_used = CASE WHEN last_reset_hour <> ? THEN 0 ELSE hourly_used END,
|
||||
last_reset_day = ?, last_reset_hour = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
).run(today, hour, today, hour, row.id);
|
||||
}
|
||||
|
||||
// Budget check
|
||||
if (row.daily_budget !== null && row.daily_used >= row.daily_budget) return null;
|
||||
if (row.hourly_budget !== null && row.hourly_used >= row.hourly_budget) return null;
|
||||
|
||||
return rowToCamel(row) as unknown as RegisteredKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment usage counters for a registered key (called by request pipeline).
|
||||
*/
|
||||
export function incrementRegisteredKeyUsage(id: string): void {
|
||||
const db = getDbInstance();
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE registered_keys
|
||||
SET daily_used = daily_used + 1, hourly_used = hourly_used + 1, updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`
|
||||
).run(id);
|
||||
}
|
||||
|
||||
// ─── Provider / Account Limit Management ──────────────────────────────────────
|
||||
|
||||
export function setProviderKeyLimit(
|
||||
provider: string,
|
||||
limits: Partial<Omit<ProviderKeyLimit, "provider" | "dailyIssued" | "hourlyIssued" | "updatedAt">>
|
||||
): void {
|
||||
const db = getDbInstance();
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO provider_key_limits (provider, max_active_keys, daily_issue_limit, hourly_issue_limit, last_reset_day, last_reset_hour)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(provider) DO UPDATE SET
|
||||
max_active_keys = excluded.max_active_keys,
|
||||
daily_issue_limit = excluded.daily_issue_limit,
|
||||
hourly_issue_limit = excluded.hourly_issue_limit,
|
||||
updated_at = datetime('now')
|
||||
`
|
||||
).run(
|
||||
provider,
|
||||
limits.maxActiveKeys ?? null,
|
||||
limits.dailyIssueLimit ?? null,
|
||||
limits.hourlyIssueLimit ?? null,
|
||||
nowDay(),
|
||||
nowHour()
|
||||
);
|
||||
}
|
||||
|
||||
export function setAccountKeyLimit(
|
||||
accountId: string,
|
||||
limits: Partial<Omit<AccountKeyLimit, "accountId" | "dailyIssued" | "hourlyIssued" | "updatedAt">>
|
||||
): void {
|
||||
const db = getDbInstance();
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO account_key_limits (account_id, max_active_keys, daily_issue_limit, hourly_issue_limit, last_reset_day, last_reset_hour)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(account_id) DO UPDATE SET
|
||||
max_active_keys = excluded.max_active_keys,
|
||||
daily_issue_limit = excluded.daily_issue_limit,
|
||||
hourly_issue_limit = excluded.hourly_issue_limit,
|
||||
updated_at = datetime('now')
|
||||
`
|
||||
).run(
|
||||
accountId,
|
||||
limits.maxActiveKeys ?? null,
|
||||
limits.dailyIssueLimit ?? null,
|
||||
limits.hourlyIssueLimit ?? null,
|
||||
nowDay(),
|
||||
nowHour()
|
||||
);
|
||||
}
|
||||
|
||||
export function getProviderKeyLimit(provider: string): ProviderKeyLimit | null {
|
||||
const db = getDbInstance();
|
||||
const row = db.prepare("SELECT * FROM provider_key_limits WHERE provider = ?").get(provider) as
|
||||
| ProviderKeyLimitRow
|
||||
| undefined;
|
||||
return row ? (rowToCamel(row) as unknown as ProviderKeyLimit) : null;
|
||||
}
|
||||
|
||||
export function getAccountKeyLimit(accountId: string): AccountKeyLimit | null {
|
||||
const db = getDbInstance();
|
||||
const row = db.prepare("SELECT * FROM account_key_limits WHERE account_id = ?").get(accountId) as
|
||||
| AccountKeyLimitRow
|
||||
| undefined;
|
||||
return row ? (rowToCamel(row) as unknown as AccountKeyLimit) : null;
|
||||
}
|
||||
|
||||
// ─── Internal types (raw DB rows) ─────────────────────────────────────────────
|
||||
|
||||
interface RegisteredKeyRow {
|
||||
id: string;
|
||||
key: string;
|
||||
key_prefix: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
account_id: string;
|
||||
is_active: number;
|
||||
revoked_at: string | null;
|
||||
expires_at: string | null;
|
||||
idempotency_key: string | null;
|
||||
daily_budget: number | null;
|
||||
hourly_budget: number | null;
|
||||
daily_used: number;
|
||||
hourly_used: number;
|
||||
last_reset_day: string;
|
||||
last_reset_hour: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ProviderKeyLimitRow {
|
||||
provider: string;
|
||||
max_active_keys: number | null;
|
||||
daily_issue_limit: number | null;
|
||||
hourly_issue_limit: number | null;
|
||||
daily_issued: number;
|
||||
hourly_issued: number;
|
||||
last_reset_day: string;
|
||||
last_reset_hour: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface AccountKeyLimitRow {
|
||||
account_id: string;
|
||||
max_active_keys: number | null;
|
||||
daily_issue_limit: number | null;
|
||||
hourly_issue_limit: number | null;
|
||||
daily_issued: number;
|
||||
hourly_issued: number;
|
||||
last_reset_day: string;
|
||||
last_reset_hour: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { isIP } from "node:net";
|
||||
|
||||
/**
|
||||
* T07: Extract the real client IP from X-Forwarded-For header.
|
||||
* Skips invalid entries like "unknown" or empty strings.
|
||||
* Falls back to remoteAddress if no valid IP found.
|
||||
* Ref: sub2api PR #1135
|
||||
*
|
||||
* @param xForwardedFor - Value of the X-Forwarded-For header (may be CSV)
|
||||
* @param remoteAddress - Fallback from the raw socket (req.socket.remoteAddress)
|
||||
* @returns The first valid IP address found, or "unknown"
|
||||
*/
|
||||
export function extractClientIp(
|
||||
xForwardedFor: string | null | undefined,
|
||||
remoteAddress: string | undefined
|
||||
): string {
|
||||
if (xForwardedFor) {
|
||||
const entries = xForwardedFor.split(",");
|
||||
for (const entry of entries) {
|
||||
const trimmed = entry.trim();
|
||||
if (trimmed && isIP(trimmed) !== 0) {
|
||||
return trimmed; // First valid IP wins
|
||||
}
|
||||
}
|
||||
}
|
||||
return remoteAddress?.trim() ?? "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract client IP from a Request or NextRequest object.
|
||||
* Checks X-Forwarded-For, X-Real-IP, CF-Connecting-IP, then socket.
|
||||
*/
|
||||
export function getClientIpFromRequest(req: {
|
||||
headers?: Headers | { get?: (n: string) => string | null };
|
||||
socket?: { remoteAddress?: string };
|
||||
ip?: string;
|
||||
}): string {
|
||||
// Helper to get header value from either Headers object or plain object
|
||||
const getHeader = (name: string): string | null => {
|
||||
if (!req.headers) return null;
|
||||
if (typeof (req.headers as Headers).get === "function") {
|
||||
return (req.headers as Headers).get(name);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Priority: CF-Connecting-IP (Cloudflare) > X-Forwarded-For > X-Real-IP > socket
|
||||
const cfIp = getHeader("cf-connecting-ip");
|
||||
if (cfIp && isIP(cfIp.trim()) !== 0) return cfIp.trim();
|
||||
|
||||
const xff = getHeader("x-forwarded-for");
|
||||
const realIp = getHeader("x-real-ip");
|
||||
const remoteAddress = req.ip ?? req.socket?.remoteAddress;
|
||||
|
||||
return extractClientIp(xff ?? realIp, remoteAddress);
|
||||
}
|
||||
@@ -138,3 +138,27 @@ export {
|
||||
getCachedProviderConnections,
|
||||
invalidateDbCache,
|
||||
} from "./db/readCache";
|
||||
|
||||
export {
|
||||
// Registered Keys Provisioning (#464)
|
||||
issueRegisteredKey,
|
||||
getRegisteredKey,
|
||||
listRegisteredKeys,
|
||||
revokeRegisteredKey,
|
||||
validateRegisteredKey,
|
||||
incrementRegisteredKeyUsage,
|
||||
checkQuota,
|
||||
setProviderKeyLimit,
|
||||
setAccountKeyLimit,
|
||||
getProviderKeyLimit,
|
||||
getAccountKeyLimit,
|
||||
} from "./db/registeredKeys";
|
||||
|
||||
export type {
|
||||
RegisteredKey,
|
||||
RegisteredKeyWithSecret,
|
||||
ProviderKeyLimit,
|
||||
AccountKeyLimit,
|
||||
QuotaCheckResult,
|
||||
IssueKeyParams,
|
||||
} from "./db/registeredKeys";
|
||||
|
||||
@@ -25,6 +25,18 @@ export const gemini = {
|
||||
|
||||
if (config.clientSecret) {
|
||||
bodyParams.client_secret = config.clientSecret;
|
||||
} else {
|
||||
// (#537) Google's OAuth2 token endpoint always requires client_secret for
|
||||
// non-PKCE flows. Without it we get a cryptic "client_secret is missing" error.
|
||||
// This typically happens in self-hosted / Docker deployments where
|
||||
// GEMINI_OAUTH_CLIENT_SECRET is not set in the container environment.
|
||||
throw new Error(
|
||||
"Gemini CLI OAuth requires GEMINI_OAUTH_CLIENT_SECRET to be set.\n" +
|
||||
"In Docker: add 'GEMINI_OAUTH_CLIENT_SECRET=<your-secret>' to your docker-compose.yml env.\n" +
|
||||
"In npm: add it to ~/.omniroute/.env\n" +
|
||||
"Obtain the client secret from https://console.cloud.google.com/apis/credentials\n" +
|
||||
"for the same OAuth 2.0 Client ID configured as GEMINI_OAUTH_CLIENT_ID."
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
|
||||
@@ -610,7 +610,12 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
|
||||
}
|
||||
|
||||
const modelId = entry.models?.[0]?.id || null;
|
||||
const baseUrl = resolveBaseUrl(entry, providerSpecificData);
|
||||
// (#532) Use testKeyBaseUrl if defined — some providers validate keys on a different endpoint
|
||||
// than where requests are sent (e.g. opencode-go validates on zen/v1, not zen/go/v1)
|
||||
const validationEntry = entry.testKeyBaseUrl
|
||||
? { ...entry, baseUrl: entry.testKeyBaseUrl }
|
||||
: entry;
|
||||
const baseUrl = resolveBaseUrl(validationEntry, providerSpecificData);
|
||||
|
||||
try {
|
||||
if (OPENAI_LIKE_FORMATS.has(entry.format)) {
|
||||
|
||||
@@ -180,6 +180,7 @@ export async function saveCallLog(entry: any) {
|
||||
path: entry.path || "/v1/chat/completions",
|
||||
status: entry.status || 0,
|
||||
model: entry.model || "-",
|
||||
requestedModel: entry.requestedModel || null, // T01: model the client asked for
|
||||
provider: entry.provider || "-",
|
||||
account,
|
||||
connectionId: entry.connectionId || null,
|
||||
@@ -205,10 +206,10 @@ export async function saveCallLog(entry: any) {
|
||||
const db = getDbInstance();
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO call_logs (id, timestamp, method, path, status, model, provider,
|
||||
INSERT INTO call_logs (id, timestamp, method, path, status, model, requested_model, provider,
|
||||
account, connection_id, duration, tokens_in, tokens_out, request_type, source_format, target_format,
|
||||
api_key_id, api_key_name, combo_name, request_body, response_body, error)
|
||||
VALUES (@id, @timestamp, @method, @path, @status, @model, @provider,
|
||||
VALUES (@id, @timestamp, @method, @path, @status, @model, @requestedModel, @provider,
|
||||
@account, @connectionId, @duration, @tokensIn, @tokensOut, @requestType, @sourceFormat, @targetFormat,
|
||||
@apiKeyId, @apiKeyName, @comboName, @requestBody, @responseBody, @error)
|
||||
`
|
||||
@@ -374,6 +375,7 @@ export async function getCallLogs(filter: any = {}) {
|
||||
path: toStringOrNull(l.path),
|
||||
status: toNumber(l.status),
|
||||
model: toStringOrNull(l.model),
|
||||
requestedModel: toStringOrNull(l.requested_model), // T01: original model from client
|
||||
provider: toStringOrNull(l.provider),
|
||||
account: toStringOrNull(l.account),
|
||||
duration: toNumber(l.duration),
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ProviderIcon — Renders a provider logo using @lobehub/icons with PNG fallback.
|
||||
*
|
||||
* Strategy (#529):
|
||||
* 1. Try @lobehub/icons ProviderIcon (130+ providers, React components)
|
||||
* 2. Fall back to /providers/{id}.png (existing static assets)
|
||||
* 3. Fall back to a generic AI icon
|
||||
*
|
||||
* Usage:
|
||||
* <ProviderIcon providerId="openai" size={24} />
|
||||
* <ProviderIcon providerId="anthropic" size={28} type="color" />
|
||||
*/
|
||||
|
||||
import { memo, useState, Component, type ReactNode } from "react";
|
||||
import Image from "next/image";
|
||||
import { ProviderIcon as LobehubProviderIcon } from "@lobehub/icons";
|
||||
|
||||
// Mapping from OmniRoute provider IDs → Lobehub icon IDs
|
||||
// Lobehub uses lowercase IDs matching ModelProvider enum values
|
||||
const LOBEHUB_PROVIDER_MAP: Record<string, string> = {
|
||||
openai: "openai",
|
||||
anthropic: "anthropic",
|
||||
claude: "anthropic",
|
||||
gemini: "google",
|
||||
google: "google",
|
||||
deepseek: "deepseek",
|
||||
groq: "groq",
|
||||
mistral: "mistral",
|
||||
cohere: "cohere",
|
||||
perplexity: "perplexity",
|
||||
xai: "xai",
|
||||
grok: "xai",
|
||||
together: "togetherai",
|
||||
fireworks: "fireworks",
|
||||
"fireworks-ai": "fireworks",
|
||||
cerebras: "cerebras",
|
||||
huggingface: "huggingface",
|
||||
"hugging-face": "huggingface",
|
||||
openrouter: "openrouter",
|
||||
"open-router": "openrouter",
|
||||
ollama: "ollama",
|
||||
minimax: "minimax",
|
||||
qwen: "qwen",
|
||||
alibaba: "qwen",
|
||||
moonshot: "moonshot",
|
||||
kimi: "moonshot",
|
||||
baidu: "baidu",
|
||||
ernie: "baidu",
|
||||
spark: "iflytek",
|
||||
"zhipu-ai": "zhipu",
|
||||
zhipu: "zhipu",
|
||||
lmsys: "lmsys",
|
||||
"stability-ai": "stability",
|
||||
stability: "stability",
|
||||
replicate: "replicate",
|
||||
ai21: "ai21",
|
||||
nvidia: "nvidia",
|
||||
cloudflare: "cloudflare",
|
||||
"cloudflare-ai": "cloudflare",
|
||||
"aws-bedrock": "bedrock",
|
||||
bedrock: "bedrock",
|
||||
azure: "azure",
|
||||
"azure-openai": "azure",
|
||||
copilot: "githubcopilot",
|
||||
"github-copilot": "githubcopilot",
|
||||
mistralai: "mistral",
|
||||
codex: "openai",
|
||||
blackbox: "blackboxai",
|
||||
blackboxai: "blackboxai",
|
||||
pollinations: "pollinations",
|
||||
};
|
||||
|
||||
interface ProviderIconProps {
|
||||
providerId: string;
|
||||
size?: number;
|
||||
type?: "mono" | "color";
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/** Error boundary to catch Lobehub component render errors gracefully. */
|
||||
class LobehubErrorBoundary extends Component<
|
||||
{ children: ReactNode; onError: () => void },
|
||||
{ hasError: boolean }
|
||||
> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch() {
|
||||
this.props.onError();
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) return null;
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function GenericProviderIcon({ size }: { size: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" style={{ flex: "none" }}>
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="1.5" opacity="0.4" />
|
||||
<path d="M8 12h8M12 8v8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const ProviderIcon = memo(function ProviderIcon({
|
||||
providerId,
|
||||
size = 24,
|
||||
type = "color",
|
||||
className,
|
||||
style,
|
||||
}: ProviderIconProps) {
|
||||
const lobehubId = LOBEHUB_PROVIDER_MAP[providerId.toLowerCase()] ?? null;
|
||||
const [useLobehub, setUseLobehub] = useState(lobehubId !== null);
|
||||
const [usePng, setUsePng] = useState(true);
|
||||
|
||||
if (useLobehub && lobehubId) {
|
||||
return (
|
||||
<span
|
||||
className={className}
|
||||
style={{ display: "inline-flex", alignItems: "center", ...style }}
|
||||
>
|
||||
<LobehubErrorBoundary onError={() => setUseLobehub(false)}>
|
||||
<LobehubProviderIcon provider={lobehubId} size={size} type={type} />
|
||||
</LobehubErrorBoundary>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (usePng) {
|
||||
return (
|
||||
<span
|
||||
className={className}
|
||||
style={{ display: "inline-flex", alignItems: "center", ...style }}
|
||||
>
|
||||
<Image
|
||||
src={`/providers/${providerId}.png`}
|
||||
alt={providerId}
|
||||
width={size}
|
||||
height={size}
|
||||
style={{ objectFit: "contain" }}
|
||||
onError={() => setUsePng(false)}
|
||||
unoptimized
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={className} style={{ display: "inline-flex", alignItems: "center", ...style }}>
|
||||
<GenericProviderIcon size={size} />
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProviderIcon;
|
||||
export type { ProviderIconProps };
|
||||
@@ -802,6 +802,30 @@ export const DEFAULT_PRICING = {
|
||||
reasoning: 1.8,
|
||||
cache_creation: 0.3,
|
||||
},
|
||||
// T12: MiniMax M2.7 — new default model (sub2api PR #1120)
|
||||
// Upgraded from M2.5, same API endpoint api.minimax.io
|
||||
// Pricing estimated, check https://platform.minimaxi.com/document/Price
|
||||
"minimax-m2.7": {
|
||||
input: 0.4,
|
||||
output: 1.6,
|
||||
cached: 0.2,
|
||||
reasoning: 2.4,
|
||||
cache_creation: 0.4,
|
||||
},
|
||||
"MiniMax-M2.7": {
|
||||
input: 0.4,
|
||||
output: 1.6,
|
||||
cached: 0.2,
|
||||
reasoning: 2.4,
|
||||
cache_creation: 0.4,
|
||||
},
|
||||
"minimax-m2.7-highspeed": {
|
||||
input: 0.4,
|
||||
output: 1.6,
|
||||
cached: 0.2,
|
||||
reasoning: 2.4,
|
||||
cache_creation: 0.4,
|
||||
},
|
||||
},
|
||||
|
||||
// ─── Free-tier API Key Providers (nominal $0 pricing) ───
|
||||
|
||||
@@ -496,6 +496,22 @@ export const APIKEY_PROVIDERS = {
|
||||
website: "https://tavily.com",
|
||||
authHint: "API key from app.tavily.com (format: tvly-...)",
|
||||
},
|
||||
"opencode-zen": {
|
||||
id: "opencode-zen",
|
||||
alias: "opencode-zen",
|
||||
name: "OpenCode Zen",
|
||||
icon: "opencode",
|
||||
color: "#6366f1",
|
||||
website: "https://opencode.ai/zen",
|
||||
},
|
||||
"opencode-go": {
|
||||
id: "opencode-go",
|
||||
alias: "opencode-go",
|
||||
name: "OpenCode Go",
|
||||
icon: "opencode",
|
||||
color: "#6366f1",
|
||||
website: "https://opencode.ai/zen/go",
|
||||
},
|
||||
alibaba: {
|
||||
id: "alibaba",
|
||||
alias: "ali",
|
||||
@@ -530,6 +546,20 @@ export const APIKEY_PROVIDERS = {
|
||||
freeNote:
|
||||
"No API key needed — access GPT-5, Claude, Gemini, DeepSeek V3, Llama 4 free (1 req/15s)",
|
||||
},
|
||||
puter: {
|
||||
id: "puter",
|
||||
alias: "pu",
|
||||
name: "Puter AI",
|
||||
icon: "cloud_circle",
|
||||
color: "#6366F1",
|
||||
textIcon: "PU",
|
||||
website: "https://puter.com",
|
||||
hasFree: true,
|
||||
freeNote:
|
||||
"500+ models (GPT-5, Claude Opus 4, Gemini 3 Pro, Grok 4, DeepSeek V3...) — Users pay via free Puter account",
|
||||
passthroughModels: true,
|
||||
authHint: "Get token at puter.com/dashboard → Copy Auth Token",
|
||||
},
|
||||
"cloudflare-ai": {
|
||||
id: "cloudflare-ai",
|
||||
alias: "cf",
|
||||
|
||||
@@ -105,6 +105,24 @@ const CLI_TOOLS: Record<string, any> = {
|
||||
|
||||
const isWindows = () => process.platform === "win32";
|
||||
|
||||
/**
|
||||
* (#510) Normalize MSYS2/Git-Bash style paths to Windows-native paths.
|
||||
* On Windows with Git Bash, 'where claude' may return '/c/Program Files/...'
|
||||
* instead of 'C:\\Program Files\\...'. Convert these so the path is usable
|
||||
* by Node's fs and child_process modules.
|
||||
*/
|
||||
const normalizeMsys2Path = (p: string): string => {
|
||||
if (!p || !isWindows()) return p;
|
||||
// Match /letter/rest-of-path — MSYS2 POSIX-style drive mount
|
||||
const msys2Match = p.match(/^\/([a-zA-Z])\/(.+)$/);
|
||||
if (msys2Match) {
|
||||
const drive = msys2Match[1].toUpperCase();
|
||||
const rest = msys2Match[2].replace(/\//g, "\\");
|
||||
return `${drive}:\\${rest}`;
|
||||
}
|
||||
return p;
|
||||
};
|
||||
|
||||
const parseBoolean = (value: unknown, defaultValue = true) => {
|
||||
if (value == null || value === "") return defaultValue;
|
||||
return !FALSE_VALUES.has(String(value).trim().toLowerCase());
|
||||
@@ -256,7 +274,7 @@ const locateCommand = async (command: string, env: Record<string, string | undef
|
||||
const first =
|
||||
located.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.map((line) => normalizeMsys2Path(line.trim()))
|
||||
.find(Boolean) || null;
|
||||
return { installed: !!first, commandPath: first, reason: first ? null : "not_found" };
|
||||
}
|
||||
@@ -271,7 +289,7 @@ const locateCommand = async (command: string, env: Record<string, string | undef
|
||||
const first =
|
||||
located.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.map((line) => normalizeMsys2Path(line.trim()))
|
||||
.find(Boolean) || null;
|
||||
return { installed: !!first, commandPath: first, reason: first ? null : "not_found" };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Model Auto-Sync Scheduler (#488)
|
||||
*
|
||||
* Automatically refreshes model lists for all providers with autoSync enabled
|
||||
* at a configurable interval (default: 24h).
|
||||
*
|
||||
* Pattern mirrors cloudSyncScheduler.ts for consistency.
|
||||
*/
|
||||
|
||||
import { getSettings, updateSettings } from "@/lib/localDb";
|
||||
|
||||
const DEFAULT_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
const MODEL_SYNC_SETTING_KEY = "model_sync_last_run";
|
||||
|
||||
/** Providers that support live model list fetching via /v1/models */
|
||||
const AUTO_SYNC_PROVIDERS = [
|
||||
"openai",
|
||||
"anthropic",
|
||||
"google",
|
||||
"gemini",
|
||||
"deepseek",
|
||||
"groq",
|
||||
"mistral",
|
||||
"cohere",
|
||||
"openrouter",
|
||||
"together",
|
||||
"fireworks",
|
||||
"perplexity",
|
||||
"xai",
|
||||
"cerebras",
|
||||
"ollama",
|
||||
"nvidia",
|
||||
];
|
||||
|
||||
let schedulerTimer: NodeJS.Timeout | null = null;
|
||||
let isRunning = false;
|
||||
|
||||
/**
|
||||
* Fetch and cache models for a single provider.
|
||||
* Calls the internal /api/providers/{id}/sync-models endpoint (if it exists)
|
||||
* or falls back to /v1/models from the provider registry.
|
||||
*/
|
||||
async function syncProviderModels(providerId: string, baseUrl: string): Promise<void> {
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/provider-nodes/sync-models`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "x-internal": "model-sync-scheduler" },
|
||||
body: JSON.stringify({ provider: providerId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.warn(`[ModelSync] Provider ${providerId}: sync returned ${res.status}`);
|
||||
} else {
|
||||
console.log(`[ModelSync] Provider ${providerId}: ✓ updated`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[ModelSync] Provider ${providerId}: fetch failed —`, (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one full model-sync cycle across all auto-sync providers.
|
||||
*/
|
||||
async function runSyncCycle(apiBaseUrl: string): Promise<void> {
|
||||
if (isRunning) {
|
||||
console.log("[ModelSync] Skipping cycle — previous run still in progress");
|
||||
return;
|
||||
}
|
||||
isRunning = true;
|
||||
const start = Date.now();
|
||||
console.log(
|
||||
`[ModelSync] Starting 24h model sync cycle — ${AUTO_SYNC_PROVIDERS.length} providers`
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
AUTO_SYNC_PROVIDERS.map((id) => syncProviderModels(id, apiBaseUrl))
|
||||
);
|
||||
|
||||
const succeeded = results.filter((r) => r.status === "fulfilled").length;
|
||||
console.log(
|
||||
`[ModelSync] Cycle complete: ${succeeded}/${AUTO_SYNC_PROVIDERS.length} providers synced in ${Date.now() - start}ms`
|
||||
);
|
||||
|
||||
// Record last sync time
|
||||
try {
|
||||
await updateSettings({ [MODEL_SYNC_SETTING_KEY]: new Date().toISOString() });
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
isRunning = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the model sync scheduler.
|
||||
* @param apiBaseUrl — internal base URL to call OmniRoute's own API
|
||||
* @param intervalMs — sync interval in milliseconds (default: 24h)
|
||||
*/
|
||||
export function startModelSyncScheduler(
|
||||
apiBaseUrl = "http://localhost:20128",
|
||||
intervalMs = DEFAULT_INTERVAL_MS
|
||||
): void {
|
||||
if (schedulerTimer) {
|
||||
console.log("[ModelSync] Scheduler already running — skipping start");
|
||||
return;
|
||||
}
|
||||
|
||||
// Read MODEL_SYNC_INTERVAL_HOURS env override
|
||||
const envHours = parseInt(process.env.MODEL_SYNC_INTERVAL_HOURS ?? "", 10);
|
||||
const effectiveIntervalMs =
|
||||
!isNaN(envHours) && envHours > 0 ? envHours * 60 * 60 * 1000 : intervalMs;
|
||||
|
||||
console.log(
|
||||
`[ModelSync] Scheduler started — interval: ${effectiveIntervalMs / 3_600_000}h, providers: ${AUTO_SYNC_PROVIDERS.length}`
|
||||
);
|
||||
|
||||
// Run immediately on startup (staggered by 5s to avoid startup congestion)
|
||||
const startupDelay = setTimeout(() => runSyncCycle(apiBaseUrl), 5_000);
|
||||
startupDelay.unref?.();
|
||||
|
||||
// Then run on the regular interval
|
||||
schedulerTimer = setInterval(() => runSyncCycle(apiBaseUrl), effectiveIntervalMs);
|
||||
schedulerTimer.unref?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the model sync scheduler.
|
||||
*/
|
||||
export function stopModelSyncScheduler(): void {
|
||||
if (schedulerTimer) {
|
||||
clearInterval(schedulerTimer);
|
||||
schedulerTimer = null;
|
||||
console.log("[ModelSync] Scheduler stopped");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last sync timestamp from settings DB.
|
||||
*/
|
||||
export async function getLastModelSyncTime(): Promise<string | null> {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
return (settings as Record<string, string>)[MODEL_SYNC_SETTING_KEY] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -106,3 +106,60 @@ test("embeddings route clears stale provider error metadata on success", async (
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("embeddings route uses provider node id for compatible provider credentials", async () => {
|
||||
await resetStorage();
|
||||
|
||||
const providerNode = await providersDb.createProviderNode({
|
||||
id: "openai-compatible-responses-google-embeddings",
|
||||
type: "openai-compatible",
|
||||
name: "Gemini Embeddings",
|
||||
prefix: "google",
|
||||
apiType: "responses",
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
});
|
||||
|
||||
const created = await providersDb.createProviderConnection({
|
||||
provider: providerNode.id,
|
||||
authType: "apikey",
|
||||
email: null,
|
||||
name: "google-compatible-key",
|
||||
apiKey: "google-compatible-test-key",
|
||||
testStatus: "active",
|
||||
lastError: null,
|
||||
lastErrorType: "token_refresh_failed",
|
||||
lastErrorSource: "oauth",
|
||||
errorCode: "refresh_failed",
|
||||
rateLimitedUntil: null,
|
||||
backoffLevel: 2,
|
||||
});
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url, init) => {
|
||||
assert.equal(url, "https://generativelanguage.googleapis.com/v1beta/openai/embeddings");
|
||||
assert.equal(init?.headers?.Authorization, "Bearer google-compatible-test-key");
|
||||
return Response.json({
|
||||
data: [{ object: "embedding", index: 0, embedding: [0.1, 0.2] }],
|
||||
usage: { prompt_tokens: 3, total_tokens: 3 },
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const request = new Request("http://localhost/v1/embeddings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model: "google/gemini-embedding-001", input: "hello" }),
|
||||
});
|
||||
|
||||
const response = await embeddingsRoute.POST(request);
|
||||
assert.equal(response.status, 200);
|
||||
|
||||
const updated = await readConnection(created.id);
|
||||
assert.equal(updated.testStatus, "active");
|
||||
assert.equal(updated.errorCode, undefined);
|
||||
assert.equal(updated.lastErrorType, undefined);
|
||||
assert.equal(updated.lastErrorSource, undefined);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { afterEach, beforeEach, describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { OpencodeExecutor } = await import("../../open-sse/executors/opencode.ts");
|
||||
const { PROVIDER_MODELS } = await import("../../open-sse/config/providerModels.ts");
|
||||
|
||||
function createMockResponse() {
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function createInput(model, stream = true, credentials = { apiKey: "test-key" }) {
|
||||
return {
|
||||
model,
|
||||
stream,
|
||||
credentials,
|
||||
body: {
|
||||
model,
|
||||
stream,
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function registerModel(provider, model) {
|
||||
PROVIDER_MODELS[provider] = [...(PROVIDER_MODELS[provider] || []), model];
|
||||
}
|
||||
|
||||
describe("OpencodeExecutor", () => {
|
||||
let zenExecutor;
|
||||
let goExecutor;
|
||||
let fetchCalls;
|
||||
let originalFetch;
|
||||
let originalZenModels;
|
||||
let originalGoModels;
|
||||
|
||||
beforeEach(() => {
|
||||
zenExecutor = new OpencodeExecutor("opencode-zen");
|
||||
goExecutor = new OpencodeExecutor("opencode-go");
|
||||
fetchCalls = [];
|
||||
originalFetch = globalThis.fetch;
|
||||
originalZenModels = [...(PROVIDER_MODELS["opencode-zen"] || [])];
|
||||
originalGoModels = [...(PROVIDER_MODELS["opencode-go"] || [])];
|
||||
globalThis.fetch = async (url, options) => {
|
||||
fetchCalls.push({ url, options });
|
||||
return createMockResponse();
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
PROVIDER_MODELS["opencode-zen"] = originalZenModels;
|
||||
PROVIDER_MODELS["opencode-go"] = originalGoModels;
|
||||
});
|
||||
|
||||
describe("execute", () => {
|
||||
it("routes opencode zen default models to chat completions", async () => {
|
||||
const minimaxResult = await zenExecutor.execute(createInput("minimax-m2.5-free"));
|
||||
assert.equal(minimaxResult.url, "https://opencode.ai/zen/v1/chat/completions");
|
||||
assert.equal(fetchCalls[0].url, "https://opencode.ai/zen/v1/chat/completions");
|
||||
|
||||
const pickleResult = await zenExecutor.execute(createInput("big-pickle"));
|
||||
assert.equal(pickleResult.url, "https://opencode.ai/zen/v1/chat/completions");
|
||||
assert.equal(fetchCalls[1].url, "https://opencode.ai/zen/v1/chat/completions");
|
||||
|
||||
const nanoResult = await zenExecutor.execute(createInput("gpt-5-nano"));
|
||||
assert.equal(nanoResult.url, "https://opencode.ai/zen/v1/chat/completions");
|
||||
assert.equal(fetchCalls[2].url, "https://opencode.ai/zen/v1/chat/completions");
|
||||
});
|
||||
|
||||
it("routes claude target format models to messages endpoint", async () => {
|
||||
const m27Result = await goExecutor.execute(
|
||||
createInput("minimax-m2.7", true, { apiKey: "claude-key" })
|
||||
);
|
||||
assert.equal(m27Result.url, "https://opencode.ai/zen/go/v1/messages");
|
||||
assert.equal(fetchCalls[0].url, "https://opencode.ai/zen/go/v1/messages");
|
||||
assert.equal(m27Result.headers["anthropic-version"], "2023-06-01");
|
||||
|
||||
const m25Result = await goExecutor.execute(
|
||||
createInput("minimax-m2.5", true, { apiKey: "claude-key" })
|
||||
);
|
||||
assert.equal(m25Result.url, "https://opencode.ai/zen/go/v1/messages");
|
||||
assert.equal(fetchCalls[1].url, "https://opencode.ai/zen/go/v1/messages");
|
||||
assert.equal(m25Result.headers["anthropic-version"], "2023-06-01");
|
||||
});
|
||||
|
||||
it("routes openai responses target format models to responses endpoint", async () => {
|
||||
registerModel("opencode-zen", {
|
||||
id: "gpt-5-responses",
|
||||
name: "GPT 5 Responses",
|
||||
targetFormat: "openai-responses",
|
||||
});
|
||||
|
||||
const result = await zenExecutor.execute(createInput("gpt-5-responses"));
|
||||
|
||||
assert.equal(result.url, "https://opencode.ai/zen/v1/responses");
|
||||
assert.equal(fetchCalls[0].url, "https://opencode.ai/zen/v1/responses");
|
||||
});
|
||||
|
||||
it("routes gemini streaming requests to streamGenerateContent", async () => {
|
||||
registerModel("opencode-zen", {
|
||||
id: "gemini-2.5-pro",
|
||||
name: "Gemini 2.5 Pro",
|
||||
targetFormat: "gemini",
|
||||
});
|
||||
|
||||
const result = await zenExecutor.execute(createInput("gemini-2.5-pro"));
|
||||
|
||||
assert.equal(
|
||||
result.url,
|
||||
"https://opencode.ai/zen/v1/models/gemini-2.5-pro:streamGenerateContent?alt=sse"
|
||||
);
|
||||
assert.equal(
|
||||
fetchCalls[0].url,
|
||||
"https://opencode.ai/zen/v1/models/gemini-2.5-pro:streamGenerateContent?alt=sse"
|
||||
);
|
||||
});
|
||||
|
||||
it("routes gemini non streaming requests to generateContent", async () => {
|
||||
registerModel("opencode-zen", {
|
||||
id: "gemini-2.5-pro",
|
||||
name: "Gemini 2.5 Pro",
|
||||
targetFormat: "gemini",
|
||||
});
|
||||
|
||||
const result = await zenExecutor.execute(createInput("gemini-2.5-pro", false));
|
||||
|
||||
assert.equal(result.url, "https://opencode.ai/zen/v1/models/gemini-2.5-pro:generateContent");
|
||||
assert.equal(
|
||||
fetchCalls[0].url,
|
||||
"https://opencode.ai/zen/v1/models/gemini-2.5-pro:generateContent"
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to chat completions for unknown models", async () => {
|
||||
const result = await zenExecutor.execute(createInput("unknown-model"));
|
||||
|
||||
assert.equal(result.url, "https://opencode.ai/zen/v1/chat/completions");
|
||||
assert.equal(fetchCalls[0].url, "https://opencode.ai/zen/v1/chat/completions");
|
||||
});
|
||||
|
||||
it("builds default headers for standard models", async () => {
|
||||
const result = await zenExecutor.execute(createInput("gpt-5-nano"));
|
||||
|
||||
assert.deepEqual(result.headers, {
|
||||
Authorization: "Bearer test-key",
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
});
|
||||
assert.deepEqual(fetchCalls[0].options.headers, result.headers);
|
||||
});
|
||||
|
||||
it("adds anthropic version for claude target format", async () => {
|
||||
const result = await goExecutor.execute(
|
||||
createInput("minimax-m2.7", true, { apiKey: "claude-key" })
|
||||
);
|
||||
|
||||
assert.deepEqual(result.headers, {
|
||||
Authorization: "Bearer claude-key",
|
||||
"Content-Type": "application/json",
|
||||
"anthropic-version": "2023-06-01",
|
||||
Accept: "text/event-stream",
|
||||
});
|
||||
assert.deepEqual(fetchCalls[0].options.headers, result.headers);
|
||||
});
|
||||
|
||||
it("omits accept header when stream is false", async () => {
|
||||
const result = await zenExecutor.execute(createInput("big-pickle", false));
|
||||
|
||||
assert.deepEqual(result.headers, {
|
||||
Authorization: "Bearer test-key",
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
assert.deepEqual(fetchCalls[0].options.headers, result.headers);
|
||||
});
|
||||
|
||||
it("omits authorization when credentials are missing", async () => {
|
||||
const result = await zenExecutor.execute(createInput("minimax-m2.5-free", true, null));
|
||||
|
||||
assert.deepEqual(result.headers, {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
});
|
||||
assert.deepEqual(fetchCalls[0].options.headers, result.headers);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -101,7 +101,8 @@ test("CUSTOM: sets OpenAI reasoning_effort from budget", () => {
|
||||
reasoning_effort: "low",
|
||||
};
|
||||
const result = applyThinkingBudget(body);
|
||||
assert.equal(result.reasoning_effort, "high");
|
||||
// T11 (sub2api gap): full budget (131072) now maps to "max" instead of "high"
|
||||
assert.equal(result.reasoning_effort, "max");
|
||||
setThinkingBudgetConfig(DEFAULT_THINKING_CONFIG);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user