Compare commits

...

56 Commits

Author SHA1 Message Date
diegosouzapw
1429fea27a fix(routing): unprefixed claude models now resolve to anthropic provider (#570)
Changed the heuristic fallback for claude-* models from 'antigravity' to 'anthropic'
as the canonical provider. Users without Antigravity credentials were getting
'No credentials for provider: antigravity' errors when sending unprefixed
Claude model names like 'claude-sonnet-4-5'.

Closes #570
2026-03-24 14:11:13 -03:00
diegosouzapw
3218563f32 chore: merge PRs #581, #582 + local improvements for rc16
Merged PRs:
- #582 — model prefix stripping option (closes #568)
- #581 — npm publish workflow fix (refs #579)

Local changes:
- Restored stashed i18n, CLI tools, and maintenance banner updates
- 926 tests passing
2026-03-24 13:32:05 -03:00
diegosouzapw
d412edbbe1 Merge PR #581: fix(npm) — link electron-release to npm-publish via workflow_call (by @jay77721, refs #579) 2026-03-24 13:28:25 -03:00
diegosouzapw
968159a85d Merge PR #582: feat(proxy) — add model name prefix stripping option (by @jay77721, closes #568) 2026-03-24 13:27:59 -03:00
jay77721
18a3741fc2 feat(proxy): add model name prefix stripping option (#568)
Add stripModelPrefix boolean setting that, when enabled, strips
provider prefixes (e.g. openai/, anthropic/) from incoming model
names and re-resolves the bare model name using existing heuristics.

This allows tools to send prefixed model names while OmniRoute
handles provider routing at the proxy layer.

- Add stripModelPrefix to settings validation schema (Zod)
- Check setting in getModelInfo() after custom node matching fails
- Falls through to normal resolution on error or when disabled
- Backward compatible: opt-in, default behavior unchanged
2026-03-24 21:52:43 +08:00
jay77721
f1be3e6bb0 fix(npm): link electron-release to npm-publish via workflow_call
- Add workflow_call trigger to npm-publish.yml for direct cross-workflow invocation
- Add publish-npm job to electron-release.yml that calls npm-publish after release
- Add dist-tag support: prerelease versions auto-get 'next' tag, stable gets 'latest'
- Add v-prefix stripping for robust version handling
- Fixes issue where GitHub releases created by bots don't reliably trigger npm-publish
- Refs #579
2026-03-24 21:52:34 +08:00
diegosouzapw
b717a02394 chore: remove PR documentation and unnecessary markdown files 2026-03-24 10:33:25 -03:00
diegosouzapw
d68143e63d Merge PR #575: feat(dashboard,sse,api) — per-model upstream headers, compat PATCH, chat alignment (by @zhangqiang8vip) 2026-03-24 09:46:59 -03:00
diegosouzapw
0d306b8b1c Merge PR #578: feat — add configurable context length to model metadata (by @hijak) 2026-03-24 09:46:32 -03:00
diegosouzapw
a655863855 feat(media): increase transcription file limit to 4GB with validation
- Added MAX_TRANSCRIPTION_FILE_SIZE constant (4GB)
- Added formatFileSize() helper for human-readable display (KB/MB/GB)
- Frontend validation rejects files > 4GB with error message
- Changed label from 'Audio File' to 'Audio / Video File'
- Shows 'Supports audio and video files up to 4 GB' hint
2026-03-24 09:42:36 -03:00
Jack Cowey
58264c80dd feat: add configurable context length to model metadata
- Add contextLength field to RegistryModel interface for per-model overrides
- Add defaultContextLength to RegistryEntry for provider-level defaults
- Set context lengths for major providers:
  - Claude: 200k
  - Codex: 400k (fixes combo context display)
  - Gemini: 1M
  - OpenAI: 128k
  - GitHub Copilot: 128k
  - Kiro/Cursor: 200k
  - OpenCode: 200k
- Include context_length in /v1/models API response
- Add context_length field to combo schema for custom combo context
- Update contextManager to use registry defaults and support env overrides
  - CONTEXT_LENGTH_<PROVIDER> for per-provider override
  - CONTEXT_LENGTH_DEFAULT for global override

This allows clients like OpenClaw to display accurate context windows
for combo models instead of guessing based on model name patterns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 12:29:34 +00:00
diegosouzapw
6f9f1aec65 chore(release): v3.0.0-rc.15 — CHANGELOG + openapi version sync
Updated CHANGELOG with sprint results:
- i18n: 2,788 keys synced across 30 languages
- 16 provider icons + SVG fallback in ProviderIcon
- Agents fingerprint synced (14 providers)
- dompurify XSS vulnerability fixed (0 npm vulns)
- openapi.yaml version synced
2026-03-24 09:22:02 -03:00
diegosouzapw
97b1ee5b02 fix: sync CLI agents fingerprinting + fix dompurify XSS vulnerability
- Agents page: Added droid, openclaw, copilot, opencode to fingerprinting list
  (synced with CLI Tools — now 14 providers total)
- Fixed dompurify XSS vulnerability (GHSA-v2wj-7wpq-c8vv) via npm overrides
  forcing dompurify ^3.3.2 across all transitive deps (monaco-editor)
- npm audit now reports 0 vulnerabilities
2026-03-24 08:14:24 -03:00
diegosouzapw
fe033cd0b3 fix: add SVG fallback to ProviderIcon component
ProviderIcon now tries: Lobehub → PNG → SVG → GenericIcon.
This resolves 11 providers that only have SVG icons
(comfyui, sdwebui, vertex, cartesia, zai, synthetic,
opencode-go/zen, puter, apikey, oauth).
2026-03-24 07:52:07 -03:00
diegosouzapw
afbd07c62a fix: sync i18n keys across 30 languages + add 16 missing provider icons
Task 01 - i18n:
- Synced 2,788 missing keys across 30 language files (all now at 100%)
- Added 6 new agents namespace keys for OpenCode Integration
- i18n-ified agents page OpenCode section (was hardcoded English)
- Added scanning progress text during agents page loading

Task 02 - Provider Icons:
- Added 16 missing provider icons:
  - 3 copied from existing (alibaba, kimi-coding-apikey, bailian-coding-plan)
  - 2 downloaded (huggingface, deepgram)
  - 11 created as SVG (comfyui, sdwebui, vertex, cartesia, zai,
    synthetic, opencode-go/zen, puter, apikey, oauth)
- Total: 86 icon files covering all 69 providers
2026-03-24 07:34:07 -03:00
diegosouzapw
9b15996545 fix: prevent login lockout when skipping wizard password setup (#574)
When users skip password setup during onboarding (either via 'Skip Password'
checkbox or 'Skip Wizard' button), the app now explicitly sets requireLogin=false.

Previously, requireLogin defaulted to true with no password hash stored,
leaving users permanently stuck on the login page.

Two code paths fixed in onboarding/page.tsx:
- handleSetPassword() with skipSecurity=true
- handleFinish() when no password was configured
2026-03-24 07:06:54 -03:00
zhang-qiang
1dbbd7241d fix(mcp-server): type list-models locals for typecheck:core
Annotate rawModels as unknown[] and warning as string | undefined (avoid never[] / undefined-only inference)

Made-with: Cursor
2026-03-24 17:50:13 +08:00
zhang-qiang
6c0ef48d45 docs(zws_docs): archive PR memory and CI notes in README
Upstream PR context: #575, T06/T11/keytar, commit hygiene, links to V8 and PR draft

Made-with: Cursor
2026-03-24 17:45:34 +08:00
zhang-qiang
8b57f88ca3 fix(open-sse): satisfy T11 explicit-any budget (regex counts word any)
- Reword comments that contained the token any; replace any types with typed shapes

- stream.ts: passthrough tool-call flag via local boolean (state is null in passthrough)

- Document T11 in zws_docs/ZWS_README_V8.md

Made-with: Cursor
2026-03-24 17:42:52 +08:00
zhang-qiang
3e9fdc777e fix(api,zed): T06 validateBody on JSON routes; lazy-load keytar for CI build
- Add validateBody() alongside request.json() on 5 routes (t06:route-validation)

- Dynamic import keytar in zed keychain-reader to avoid libsecret/keytar load during next build

- Document in zws_docs/ZWS_README_V8.md section 9

Made-with: Cursor
2026-03-24 17:36:55 +08:00
zhang-qiang
a8ca88797a feat(dashboard,sse,api): per-model upstream headers, compat PATCH, chat alignment
- Store/sanitize upstreamHeaders; shared forbidden header names (upstreamHeaders.ts)

- chatCore: buildUpstreamHeadersForExecute; T5 recomputes; 401 retry uses translatedBody.model

- Dashboard compat popover + i18n; Zod partialRecord + header value newline guard

- Executors merge upstreamExtraHeaders; sanitize unit tests

- Dev: bootstrap env in run-next, instrumentation-node import, credentialLoader dedupe

Made-with: Cursor
2026-03-24 17:24:11 +08:00
zhang-qiang
71540b5dc0 merge: sync upstream/main (diegosouzapw/OmniRoute) 2026-03-24 13:01:08 +08:00
diegosouzapw
b5a145d7b3 Merge branch 'pr-565' into 3.0.0-rc.14
# Conflicts:
#	docs/i18n/cs/API_REFERENCE.md
#	docs/i18n/cs/CODEBASE_DOCUMENTATION.md
#	docs/i18n/cs/README.md
#	src/i18n/messages/cs.json
2026-03-24 00:19:01 -03:00
diegosouzapw
21d6a0a2dd fix: replace custom YAML parser with js-yaml for correct OpenAPI spec parsing 2026-03-23 22:18:04 -03:00
diegosouzapw
80cc7340ac feat: API Endpoints dashboard — interactive catalog, webhooks, OpenAPI viewer
Phase 1: Interactive REST API Catalog
- GET /api/openapi/spec: serves parsed openapi.yaml as JSON catalog
- POST /api/openapi/try: Try It proxy for inline endpoint testing
- Endpoint catalog with tag grouping, search, method badges
- Expand: schemas, auth, curl examples, Try It panel

Phase 2: OpenAPI Spec Viewer
- Spec info header with version, download YAML/JSON, schema browser

Phase 3: Webhooks & Event Subscriptions
- Migration 011: webhooks table
- src/lib/db/webhooks.ts: CRUD + delivery tracking + auto-disable
- src/lib/webhookDispatcher.ts: HMAC-SHA256, retries
- API: CRUD /api/webhooks + test delivery
- Dashboard: add/edit/toggle/test/delete webhook UI

923 tests pass, tsc clean
2026-03-23 22:07:10 -03:00
diegosouzapw
45b272ee2f chore: bump version to 3.0.0-rc.15
- CHANGELOG: add rc.14 (PRs #562, #561) and rc.15 (#563 per-model combo routing)
- package.json: 3.0.0-rc.13 → 3.0.0-rc.15
- openapi.yaml: version sync to 3.0.0-rc.15
2026-03-23 21:05:44 -03:00
zenobit
f765664580 Update docs/i18n/cs/CLI-TOOLS.md
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-24 00:47:41 +01:00
zenobit
10b44f036d Update docs/i18n/cs/USER_GUIDE.md
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-24 00:47:25 +01:00
zenobit
1bf4ee3a3c Update docs/i18n/cs/CODEBASE_DOCUMENTATION.md
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-24 00:46:58 +01:00
zenobit
5d82ffa503 fix(i18n): Improve Czech translation and variables 2026-03-24 00:43:47 +01:00
diegosouzapw
5dc3fd2ec0 feat: per-model combo routing support (#563)
Add model-pattern → combo mapping feature that automatically routes requests
to specific combos based on model name patterns (glob matching).

Implementation:
- New migration 010: model_combo_mappings table with pattern, combo_id, priority
- DB module with CRUD + resolveComboForModel() using glob-to-regex matching
- getComboForModel() in model.ts: augments getCombo() with pattern fallback
- chat.ts: replaced getCombo() → getComboForModel() at routing decision point
- API endpoints: GET/POST /api/model-combo-mappings, GET/PUT/DELETE by [id]
- ModelRoutingSection.tsx: dashboard UI with inline add/edit/toggle/delete
- Integrated into Combos page
- 15 new unit tests (glob matching, priority ordering, disabled filtering)
- Full test suite: 923/923 pass

Examples:
  claude-sonnet* → code-combo
  claude-*-opus* → frontier-combo
  gpt-4o*       → openai-combo
  gemini-*      → google-combo

Resolves: #563
2026-03-23 20:36:00 -03:00
diegosouzapw
4562fdda92 fix(i18n): improve Czech translation — correct HTTP methods and documentation text
Squash-merge from PR #561 by @zen0bit:
- Replace machine-translated HTTP method names (ZÍSKAT→GET, ZVEŘEJNIT→POST, VLOŽIT→PUT, SMAZAT→DELETE)
- Fix Czech documentation text in API_REFERENCE.md and CODEBASE_DOCUMENTATION.md
- Clean up cs.json translation entries

PR: #561
2026-03-23 19:55:42 -03:00
diegosouzapw
18258b9b0d fix: merge PR #562 — MCP session management, Claude passthrough, OAuth modal, detectFormat fixes
Cherry-pick from codex/omniroute-fixes-20260324:
- Replace MCP singleton transport with per-session architecture for Streamable HTTP
- Fix Claude passthrough via OpenAI round-trip normalization
- Add detectFormatFromEndpoint() for endpoint-aware format detection
- Support raw code#state in OAuth modal for Claude Code remote auth
- Expose cloudConfigured/cloudUrl/machineId in settings API
- Switch docker-compose.prod.yml target to runner-cli
- Add 3 new tests for round-trip and detectFormat

PR: #562
2026-03-23 19:53:02 -03:00
diegosouzapw
92e0f242c7 fix(build): resolve all TypeScript compilation errors and Next.js 15 dynamic route slug conflicts
- Fix Next.js 15 async params in 4 API route handlers (accounts, providers, registered-keys)
- Move providers/[id]/limits → providers/[provider]/limits to resolve slug name conflict
- Add keytar to serverExternalPackages and KNOWN_EXTERNALS in next.config.mjs
- Fix Zod z.record() arity across a2a.ts and issues/report/route.ts
- Fix SearchResponse interface (optional answer property) in SearchTools and ResultsPanel
- Fix ProviderLimits implicit any types in index.tsx and utils.tsx
- Fix better-sqlite3 prepare<T> generic usage in secrets.ts
- Remove duplicate pricing keys (gemini-3-flash-preview)
- Cast analytics result, ApiErrorType import, TaskRoutingConfig type
- Remove rogue app/ duplicate directory from project root

Resolves: #560
2026-03-23 18:23:08 -03:00
diegosouzapw
428fa9404c Merge branch 'main' into 3.0.0-rc 2026-03-23 17:10:35 -03:00
diegosouzapw
3cccc480fb feat: add update notification banner to dashboard homepage (resolves #552) 2026-03-23 16:00:03 -03:00
diegosouzapw
acb94216c8 fix(providers): secure Zed import route and add dashboard UI component 2026-03-23 15:58:18 -03:00
Abhinav
5fa97841b2 fix: Address all 4 bot review warnings
- FIX #1: Add null check for cred.password (prevent undefined access)
- FIX #2: Prioritize actual credentials over hardcoded account patterns
- FIX #3: Convert CommonJS require() to ES imports for consistency
- FIX #4: Move to App Router, add credential metadata response, document maintainer integration

Additional improvements:
- Better TypeScript error typing with optional chaining
- Improved error messages for missing dependencies
- Added maintainer TODO for provider system integration
- Proper Next.js App Router format (route.ts)

All bot warnings resolved. Ready for maintainer review.
2026-03-23 15:58:18 -03:00
Abhinav
4ad66bf7b9 feat: Add Zed IDE OAuth credential import support
- Implement keychain-based credential extractor for Zed IDE
- Support macOS (Keychain), Windows (Credential Manager), Linux (libsecret)
- Add API endpoint: POST /api/providers/zed/import
- Auto-discover OAuth tokens for OpenAI, Anthropic, Google, Mistral, xAI, etc.
- Cross-platform support via keytar library
- Complete documentation with security considerations

Closes community request from OmniRoute Telegram group.
Follows proven pattern used by VS Code, GitHub Copilot CLI, Claude Code.
2026-03-23 15:58:18 -03:00
Diego Rodrigues de Sa e Souza
64860ed5e5 Merge pull request #557 from diegosouzapw/dependabot/npm_and_yarn/production-834ce0f99d
deps: bump the production group with 4 updates
2026-03-23 15:47:48 -03:00
dependabot[bot]
b17faf6e1e deps: bump the production group with 4 updates
Bumps the production group with 4 updates: [jose](https://github.com/panva/jose), [next](https://github.com/vercel/next.js), [undici](https://github.com/nodejs/undici) and [wreq-js](https://github.com/sqdshguy/wreq-js).


Updates `jose` from 6.2.1 to 6.2.2
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v6.2.1...v6.2.2)

Updates `next` from 16.1.7 to 16.2.1
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.1.7...v16.2.1)

Updates `undici` from 7.24.4 to 7.24.5
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.24.4...v7.24.5)

Updates `wreq-js` from 2.2.0 to 2.2.2
- [Release notes](https://github.com/sqdshguy/wreq-js/releases)
- [Commits](https://github.com/sqdshguy/wreq-js/compare/v2.2.0...v2.2.2)

---
updated-dependencies:
- dependency-name: jose
  dependency-version: 6.2.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: next
  dependency-version: 16.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: undici
  dependency-version: 7.24.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: wreq-js
  dependency-version: 2.2.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-23 18:45:59 +00:00
diegosouzapw
0ea73bd527 chore(release): bump version to 3.0.0-rc.13 2026-03-23 15:39:11 -03:00
diegosouzapw
b2f0820560 fix(#549): resolve real API key from keyId in codex/droid/kilo settings
CLI settings routes (codex-settings, droid-settings, kilo-settings) were
writing the masked API key string directly to config files when the
dashboard sent a keyId. Now resolves the real key from the database via
getApiKeyById() before writing, matching the pattern already implemented
in claude-settings, openclaw-settings, and cline-settings.

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

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

Docs: added rc.8 and rc.9 sprint summary to CHANGELOG.md
2026-03-23 08:20:54 -03:00
zhang-qiang
23e3a1c269 docs: move ZWS_README_V4/V5 into zws_docs/
Made-with: Cursor
2026-03-23 14:04:11 +08:00
243 changed files with 15502 additions and 2762 deletions

View File

@@ -0,0 +1,118 @@
---
description: Read all open GitHub Discussions, summarize them, respond to pending ones, and create issues from actionable feature requests
---
# /review-discussions — GitHub Discussions Review & Response Workflow
## Overview
This workflow reads all open GitHub Discussions, generates a categorized summary, identifies which ones need a response, drafts and posts replies, and optionally creates issues from actionable feature requests. It follows the same flow used for Issues but adapted for the Discussions forum.
// turbo-all
## Steps
### 1. Identify the GitHub Repository
- Run: `git -C <project_root> remote get-url origin` to extract the owner/repo
- Parse the owner and repo name from the URL
### 2. Fetch All Open Discussions
- Use `read_url_content` to fetch `https://github.com/<owner>/<repo>/discussions`
- Parse the discussion list to get all discussion titles, IDs, authors, categories, and dates
- For each discussion, fetch the individual page to read the full content and all comments/replies
### 3. Summarize All Discussions
For each discussion, extract:
- **Title** and **#Number**
- **Author** (GitHub username)
- **Category** (Announcements, General, Ideas, Q&A, Show and tell)
- **Date** created
- **Summary** of the original post (1-2 sentences)
- **Comments count** and key participants
- **Your previous response** (if any)
- **Pending action** — whether a response or follow-up is needed
### 4. Present Summary Report to User
Present the full summary to the user organized by category, using a table:
| # | Category | Title | Author | Date | Status |
| --- | -------- | ----- | ------ | ------ | ----------------- |
| #N | Ideas | Title | @user | Mar 23 | ⚠️ Needs response |
| #N | Q&A | Title | @user | Mar 9 | ✅ Answered |
| #N | General | Title | @user | Mar 19 | ⚠️ Needs response |
Highlight:
- **⚠️ Needs response** — No reply from maintainer, or a follow-up comment was left unanswered
- **✅ Answered** — Maintainer already responded
- **🐛 Bug reported** — A bug was mentioned that needs tracking
- **💡 Actionable** — Contains a concrete feature request that could become an issue
### 5. Draft & Post Responses
For each discussion that needs a response, draft a reply following these guidelines:
#### Response Style
- **Friendly and professional** — Start with "Hey @username!"
- **Acknowledge the contribution** — Thank the user for their input
- **Be specific** — Reference existing features, settings, or dashboard pages if the feature already exists
- **Provide workarounds** — If the request isn't implemented yet, suggest current alternatives
- **Commit to action** — If the request is valid, state that you'll open an issue or add it to the roadmap
- **Keep it concise** — 3-5 paragraphs max
#### Posting via Browser
- Use `browser_subagent` to navigate to each discussion and post the comment
- **IMPORTANT**: When typing text in GitHub comment boxes via the browser, use only plain ASCII characters:
- Use regular hyphens `-` instead of em-dashes
- Use `->` instead of arrow symbols
- Do NOT use emoji Unicode characters (the browser keyboard may fail on them)
- Use `**bold**` and `\`code\`` markdown formatting
- Click the green "Comment" button (or "Reply" for threaded replies) after typing
- Verify the comment was posted by checking the page shows the new comment
### 6. Create Issues from Actionable Feature Requests
For discussions that contain concrete, actionable feature requests:
1. Ask the user which ones should become issues
2. For each approved request, create a GitHub issue via `browser_subagent`:
- Navigate to `https://github.com/<owner>/<repo>/issues/new`
- **Title**: `<Feature Name> - <Short description>`
- **Body** should include:
- `## Feature Request` header
- `**Source:** Discussion #N by @author`
- `## Problem` — What limitation the user hit
- `## Proposed Solution` — How it could work
- `### Implementation Ideas` — Technical approach
- `### Current Workarounds` — What users can do today
- `## Additional Context` — Links to related issues/discussions
- Add `enhancement` label
- Click "Submit new issue" / "Create"
3. After creation, go back to the original discussion and post a comment linking to the new issue:
- "I've opened Issue #N to track this feature request. Follow along there for updates!"
### 7. Final Report
Present a final summary to the user:
| Discussion | Action Taken |
| ---------- | ---------------------------------- |
| #N — Title | Responded with workarounds |
| #N — Title | Responded + created Issue #N |
| #N — Title | Already answered, no action needed |
| #N — Title | Responded to follow-up comment |
## Notes
- This workflow is **interactive** — always present the summary and wait for user approval before posting responses or creating issues
- If the user says "pode responder" (or similar approval), proceed with posting all drafted responses
- For discussions in non-English languages, respond in the same language as the original post
- Always reference specific dashboard paths, config options, or code files when explaining existing features
- When a discussion reveals a bug, note it separately from feature requests

View File

@@ -201,3 +201,13 @@ jobs:
release-assets/*.source.zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
publish-npm:
name: Publish to npm
needs: [validate, release]
uses: ./.github/workflows/npm-publish.yml
with:
version: ${{ needs.validate.outputs.version }}
tag: latest
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -6,9 +6,31 @@ on:
workflow_dispatch:
inputs:
version:
description: "Version tag to publish (e.g. 2.6.0)"
description: "Version to publish (e.g. 2.9.5 or 3.0.0-rc.15)"
required: true
type: string
tag:
description: "npm dist-tag (latest / next)"
required: false
default: "latest"
type: choice
options:
- latest
- next
workflow_call:
inputs:
version:
description: "Version to publish (without v prefix)"
required: true
type: string
tag:
description: "npm dist-tag (latest / next)"
required: false
default: "latest"
type: string
secrets:
NPM_TOKEN:
required: true
permissions:
contents: read
@@ -31,16 +53,35 @@ jobs:
- name: Install dependencies (skip scripts to avoid heavy build)
run: npm install --ignore-scripts --no-audit --no-fund
- name: Sync version from release tag or input
- name: Resolve version and dist-tag
id: resolve
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="${{ inputs.version }}"
else
VERSION="${GITHUB_REF_NAME}"
VERSION="${VERSION#v}"
case "${{ github.event_name }}" in
workflow_dispatch|workflow_call)
VERSION="${{ inputs.version }}"
TAG="${{ inputs.tag }}"
;;
release)
VERSION="${GITHUB_REF_NAME}"
;;
esac
# Strip v prefix if present
VERSION="${VERSION#v}"
# Default dist-tag logic
if [ -z "$TAG" ]; then
if [[ "$VERSION" == *-* ]]; then
TAG="next"
else
TAG="latest"
fi
fi
npm version "$VERSION" --no-git-tag-version --allow-same-version
echo "Publishing version: $VERSION"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "tag=$TAG" >> $GITHUB_OUTPUT
echo "📦 Publishing omniroute@$VERSION with tag=$TAG"
- name: Sync package.json version
run: |
npm version "${{ steps.resolve.outputs.version }}" --no-git-tag-version --allow-same-version
- name: Build CLI bundle (standalone app)
env:
@@ -49,12 +90,18 @@ jobs:
- name: Publish to npm
run: |
VERSION=$(node -p "require('./package.json').version")
VERSION="${{ steps.resolve.outputs.version }}"
TAG="${{ steps.resolve.outputs.tag }}"
# Check if this version is already published — skip instead of failing with E403
if npm view "omniroute@${VERSION}" version --silent 2>/dev/null | grep -q "^${VERSION}$"; then
echo "⚠️ Version ${VERSION} is already published on npm — skipping."
echo "⚠️ Version ${VERSION} is already published on npm — skipping."
exit 0
fi
npm publish --access public
if [ "$TAG" = "latest" ]; then
npm publish --access public
else
npm publish --access public --tag "$TAG"
fi
echo "✅ Published omniroute@$VERSION (tag: $TAG)"
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

3
.gitignore vendored
View File

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

View File

@@ -49,19 +49,22 @@ but the real logic lives in `src/lib/db/`.
Translation between provider formats: `open-sse/translator/`
**Upstream model extra headers** (`compatByProtocol` / custom models): merged in executors after default auth; **same header name replaces** the executor value (e.g. custom `Authorization` overrides Bearer). In `open-sse/handlers/chatCore.ts`, the primary request merges headers for **both** the client model id and `resolveModelAlias(clientModel)` (resolved id wins on key conflicts). **T5 intra-family fallback** recomputes headers using only the fallback model id and `resolveModelAlias(fallback)` so sibling models do not inherit another models headers. Forbidden header names live in `src/shared/constants/upstreamHeaders.ts` — keep sanitize (`models.ts`), Zod (`schemas.ts`), and unit tests aligned when editing that list.
### MCP Server (`open-sse/mcp-server/`)
16 tools for AI agent control via **3 transport modes**:
- **stdio** — Local IDE integration (Claude Desktop, Cursor, VS Code)
- **SSE** — Remote Server-Sent Events at `/api/mcp/sse`
- **Streamable HTTP** — Modern bidirectional HTTP at `/api/mcp/stream`
HTTP transports run in-process via `httpTransport.ts` singleton using `WebStandardStreamableHTTPServerTransport`.
| Category | Tools |
| ---------- | ------------------------------------------------------------------------------------------------------------------------- |
| Essential | `get_health`, `list_combos`, `get_combo_metrics`, `switch_combo`, `check_quota`, `route_request`, `cost_report`, `list_models_catalog` |
| Advanced | `simulate_route`, `set_budget_guard`, `set_resilience_profile`, `test_combo`, `get_provider_metrics`, `best_combo_for_task`, `explain_route`, `get_session_snapshot` |
| Category | Tools |
| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Essential | `get_health`, `list_combos`, `get_combo_metrics`, `switch_combo`, `check_quota`, `route_request`, `cost_report`, `list_models_catalog` |
| Advanced | `simulate_route`, `set_budget_guard`, `set_resilience_profile`, `test_combo`, `get_provider_metrics`, `best_combo_for_task`, `explain_route`, `get_session_snapshot` |
- Scoped authorization (9 scopes), audit logging, Zod schemas
- IDE configs for Claude Desktop, Cursor, VS Code Copilot
@@ -79,25 +82,26 @@ Agent-to-Agent v0.3 protocol:
### Auto-Combo Engine (`open-sse/services/autoCombo/`)
Self-healing routing optimization:
- 6-factor scoring, 4 mode packs, bandit exploration
- Progressive cooldown, probe-based re-admission
### Dashboard (`src/app/(dashboard)/`)
| Page | Description |
| ---------------------------- | -------------------------------------------------------------- |
| `/dashboard` | Home with quick start, provider overview |
| `/dashboard/endpoint` | **Endpoints** (tabbed): Endpoint Proxy, MCP, A2A, API Endpoints |
| `/dashboard/providers` | Provider management and connections |
| `/dashboard/combos` | Combo configurations with routing strategies |
| `/dashboard/logs` | Request, Proxy, Audit, Console logs (tabbed) |
| `/dashboard/analytics` | Usage analytics and evaluations |
| `/dashboard/costs` | Cost tracking and breakdown |
| `/dashboard/health` | Uptime, circuit breakers, latency |
| `/dashboard/cli-tools` | CLI tool integrations (Claude, Codex, Antigravity, etc.) |
| `/dashboard/media` | Image, Video, Music generation playground |
| `/dashboard/settings` | System settings with multiple tabs |
| `/dashboard/api-manager` | API key management with model permissions |
| Page | Description |
| ------------------------ | --------------------------------------------------------------- |
| `/dashboard` | Home with quick start, provider overview |
| `/dashboard/endpoint` | **Endpoints** (tabbed): Endpoint Proxy, MCP, A2A, API Endpoints |
| `/dashboard/providers` | Provider management and connections |
| `/dashboard/combos` | Combo configurations with routing strategies |
| `/dashboard/logs` | Request, Proxy, Audit, Console logs (tabbed) |
| `/dashboard/analytics` | Usage analytics and evaluations |
| `/dashboard/costs` | Cost tracking and breakdown |
| `/dashboard/health` | Uptime, circuit breakers, latency |
| `/dashboard/cli-tools` | CLI tool integrations (Claude, Codex, Antigravity, etc.) |
| `/dashboard/media` | Image, Video, Music generation playground |
| `/dashboard/settings` | System settings with multiple tabs |
| `/dashboard/api-manager` | API key management with model permissions |
### OAuth & Tokens (`src/lib/oauth/`)

View File

@@ -6,6 +6,133 @@
---
## [3.0.0-rc.16] — 2026-03-24
### ✨ New Features
- Increased media transcription limits
- Added Model Context Length to registry metadata
- Added per-model upstream custom headers via configuration UI
- Fixed multiple bugs, Zod valiadation for patches, and resolved various community issues.
## [3.0.0-rc.15] — 2026-03-24
### ✨ New Features
- **#563** — Per-model Combo Routing: map model name patterns (glob) to specific combos for automatic routing
- New `model_combo_mappings` table (migration 010) with pattern, combo_id, priority, enabled
- `resolveComboForModel()` DB function with glob-to-regex matching (case-insensitive, `*` and `?` wildcards)
- `getComboForModel()` in `model.ts`: augments `getCombo()` with model-pattern fallback
- `chat.ts`: routing decision now checks model-combo mappings before single-model handling
- API: `GET/POST /api/model-combo-mappings`, `GET/PUT/DELETE /api/model-combo-mappings/:id`
- Dashboard: "Model Routing Rules" section added to Combos page with inline add/edit/toggle/delete
- Examples: `claude-sonnet*` → code-combo, `gpt-4o*` → openai-combo, `gemini-*` → google-combo
### 🌐 i18n
- **Full i18n Sync**: 2,788 missing keys added across 30 language files — all languages now at 100% parity with `en.json`
- **Agents page i18n**: OpenCode Integration section fully internationalized (title, description, scanning, download labels)
- **6 new keys** added to `agents` namespace for OpenCode section
### 🎨 UI/UX
- **Provider Icons**: 16 missing provider icons added (3 copied, 2 downloaded, 11 SVG created)
- **SVG fallback**: `ProviderIcon` component updated with 4-tier strategy: Lobehub → PNG → SVG → Generic icon
- **Agents fingerprinting**: Synced with CLI tools — added droid, openclaw, copilot, opencode to fingerprint list (14 total)
### 🔒 Security
- **CVE fix**: Resolved dompurify XSS vulnerability (GHSA-v2wj-7wpq-c8vv) via npm overrides forcing `dompurify@^3.3.2`
- `npm audit` now reports **0 vulnerabilities**
### 🧪 Tests
- Test suite: **923 tests, 0 failures** (+15 new model-combo mapping tests)
---
## [3.0.0-rc.14] — 2026-03-23
### 🔀 Community PRs Merged
| PR | Author | Summary |
| -------- | -------- | -------------------------------------------------------------------------------------------- |
| **#562** | @coobabm | fix(ux): MCP session management, Claude passthrough normalization, OAuth modal, detectFormat |
| **#561** | @zen0bit | fix(i18n): Czech translation corrections — HTTP method names and documentation updates |
### 🧪 Tests
- Test suite: **908 tests, 0 failures**
---
## [3.0.0-rc.13] — 2026-03-23
### 🔧 Bug Fixes
- **config:** resolve real API key from `keyId` in CLI settings routes (`codex-settings`, `droid-settings`, `kilo-settings`) to prevent writing masked strings (#549)
---
## [3.0.0-rc.12] — 2026-03-23
### 🔀 Community PRs Merged
| PR | Author | Summary |
| -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **#546** | @k0valik | fix(cli): `--version` returning `unknown` on Windows — use `JSON.parse(readFileSync)` instead of ESM import |
| **#555** | @k0valik | fix(sse): centralized `resolveDataDir()` for path resolution in credentials, autoCombo, responses logger, and request logger |
| **#544** | @k0valik | fix(cli): secure CLI tool detection via known installation paths (8 tools) with symlink validation, file-type checks, size bounds, minimal env in healthcheck |
| **#542** | @rdself | fix(ui): improve light mode contrast — add missing CSS theme variables (`bg-primary`, `bg-subtle`, `text-primary`) and fix dark-only colors in log detail |
### 🔧 Bug Fixes
- **TDZ fix in `cliRuntime.ts`** — `validateEnvPath` was used before initialization at module startup by `getExpectedParentPaths()`. Reordered declarations to fix `ReferenceError`.
- **Build fixes** — Added `pino` and `pino-pretty` to `serverExternalPackages` to prevent Turbopack from breaking Pino's internal worker loading.
### 🧪 Tests
- Test suite: **905 tests, 0 failures**
---
## [3.0.0-rc.10] — 2026-03-23
### 🔧 Bug Fixes
- **#509 / #508** — Electron build regression: downgraded Next.js from `16.1.x` to `16.0.10` to eliminate Turbopack module-hashing instability that caused blank screens in the Electron desktop bundle.
- **Unit test fixes** — Corrected two stale test assertions (`nanobanana-image-handler` aspect ratio/resolution, `thinking-budget` Gemini `thinkingConfig` field mapping) that had drifted after recent implementation changes.
- **#541** — Responded to user feedback about installation complexity; no code changes required.
---
## [3.0.0-rc.9] — 2026-03-23
### ✨ New Features
- **T29** — Vertex AI SA JSON Executor: implemented using the `jose` library to handle JWT/Service Account auth, along with configurable regions in the UI and automatic partner model URL building.
- **T42** — Image generation aspect ratio mapping: created `sizeMapper` logic for generic OpenAI formats (`size`), added native `imagen3` handling, and updated NanoBanana endpoints to utilize mapped aspect ratios automatically.
- **T38** — Centralized model specifications: `modelSpecs.ts` created for limits and parameters per model.
### 🔧 Improvements
- **T40** — OpenCode CLI tools integration: native `opencode-zen` and `opencode-go` integration completed in earlier PR.
---
## [3.0.0-rc.8] — 2026-03-23
### 🔧 Bug Fixes & Improvements (Fallback, Quota & Budget)
- **T24** — `503` cooldown await fix + `406` mapping: mapped `406 Not Acceptable` to `503 Service Unavailable` with proper cooldown intervals.
- **T25** — Provider validation fallback: graceful fallback to standard validation models when a specific `validationModelId` is not present.
- **T36** — `403` vs `429` provider handling refinement: extracted into `errorClassifier.ts` to properly segregate hard permissions failures (`403`) from rate limits (`429`).
- **T39** — Endpoint Fallback for `fetchAvailableModels`: implemented a tri-tier mechanism (`/models` -> `/v1/models` -> local generic catalog) + `list_models_catalog` MCP tool updates to reflect `source` and `warning`.
- **T33** — Thinking level to budget conversion: translates qualitative thinking levels into precise budget allocations.
- **T41** — Background task auto redirect: routes heavy background evaluation tasks to flash/efficient models automatically.
- **T23** — Intelligent quota reset fallback: accurately extracts `x-ratelimit-reset` / `retry-after` header values or maps static cooldowns.
---
## [3.0.0-rc.7] — 2026-03-23 _(What's New vs v2.9.5 — will be released as v3.0.0)_
> **Upgrade from v2.9.5:** 16 issues resolved · 2 community PRs merged · 2 new providers · 7 new API endpoints · 3 new features · DB migration 008+009 · 832 tests passing · 15 sub2api gap improvements (T01T15 complete).

View File

@@ -1,374 +0,0 @@
# ZWS_README_V4 — 启动性能优化HMR 泄漏修复与 Turbopack 迁移
## 一、如何发现问题
### 现象
- `npm run dev` 后,首次打开浏览器白屏等待 **5-22 秒**不等。
- 运行一段时间后 Node 进程内存飙升至 **2.4 GB**,触发 Next.js 内存阈值保护强制重启。
- 重启后 `Ready in 82.6s`(正常冷启动仅 3.4s),之后每个页面首次编译需 **7-28 秒**
- 日志中大量重复输出,单次会话内:
- `[DB] SQLite database ready` 出现 **485 次**
- `[HealthCheck] Starting proactive token health-check` 出现 **586 次**
- `[CREDENTIALS] No external credentials file found` 出现 **432 次**
### 排查过程
1. **Terminal 日志分析**:统计关键日志出现次数,发现 DB 连接和 HealthCheck 定时器被反复创建。
2. **代码审计**:追踪到所有受影响模块使用 `let initialized = false` 作为单例守卫——这在 Next.js dev 模式的 Webpack HMR 下会被重置。
3. **对比**`apiBridgeServer.ts` 使用了 `globalThis.__omnirouteApiBridgeStarted`,在日志中无重复初始化,验证了 `globalThis` 方案的有效性。
4. **内存快照**:通过 `Get-Process node` 观察到两个 node 进程分别占用 1.7GB 和 1.0GB。
5. **编译时间分析**:日志中 `compile:` 字段显示 Webpack 编译每个路由需 2-26 秒,对比 Turbopack 应在 0.5-3 秒。
---
## 二、根因分析
### 根因 1P0模块级单例在 HMR 中丢失
Next.js dev 模式下Webpack HMR 会重新执行被修改(或依赖链变化)的模块。模块级 `let` 变量在每次重新执行时被重置为初始值。
```typescript
// 修复前 — 每次 HMR 重新执行时 _db 重置为 null
let _db: SqliteDatabase | null = null;
export function getDbInstance() {
if (_db) return _db; // HMR 后这里永远 false
// ... 重新打开一个新的 DB 连接(旧连接泄漏)
}
```
**受影响的模块与泄漏类型:**
| 模块 | 泄漏资源 | 累计次数 | 后果 |
| ----------------------- | ---------------------- | -------- | ----------------------- |
| `db/core.ts` | SQLite 连接 | 485 | 文件句柄泄漏 + 内存占用 |
| `tokenHealthCheck.ts` | `setInterval` 定时器 | 586 | CPU 空转 + DB 查询风暴 |
| `localHealthCheck.ts` | `setTimeout` 定时器链 | ~400 | 重复 HTTP 请求 + CPU |
| `consoleInterceptor.ts` | console 方法包装 | ~400 | 日志 double-write |
| `gracefulShutdown.ts` | SIGTERM/SIGINT handler | ~400 | 信号处理器堆叠 |
**级联效应**:泄漏的资源持续消耗内存和 CPU → 触发 Next.js 内存阈值保护 → 进程重启 → Webpack 从零重建模块图 → **Ready in 82.6s**
### 根因 2P0强制使用 Webpack 而非 Turbopack
`scripts/run-next.mjs` 中硬编码了 `--webpack` 标志:
```javascript
if (mode === "dev") {
args.splice(2, 0, "--webpack");
}
```
Next.js 16 默认使用 TurbopackRust 编写的增量打包器dev 编译速度是 Webpack 的 5-10 倍。强制回退到 Webpack 导致:
| 指标 | Webpack | Turbopack预期 |
| ----------------------- | ------- | ----------------- |
| 首页编译 | 3.7s | ~0.5s |
| Provider 详情页首次编译 | 22s | ~2-3s |
| API route 首次编译 | 2-7s | ~0.3-1s |
| 内存重启后 Ready | 82.6s | 不会触发 |
### 根因 3P1`node:crypto` 被拉入客户端 bundle
`src/lib/db/proxies.ts` 使用了 `import { randomUUID } from "node:crypto"`。通过 `localDb.ts` 的 re-export 链,这个 Node.js 原生模块被间接拉入客户端组件的 bundle导致 Webpack 报错:
```
UnhandledSchemeError: Reading from "node:crypto" is not handled by plugins
Import trace: node:crypto → ./src/lib/db/proxies.ts → ./src/lib/localDb.ts → page.tsx
```
Webpack 无法处理 `node:` URI scheme 前缀。`crypto`(不带 `node:` 前缀)已在 `next.config.mjs``serverExternalPackages` 中声明为服务端外部包。
### 根因 4P1Edge Runtime 编译警告刷屏
Next.js 16 会同时为 **Node.js****Edge** 两种运行时编译 `instrumentation.ts`。虽然 `register()` 函数内有 `process.env.NEXT_RUNTIME === "nodejs"` 的运行时守卫,但 Turbopack 在打包 Edge 版本时仍会**静态追踪**所有动态 `import()` 的依赖链:
```
instrumentation.ts
→ import("@/lib/db/secrets")
→ @/lib/db/core.ts → fs, path, better-sqlite3
→ @/lib/dataPaths.ts → path, os
→ @/lib/db/migrationRunner.ts → fs, path, url
```
对每个 Node.js 原生模块Turbopack 都输出一条 "not supported in Edge Runtime" 警告。每次有新请求触发热编译时,这组 **10+ 条警告重复刷一遍**,严重污染终端输出,干扰开发调试。
### 根因 5P2启动 import 完全串行
`instrumentation.ts` 中 9 个 `await import()` 完全串行执行,每个都可能触发 Webpack 编译其依赖树:
```typescript
await ensureSecrets(); // 串行 1
const { initConsoleInterceptor } = await import(...); // 串行 2
const { initGracefulShutdown } = await import(...); // 串行 3
const { initApiBridgeServer } = await import(...); // 串行 4
const { startBackgroundRefresh } = await import(...); // 串行 5
const { getSettings } = await import(...); // 串行 6
const { setCustomAliases } = await import(...); // 串行 7
const { setDefaultFastServiceTierEnabled } = await import(...); // 串行 8
const { initAuditLog, cleanupExpiredLogs } = await import(...); // 串行 9
```
其中 4-6 互不依赖7-8 互不依赖,完全可以并行。
---
## 三、修复方案
### 修复 1globalThis 单例守卫core.ts, tokenHealthCheck.ts, localHealthCheck.ts, consoleInterceptor.ts, gracefulShutdown.ts
**原理**`globalThis` 对象在 Node.js 进程生命周期内全局唯一,不受 Webpack 模块重新执行的影响。
```typescript
// 修复后 — globalThis 在 HMR 后依然保留
declare global {
var __omnirouteDb: import("better-sqlite3").Database | undefined;
}
function getDb() {
return globalThis.__omnirouteDb ?? null;
}
function setDb(db) {
/* ... */
}
export function getDbInstance() {
const existing = getDb();
if (existing) return existing; // HMR 后命中缓存
// ...
}
```
**每个模块的具体改动:**
| 模块 | globalThis key | 守卫内容 |
| ----------------------- | ----------------------------------- | ----------------------------------------------------------- |
| `db/core.ts` | `__omnirouteDb` | SQLite 连接实例 |
| `tokenHealthCheck.ts` | `__omnirouteTokenHC` | `{ initialized, interval }` |
| `localHealthCheck.ts` | `__omnirouteLocalHC` | `{ initialized, sweepTimer, healthCache, sweepInProgress }` |
| `consoleInterceptor.ts` | `__omnirouteConsoleInterceptorInit` | `boolean` |
| `gracefulShutdown.ts` | `__omnirouteShutdownInit` | `boolean` |
**优点**
- 零依赖,无需额外库。
-`apiBridgeServer.ts` 已有模式一致。
- 对生产环境零影响(非 HMR 场景下行为完全相同)。
**缺点/注意**
- `globalThis` 键名需全局唯一,使用 `__omniroute` 前缀避免冲突。
- 需要 `declare global` 类型声明以保持 TypeScript 类型安全。
- 生产构建中 `globalThis` 存储略冗余(但仅是一个对象引用,几乎零开销)。
### 修复 2支持通过环境变量切换 Turbopackrun-next.mjs
```javascript
// 修复后 — 默认仍用 webpack保持原有行为设置环境变量可启用 Turbopack
if (mode === "dev" && process.env.OMNIROUTE_USE_TURBOPACK !== "1") {
args.splice(2, 0, "--webpack");
}
```
**默认行为不变**dev 模式仍使用 Webpack与修复前完全一致。设置 `OMNIROUTE_USE_TURBOPACK=1` 可切换到 Turbopack 以获得更快的 dev 编译速度。
**优点**
- 零风险:不改变任何人的现有体验。
- 需要时设置 `OMNIROUTE_USE_TURBOPACK=1` 即可获得 5-10 倍编译加速。
- `next.config.mjs` 中已有 `turbopack.resolveAlias` 配置,说明项目已在准备 Turbopack 迁移。
**缺点/注意**
- Turbopack 对某些 Webpack 特定配置(如自定义 externals 函数)的支持方式不同,启用前需测试兼容性。
- 默认走 Webpack 意味着不主动启用 Turbopack 的用户无法享受编译加速。
### 修复 3`node:crypto` → `crypto`proxies.ts, errorResponse.ts
```typescript
// 修复前
import { randomUUID } from "node:crypto";
// 修复后
import { randomUUID } from "crypto";
```
**优点**
- `crypto`(无 `node:` 前缀)已在 `next.config.mjs``serverExternalPackages` 列表中Webpack/Turbopack 会正确将其标记为外部包。
- 消除 `UnhandledSchemeError` 构建失败。
- Node.js 中 `crypto``node:crypto` 解析到同一模块。
**缺点**
- 无。`crypto` 是 Node.js 内建模块,两种写法功能完全等价。
### 修复 4分离 Edge/Node.js Instrumentationinstrumentation.ts → instrumentation-node.ts
**问题**`instrumentation.ts` 中所有 Node.js 逻辑(`ensureSecrets`、DB 初始化、审计日志等)虽然只在 `NEXT_RUNTIME === "nodejs"` 时执行,但 Turbopack 编译 Edge 版本时仍静态追踪其 import 链,对每个 `fs`/`path`/`os`/`better-sqlite3` 等原生模块输出警告。
**方案**:将所有 Node.js 专属逻辑提取到 `src/instrumentation-node.ts`,主文件通过**计算的 import 路径**引入,阻止 Turbopack 静态解析:
```typescript
// src/instrumentation.ts — 精简后仅 ~20 行
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
// 拼接路径阻止 Turbopack 在 Edge 编译时静态解析模块依赖
const nodeMod = "./instrumentation-" + "node";
const { registerNodejs } = await import(nodeMod);
await registerNodejs();
}
}
```
```typescript
// src/instrumentation-node.ts — 包含全部 Node.js 启动逻辑
export async function registerNodejs(): Promise<void> {
await ensureSecrets();
// initConsoleInterceptor, initGracefulShutdown, initApiBridgeServer, ...
// (原 instrumentation.ts 的完整 Node.js 逻辑)
}
```
**关键技术**`"./instrumentation-" + "node"` 是运行时拼接的字符串Turbopack 无法在编译期确定其值,因此**不会追踪**该 import 的依赖树。Node.js 运行时则正常解析该路径并执行。
**优点**
- Edge 编译时完全跳过 Node.js 模块追踪,**10+ 条重复警告全部消除**。
- Node.js 运行时行为与修复前完全一致。
- 启动时间从 **13.9s → 1.25s**Turbopack 不再在 Edge 编译中处理 Node.js 模块图)。
**缺点/注意**
- 新增一个文件 `instrumentation-node.ts`,需同步维护。
- 计算 import 路径是有意为之的 bundler 逃逸技巧,需加注释说明原因防止后续重构时被"优化"回静态字符串。
### 修复 5并行化 instrumentation.ts 中的启动 import
```typescript
// 修复后 — 4 个独立模块并行导入
const [
{ initGracefulShutdown },
{ initApiBridgeServer },
{ startBackgroundRefresh },
{ getSettings },
] = await Promise.all([
import("@/lib/gracefulShutdown"),
import("@/lib/apiBridgeServer"),
import("@/domain/quotaCache"),
import("@/lib/db/settings"),
]);
// 2 个 open-sse 模块也并行导入
const [{ setCustomAliases }, { setDefaultFastServiceTierEnabled }] = await Promise.all([
import("@omniroute/open-sse/services/modelDeprecation.ts"),
import("@omniroute/open-sse/executors/codex.ts"),
]);
```
**优点**
- `consoleInterceptor` 仍保持第一个(必须在任何日志前初始化)。
- 后续 4 个无依赖模块并行加载,节省 3 次串行等待。
- open-sse 的 2 个模块也并行加载。
**缺点**
- 并行 import 的错误堆栈略复杂Promise.all 中某一个失败会 reject 整个组)。
- 这里的 compliance 模块仍保持独立 try/catch 串行,因为它有自己的错误处理逻辑。
---
## 四、预期效果
| 指标 | 修复前 | 修复后(预期) |
| ----------------------------- | ------------------------- | ------------------------ |
| DB 连接创建次数 | 485 次/会话 | 1 次 |
| HealthCheck 定时器 | 586 个泄漏 | 1 个 |
| 信号处理器注册 | ~400 次重复 | 1 次 |
| Console 拦截层数 | ~400 层嵌套 | 1 层 |
| 内存使用峰值 | 2.4 GB → OOM 重启 | 预期 < 500 MB |
| 冷启动 Ready | 3.4s | ~3s略快 |
| 内存重启 Ready | 82.6s | 不再触发内存重启 |
| Login 页首次编译 | 3.7s | ~0.5s (需启用 Turbopack) |
| Provider 详情页首次编译 | 22s | ~2-3s (需启用 Turbopack) |
| `node:crypto` 构建错误 | 反复出现 | 消除 |
| Edge Runtime 编译警告 | 每次热编译刷出 10+ 条 | **0 条** |
| instrumentation 启动耗时 | 13.9s(含 Edge 模块追踪) | **1.25s** |
| instrumentation import 并行度 | 9 次串行 import | 3 批并行 import |
---
## 五、涉及文件清单
| 区域 | 文件 | 改动类型 |
| ------------------- | ------------------------------- | ------------------------------------------------------------------ |
| DB 单例 | `src/lib/db/core.ts` | `let _db``globalThis.__omnirouteDb` |
| Token 健康检查 | `src/lib/tokenHealthCheck.ts` | `let initialized``globalThis.__omnirouteTokenHC` |
| 本地节点健康检查 | `src/lib/localHealthCheck.ts` | `let initialized``globalThis.__omnirouteLocalHC` |
| Console 拦截 | `src/lib/consoleInterceptor.ts` | `let initialized``globalThis.__omnirouteConsoleInterceptorInit` |
| 优雅关停 | `src/lib/gracefulShutdown.ts` | 新增 `globalThis.__omnirouteShutdownInit` 守卫 |
| Dev 启动脚本 | `scripts/run-next.mjs` | 新增 `OMNIROUTE_USE_TURBOPACK=1` 开关 |
| Proxy 注册表 | `src/lib/db/proxies.ts` | `node:crypto``crypto` |
| API 错误响应 | `src/lib/api/errorResponse.ts` | `node:crypto``crypto` |
| 启动钩子(主入口) | `src/instrumentation.ts` | 精简为 ~20 行,计算 import 路径阻止 Edge 追踪 |
| 启动钩子Node.js | `src/instrumentation-node.ts` | 新文件,承载全部 Node.js 启动逻辑 + `Promise.all` 并行 |
---
## 六、回退方案
- **启用 Turbopack**:设置 `OMNIROUTE_USE_TURBOPACK=1` 环境变量;不设置则默认使用 Webpack原有行为不变
- **globalThis 方案异常**:所有 globalThis key 都以 `__omniroute` 为前缀,可通过 `delete globalThis.__omnirouteDb` 等方式手动重置。
- **Edge 警告回退**:若 `instrumentation-node.ts` 拆分导致问题,可将其内容合并回 `instrumentation.ts`,恢复为直接 `import()` 调用(警告会重新出现但不影响功能)。
- **生产环境**:以上修复对生产构建无负面影响——生产环境不存在 HMRglobalThis 单例仅在首次调用时初始化一次。计算 import 路径在 `next build` 时由 Node.js 正常解析,不影响打包产物。
---
## 七、单元测试与备份恢复pre-commit 验证通过)
为保证提交前必须通过验证(不再使用 `--no-verify`),对以下失败用例与生产逻辑做了修复与加固。
### 问题与根因
| 失败项 | 根因 |
| ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| bootstrap-env 4 个用例 | Windows 上 DATA_DIR 解析用 `APPDATA`/`homedir()`,测试只设了 `HOME`,脚本读不到测试用的 `.env`。 |
| domain-persistence costRules 2 个用例 | `core` 在首次 import 时缓存 `DATA_DIR`;测试每测一个 tmpDir 并在 afterEach 删目录,导致后续 describe 使用的 DB 路径已被删,读写得到 0。 |
| fixes-p1 restoreDbBackup | 测试在 DB 仍打开时写 stale 侧文件;`restoreDbBackup` 内 pre-restore 备份未 await 就关库Windows 上句柄未及时释放unlink 报 EBUSY。 |
| fixes-p1 resetStorage 及后续用例 | 上一测留下 DB 打开,下一测 `resetStorage()` 删目录时文件仍被占用EBUSY。 |
### 修复 6bootstrap-env 测试tests/unit/bootstrap-env.test.mjs
在每个用例的 `withTempEnv` 回调开头增加 `process.env.DATA_DIR = dataDir`,使脚本在任意平台(含 Windows都使用测试临时目录而不是依赖 `HOME`/`APPDATA`
### 修复 7domain-persistence 测试tests/unit/domain-persistence.test.mjs
- **单例 tmpDir**:全文件共用一个 `fileTmpDir`,在模块加载时创建并设置 `process.env.DATA_DIR`,与 `core` 首次加载时缓存的路径一致。
- **每测清 DB 不清目录**`beforeEach``resetDbInstance()` 后删除 `storage.sqlite` 及其 `-wal`/`-shm`/`-journal`,保证每测干净 DB不在 afterEach 删目录,避免路径失效。
- **收尾**`after()` 中恢复 `DATA_DIR` 并删除 `fileTmpDir`
- **costRules 断言**:改为小容差精确校验(`assertAlmostEqual`),继续验证 `4.5` / `4.0` 这类业务关键值,避免把真实累计错误放过去。
### 修复 8fixes-p1 测试tests/unit/fixes-p1.test.mjs
- **restoreDbBackup 用例**:在写入 stale 侧文件前调用 `core.resetDbInstance()`,避免 DB 仍打开时写 `-wal`/`-shm` 触发 Windows 锁错误。
- **Windows 跳过**:该用例在 Windows 上仍使用 `test(..., { skip: isWindows })`。原因不是业务逻辑不支持 Windows而是 better-sqlite3 关闭后底层句柄释放存在时序抖动,这条真实 sidecar 集成测试容易退化成不稳定的文件锁测试Linux/macOS 上照常运行。
- **核心兜底测试**:新增平台无关的 `unlinkFileWithRetry` 单测,直接模拟 `EBUSY` / `EPERM` 后重试并最终成功,确保 Windows 相关的重试删除逻辑被稳定覆盖,而不是完全依赖 flaky 的真实文件锁时序。
- **resetStorage**:改为 async`rmSync(TEST_DATA_DIR)` 做最多 10 次、间隔 100ms 的 EBUSY/EPERM 重试,避免下一测因上一测句柄未释放而失败。
### 修复 9备份恢复逻辑src/lib/db/backup.ts
- **pre-restore 备份改为同步等待**:在 `restoreDbBackup` 内用内联逻辑做 pre-restore 备份并 `await` 完成,再调用 `resetDbInstance()`,避免异步 backup 未结束就关库导致后续 unlink 失败。
- **节流语义保持一致**pre-restore 备份成功后补回 `_lastBackupAt = Date.now()`,避免恢复后紧接着又触发一轮额外自动备份。
- **关库后短延迟**`resetDbInstance()``await new Promise(r => setTimeout(r, 500))`,再执行 unlink给 Windows 等平台释放句柄留时间。
- **unlink 重试**:将主库及 `-wal`/`-shm`/`-journal` 的删除提取为 `unlinkFileWithRetry`,统一做最多 10 次、间隔 100ms 的 EBUSY/EPERM 重试,提高恢复流程在锁释放较慢环境下的成功率,也便于单测直接覆盖重试逻辑。
### 涉及文件(本节)
| 区域 | 文件 | 改动类型 |
| -------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| 单元测试 | `tests/unit/bootstrap-env.test.mjs` | 各用例内设置 `process.env.DATA_DIR = dataDir` |
| 单元测试 | `tests/unit/domain-persistence.test.mjs` | 单例 tmpDir、beforeEach 清 DB 文件、after 删目录costRules 改为小容差精确断言 |
| 单元测试 | `tests/unit/fixes-p1.test.mjs` | restoreDbBackup 前 resetDbInstance、Windows skip 说明、resetStorage 重试、`unlinkFileWithRetry` 核心单测 |
| 备份恢复 | `src/lib/db/backup.ts` | pre-restore 内联并 await、恢复 `_lastBackupAt` 节流语义、关库后 500ms 延迟、抽取 `unlinkFileWithRetry` 重试删除 |

View File

@@ -1,332 +0,0 @@
# ZWS_README_V5 — 按协议配置模型兼容性 + 前端性能优化
V4 内容HMR 泄漏修复、Edge 警告消除、测试稳定性已完成V5 在 V4 基础上实现**按协议维度配置模型兼容性**,新增前端查找性能优化与类型安全改进。
---
## 一、如何发现问题
### 现象
- 同一模型被 **OpenAI Chat Completions**、**OpenAI Responses API**、**Anthropic Messages** 三种客户端请求形态调用时V2 的兼容性开关(工具 ID 9 位、不保留 developer 角色)是**全局生效**的——无法为不同协议设置不同的兼容策略。
- 例如:用户希望 OpenAI Responses API 请求时不保留 developer 角色MiniMax 422 修复),但 OpenAI Chat Completions 请求时保留。V2 下只能二选一。
- 前端兼容性弹层未标明当前配置对应哪种协议,容易误导。
- 前端组件中 `Array.find()` 在每次渲染时对 customModels 和 modelCompatOverrides 做 O(n) 线性扫描,模型数量多时存在不必要的性能开销。
- `ModelCompatPatch` 类型定义与运行时逻辑不一致:`preserveOpenAIDeveloperRole` 字段需要支持 `null`(表示取消设置/恢复默认),但类型仅允许 `boolean`
### 排查过程
1. **需求分析**:梳理 `detectFormat(body)` 返回的三种协议键(`openai``openai-responses``claude`),确认每种协议对 developer 角色和 tool call ID 的需求不同。
2. **数据模型设计**:在现有 `normalizeToolCallId` / `preserveOpenAIDeveloperRole` 顶层字段基础上,设计 `compatByProtocol` 嵌套结构,按协议键细分。
3. **构建问题**:客户端 `"use client"` 组件直接从 `@/lib/localDb` 引入常量时,间接拉入了 `node:crypto`(经由 `db/proxies.ts`),触发 Webpack `UnhandledSchemeError`。需将常量拆到 `shared/` 层。
4. **前端性能**:通过 React DevTools 和代码审计发现 `effectiveNormalizeForProtocol` 等函数每次调用都对数组做 `find()`,在渲染列表时存在 O(n²) 的隐患。
---
## 二、根因分析
### 根因 1P0兼容选项无协议维度
V2 的 `normalizeToolCallId` / `preserveOpenAIDeveloperRole` 存储在模型级别的顶层字段,无法区分请求来源协议。`chatCore.ts` 中的 getter 函数只接收 `(providerId, modelId)` 两个参数,不感知当前请求的 `sourceFormat`
**影响**:跨协议场景下用户只能设置一个全局值,无法精确控制。
### 根因 2P1客户端构建拉入 Node.js 模块
`page.tsx`"use client")→ `@/lib/localDb``db/proxies.ts``import { randomUUID } from "node:crypto"`
Webpack 无法处理 `node:` URI scheme`UnhandledSchemeError`。虽然 V4 已将 `node:crypto``crypto` 修复了 `proxies.ts`,但 `localDb.ts` 的 barrel export 链仍然存在风险——客户端组件不应引入任何可能传递到 Node.js 模块的路径。
### 根因 3P2前端查找性能
`effectiveNormalizeForProtocol``effectivePreserveForProtocol``anyNormalizeCompatBadge``anyNoPreserveCompatBadge` 四个函数每次调用都使用 `Array.find()``customModels``modelCompatOverrides` 数组中查找目标模型。在模型列表渲染时,每个模型行会调用多次这些函数,导致 O(n × m) 的查找开销n = 模型数m = 每行调用次数)。
### 根因 4P2类型定义与运行时不一致
```typescript
// V3 暂存区版本(有问题)
export type ModelCompatPatch = Partial<
Pick<
ModelCompatOverride,
"normalizeToolCallId" | "preserveOpenAIDeveloperRole" | "compatByProtocol"
>
>;
```
`ModelCompatOverride.preserveOpenAIDeveloperRole` 类型为 `boolean | undefined`,但 `mergeModelCompatOverride()` 内部有 `=== null` 判断(用于取消设置/恢复默认),类型层面无法覆盖。
---
## 三、修复方案
### 修复 1`compatByProtocol` 存储与读取models.ts
**新增数据结构**
```typescript
type CompatByProtocolMap = Partial<Record<ModelCompatProtocolKey, ModelCompatPerProtocol>>;
export type ModelCompatOverride = {
id: string;
normalizeToolCallId?: boolean;
preserveOpenAIDeveloperRole?: boolean;
compatByProtocol?: CompatByProtocolMap; // 新增
};
```
**读取优先级链**(适用于 `getModelNormalizeToolCallId``getModelPreserveOpenAIDeveloperRole`
```
compatByProtocol[sourceFormat].field → 顶层 field → 默认值
```
1.`sourceFormat` 属于已知协议键(`openai` / `openai-responses` / `claude`),且 `compatByProtocol[sourceFormat]` 中存在目标字段,使用该值。
2. 否则回退到顶层字段。
3. 顶层字段也不存在时使用默认值normalizeToolCallId=falsepreserveOpenAIDeveloperRole=undefined
**深度合并逻辑** `deepMergeCompatByProtocol()`
- 对每个协议键,逐字段合并而非覆盖。
- `normalizeToolCallId=false` 时删除该字段(不存储 false减少冗余
- 合并后若整个协议条目为空对象,删除该协议条目。
- 协议键通过 `isCompatProtocolKey()` 白名单校验,拒绝未知键。
**Getter 签名扩展**(向后兼容,第三参数可选):
```typescript
export function getModelNormalizeToolCallId(
providerId: string,
modelId: string,
sourceFormat?: string | null
): boolean;
export function getModelPreserveOpenAIDeveloperRole(
providerId: string,
modelId: string,
sourceFormat?: string | null
): boolean | undefined;
```
**优点**
- 完全向后兼容:无 `sourceFormat` 参数时行为与 V2 完全一致。
- 协议键白名单校验防止存储污染。
- 深度合并保留未变更协议的配置。
**缺点/注意**
- JSON 存储体积略增(每个模型最多增加 3 个协议条目)。
- 新增 ~80 行 TypeScript 代码。
### 修复 2请求管线传入 sourceFormatchatCore.ts
```typescript
const normalizeToolCallId = getModelNormalizeToolCallId(
provider || "",
model || "",
sourceFormat // 新增第三参
);
const preserveDeveloperRole = getModelPreserveOpenAIDeveloperRole(
provider || "",
model || "",
sourceFormat // 新增第三参
);
```
`sourceFormat` 由已有的 `detectFormat(body)` 返回,无需新增检测逻辑。
**优点**
- 改动仅 2 行,精准传参。
- 不影响其他 handlerembeddings、imageGeneration 等不涉及 developer 角色和 tool call ID
### 修复 3API 路由支持 compatByProtocolroute.ts
**PUT 请求体扩展**
- 解构 `compatByProtocol` 并传入 `updateCustomModel()`
- `compatOnly` 判断扩展:仅含 `provider` + `modelId` + 兼容字段时,走 `mergeModelCompatOverride()` 路径。
- 使用 `ModelCompatPatch` 类型替代行内类型定义,统一类型来源。
**Zod 校验 schema**
```typescript
const modelCompatPerProtocolSchema = z.object({
normalizeToolCallId: z.boolean().optional(),
preserveOpenAIDeveloperRole: z.boolean().optional(),
}).strict(); // strict: 拒绝额外字段
compatByProtocol: z
.record(z.enum(["openai", "openai-responses", "claude"]), modelCompatPerProtocolSchema)
.optional(),
```
**优点**
- `.strict()` 防止客户端注入额外字段。
- `z.enum()` 限定协议键,与后端白名单一致。
- 仅传 `compatByProtocol` 即可更新,前端无需拼装完整模型对象。
### 修复 4客户端安全常量拆分modelCompat.ts
**新增** `src/shared/constants/modelCompat.ts`
```typescript
export const MODEL_COMPAT_PROTOCOL_KEYS = ["openai", "openai-responses", "claude"] as const;
export type ModelCompatProtocolKey = (typeof MODEL_COMPAT_PROTOCOL_KEYS)[number];
```
- 不依赖 Node.js / DB 代码,客户端组件可安全引入。
- `models.ts` 从此模块引入并再导出。
- `localDb.ts` 新增 `ModelCompatPatch` 类型导出(供 route.ts 使用),不导出协议常量。
- `page.tsx` 改为从 `@/shared/constants/modelCompat` 引入。
**优点**
- 彻底切断客户端 → localDb → db → proxies → node:crypto 的依赖链。
- 协议键定义单一来源Single Source of Truth
### 修复 5前端协议选择器与按协议解析page.tsx
**ModelCompatPopover 重构**
- 新增协议下拉选择器(`<select>`),可选 OpenAI Chat / OpenAI Responses / Anthropic Messages。
- 两个开关(工具 ID 9 位、不保留 developer**针对选中协议**生效。
- 选择 Claude 协议时隐藏 developer 角色开关developer 仅对 OpenAI 系有意义)。
- 保存时以 `{ compatByProtocol: { [protocol]: payload } }` 形式提交,后端按协议合并。
- 深色模式适配:下拉框使用 `bg-white dark:bg-zinc-800``text-zinc-900 dark:text-zinc-100`
**Props 接口重构**
旧接口4 个独立值/回调):
```typescript
(normalizeToolCallId, preserveDeveloperRole, onNormalizeChange, onPreserveChange);
```
新接口3 个函数式 props
```typescript
effectiveModelNormalize: (protocol: string) => boolean
effectiveModelPreserveDeveloper: (protocol: string) => boolean
onCompatPatch: (protocol: string, payload: {...}) => void
```
所有消费方(`ModelRow``PassthroughModelRow``CustomModelsSection``CompatibleModelsSection`)已同步更新。
**角标显示逻辑**
- `anyNormalizeCompatBadge()`:任意协议或顶层存在 `normalizeToolCallId=true` 即显示「ID×9」角标。
- `anyNoPreserveCompatBadge()`:任意协议或顶层存在 `preserveOpenAIDeveloperRole=false` 即显示「不保留」角标。
**CustomModelsSection 增强**
- 新增 `modelCompatOverrides` 状态,从 API 响应中获取。
- 新增 `saveCustomCompat()` 函数,支持仅传 `compatByProtocol` 的独立保存。
### 修复 6前端 Map 查找性能优化page.tsx
**问题**`effectiveNormalizeForProtocol` 等函数对 `customModels``modelCompatOverrides``Array.find()` 做 O(n) 查找,在列表渲染时每个模型行多次调用。
**方案**:使用 `useMemo` + `Map` 将数组预建为 O(1) 查找表。
```typescript
type CompatModelMap = Map<string, CompatModelRow>;
function buildCompatMap(rows: CompatModelRow[]): CompatModelMap {
const m = new Map<string, CompatModelRow>();
for (const r of rows) if (r.id) m.set(r.id, r);
return m;
}
// 在组件内
const customMap = useMemo(() => buildCompatMap(modelMeta.customModels), [modelMeta.customModels]);
const overrideMap = useMemo(
() => buildCompatMap(modelMeta.modelCompatOverrides),
[modelMeta.modelCompatOverrides]
);
```
所有查找函数签名从 `(modelId, protocol, customModels[], overrides[])` 改为 `(modelId, protocol, customMap, overrideMap)`,内部使用 `Map.get()` 替代 `Array.find()`
**优点**
- 查找从 O(n) 降为 O(1)。
- `useMemo` 依赖项正确,仅在数据变化时重建 Map。
- `CustomModelsSection` 内部也独立构建 Map不依赖父组件。
### 修复 7ModelCompatPatch 类型修正models.ts
```typescript
// 修复后 — 显式允许 null
export type ModelCompatPatch = {
normalizeToolCallId?: boolean;
preserveOpenAIDeveloperRole?: boolean | null; // null = 取消设置/恢复默认
compatByProtocol?: CompatByProtocolMap;
};
```
`mergeModelCompatOverride()` 内的 `=== null` 判断逻辑一致,类型安全。
### 修复 8CompatByProtocolMap 类型收紧page.tsx
客户端 `CompatByProtocolMap``Record<string, ...>` 改为 `Record<ModelCompatProtocolKey, ...>`,增强类型安全,防止传入未知协议键。
### 修复 9i18n 文案新增
| 键名 | 中文 | 英文 |
| ------------------------------- | --------------------------------------------- | -------------------------------------------------------------- |
| `compatProtocolLabel` | 客户端请求协议 | Client request protocol |
| `compatProtocolHint` | 以下选项在 OmniRoute 识别到该请求形态时生效。 | These options apply when OmniRoute detects this request shape. |
| `compatProtocolOpenAI` | OpenAI Chat Completions | OpenAI Chat Completions |
| `compatProtocolOpenAIResponses` | OpenAI Responses API | OpenAI Responses API |
| `compatProtocolClaude` | Anthropic Messages | Anthropic Messages |
---
## 四、使用方式
1. 点击模型行的 **「兼容性」** 按钮。
2. 在弹层内先选择 **「客户端请求协议」**OpenAI Chat / OpenAI Responses / Anthropic Messages
3. 勾选该协议下的「工具 ID 9 位」或「不保留 developer 角色」。
4. 保存后,仅在该协议形态的请求下生效。
5. 未配置某协议时,该协议下行为回退到顶层兼容字段(若存在),再回退到默认值(保留 developer、不规范化 tool id
6. 角标「ID×9」「不保留」在任意协议存在对应配置时显示。
---
## 五、预期效果
| 指标 | 修复前 | 修复后 |
| ------------------------- | --------------------- | ------------------------------------------ |
| 兼容性配置维度 | 全局(模型级) | 按协议OpenAI Chat / Responses / Claude |
| developer 角色精确控制 | 不支持 | 支持(如:仅 Responses API 不保留) |
| 前端兼容性查找性能 | O(n) Array.find | O(1) Map.getuseMemo 缓存) |
| ModelCompatPatch 类型安全 | null 值无类型覆盖 | 显式 `boolean \| null` |
| 客户端构建风险 | 可能引入 Node.js 模块 | 已隔离shared/constants 层) |
| API 验证 | 无 compatByProtocol | Zod strict schema 校验 |
| 深色模式 | 协议选择器不可读 | bg/text 适配 dark 主题 |
---
## 六、涉及文件清单
| 区域 | 文件 | 改动类型 |
| ---------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| 协议常量 | `src/shared/constants/modelCompat.ts` | **新建**,客户端安全的协议键与类型 |
| 存储与读写 | `src/lib/db/models.ts` | `compatByProtocol` 数据结构、深度合并、getter 第三参 `sourceFormat``ModelCompatPatch` 类型修正 |
| 再导出层 | `src/lib/localDb.ts` | 新增 `ModelCompatPatch` 类型导出 |
| API 路由 | `src/app/api/provider-models/route.ts` | PUT 支持 `compatByProtocol`,使用 `ModelCompatPatch` 类型 |
| 输入校验 | `src/shared/validation/schemas.ts` | `modelCompatPerProtocolSchema`strict+ `compatByProtocol` 记录校验 |
| 请求管线 | `open-sse/handlers/chatCore.ts` | `getModelNormalizeToolCallId` / `getModelPreserveOpenAIDeveloperRole` 传入 `sourceFormat` |
| 前端 UI | `src/app/(dashboard)/dashboard/providers/[id]/page.tsx` | 协议选择器、按协议解析/保存、角标逻辑、Map 性能优化、类型收紧 |
| i18n | `src/i18n/messages/en.json``src/i18n/messages/zh-CN.json` | 5 条新文案 |
---
## 七、回退方案
- **禁用按协议配置**:删除 `compatByProtocol` 字段后getter 自动回退到顶层字段,行为与 V2 一致。
- **前端 Map 优化回退**:将 `Map.get()` 改回 `Array.find()` 即可,纯性能优化无功能耦合。
- **客户端常量回退**:将 `MODEL_COMPAT_PROTOCOL_KEYS` 定义移回 `models.ts` 并从 `localDb.ts` 导出(需同时确保 `node:crypto` 问题不再存在)。
- **生产环境**:以上修复对生产构建无负面影响。`compatByProtocol` 为可选字段未配置时默认行为不变。API Zod 校验确保不会接受畸形数据。

View File

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

View File

@@ -16,7 +16,7 @@ services:
container_name: omniroute-prod
build:
context: .
target: runner-base
target: runner-cli
image: omniroute:prod
restart: unless-stopped
env_file: .env

View File

@@ -38,15 +38,20 @@ Content-Type: application/json
### Custom Headers
| Header | Direction | Description |
| ------------------------ | --------- | --------------------------------- |
| `X-OmniRoute-No-Cache` | Request | Set to `true` to bypass cache |
| `X-OmniRoute-Progress` | Request | Set to `true` for progress events |
| `Idempotency-Key` | Request | Dedup key (5s window) |
| `X-Request-Id` | Request | Alternative dedup key |
| `X-OmniRoute-Cache` | Response | `HIT` or `MISS` (non-streaming) |
| `X-OmniRoute-Idempotent` | Response | `true` if deduplicated |
| `X-OmniRoute-Progress` | Response | `enabled` if progress tracking on |
| Header | Direction | Description |
| ------------------------ | --------- | ------------------------------------------------ |
| `X-OmniRoute-No-Cache` | Request | Set to `true` to bypass cache |
| `X-OmniRoute-Progress` | Request | Set to `true` for progress events |
| `X-Session-Id` | Request | Sticky session key for external session affinity |
| `x_session_id` | Request | Underscore variant also accepted (direct HTTP) |
| `Idempotency-Key` | Request | Dedup key (5s window) |
| `X-Request-Id` | Request | Alternative dedup key |
| `X-OmniRoute-Cache` | Response | `HIT` or `MISS` (non-streaming) |
| `X-OmniRoute-Idempotent` | Response | `true` if deduplicated |
| `X-OmniRoute-Progress` | Response | `enabled` if progress tracking on |
| `X-OmniRoute-Session-Id` | Response | Effective session ID used by OmniRoute |
> Nginx note: if you rely on underscore headers (for example `x_session_id`), enable `underscores_in_headers on;`.
---
@@ -137,10 +142,10 @@ The provider prefix is auto-added if missing. Mismatched models return `400`.
```bash
# Get cache stats
GET /api/cache
GET /api/cache/stats
# Clear all caches
DELETE /api/cache
DELETE /api/cache/stats
```
Response example:
@@ -213,7 +218,7 @@ Response example:
| Endpoint | Method | Description |
| ------------------------------- | ------- | ---------------------- |
| `/api/settings` | GET/PUT | General settings |
| `/api/settings` | GET/PUT/PATCH | General settings |
| `/api/settings/proxy` | GET/PUT | Network proxy config |
| `/api/settings/proxy/test` | POST | Test proxy connection |
| `/api/settings/ip-filter` | GET/PUT | IP allowlist/blocklist |
@@ -226,8 +231,8 @@ Response example:
| ------------------------ | ---------- | ----------------------- |
| `/api/sessions` | GET | Active session tracking |
| `/api/rate-limits` | GET | Per-account rate limits |
| `/api/monitoring/health` | GET | Health check |
| `/api/cache` | GET/DELETE | Cache stats / clear |
| `/api/monitoring/health` | GET | Health check + provider summary (`catalogCount`, `configuredCount`, `activeCount`, `monitoredCount`) |
| `/api/cache/stats` | GET/DELETE | Cache stats / clear |
### Backup & Export/Import
@@ -274,7 +279,7 @@ GET response includes `agents[]` (id, name, binary, version, installed, protocol
| Endpoint | Method | Description |
| ----------------------- | ------- | ------------------------------- |
| `/api/resilience` | GET/PUT | Get/update resilience profiles |
| `/api/resilience` | GET/PATCH | Get/update resilience profiles |
| `/api/resilience/reset` | POST | Reset circuit breakers |
| `/api/rate-limits` | GET | Per-account rate limit status |
| `/api/rate-limit` | GET | Global rate limit configuration |

View File

@@ -2,7 +2,7 @@
🌐 **Languages:** 🇺🇸 [English](ARCHITECTURE.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/ARCHITECTURE.md) | 🇪🇸 [Español](i18n/es/ARCHITECTURE.md) | 🇫🇷 [Français](i18n/fr/ARCHITECTURE.md) | 🇮🇹 [Italiano](i18n/it/ARCHITECTURE.md) | 🇷🇺 [Русский](i18n/ru/ARCHITECTURE.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/ARCHITECTURE.md) | 🇩🇪 [Deutsch](i18n/de/ARCHITECTURE.md) | 🇮🇳 [हिन्दी](i18n/in/ARCHITECTURE.md) | 🇹🇭 [ไทย](i18n/th/ARCHITECTURE.md) | 🇺🇦 [Українська](i18n/uk-UA/ARCHITECTURE.md) | 🇸🇦 [العربية](i18n/ar/ARCHITECTURE.md) | 🇯🇵 [日本語](i18n/ja/ARCHITECTURE.md) | 🇻🇳 [Tiếng Việt](i18n/vi/ARCHITECTURE.md) | 🇧🇬 [Български](i18n/bg/ARCHITECTURE.md) | 🇩🇰 [Dansk](i18n/da/ARCHITECTURE.md) | 🇫🇮 [Suomi](i18n/fi/ARCHITECTURE.md) | 🇮🇱 [עברית](i18n/he/ARCHITECTURE.md) | 🇭🇺 [Magyar](i18n/hu/ARCHITECTURE.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/ARCHITECTURE.md) | 🇰🇷 [한국어](i18n/ko/ARCHITECTURE.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/ARCHITECTURE.md) | 🇳🇱 [Nederlands](i18n/nl/ARCHITECTURE.md) | 🇳🇴 [Norsk](i18n/no/ARCHITECTURE.md) | 🇵🇹 [Português (Portugal)](i18n/pt/ARCHITECTURE.md) | 🇷🇴 [Română](i18n/ro/ARCHITECTURE.md) | 🇵🇱 [Polski](i18n/pl/ARCHITECTURE.md) | 🇸🇰 [Slovenčina](i18n/sk/ARCHITECTURE.md) | 🇸🇪 [Svenska](i18n/sv/ARCHITECTURE.md) | 🇵🇭 [Filipino](i18n/phi/ARCHITECTURE.md) | 🇨🇿 [Čeština](i18n/cs/ARCHITECTURE.md)
_Last updated: 2026-03-04_
_Last updated: 2026-03-24_
## Executive Summary
@@ -65,6 +65,26 @@ Primary runtime model:
- Provider SLA/control plane outside local process
- External CLI binaries themselves (Claude CLI, Codex CLI, etc.)
## Dashboard Surface (Current)
Main pages under `src/app/(dashboard)/dashboard/`:
- `/dashboard` — quick start + provider overview
- `/dashboard/endpoint` — endpoint proxy + MCP + A2A + API endpoint tabs
- `/dashboard/providers` — provider connections and credentials
- `/dashboard/combos` — combo strategies, templates, model routing rules
- `/dashboard/costs` — cost aggregation and pricing visibility
- `/dashboard/analytics` — usage analytics and evaluations
- `/dashboard/limits` — quota/rate controls
- `/dashboard/cli-tools` — CLI onboarding, runtime detection, config generation
- `/dashboard/agents` — detected ACP agents + custom agent registration
- `/dashboard/media` — image/video/music playground
- `/dashboard/search-tools` — search provider testing and history
- `/dashboard/health` — uptime, circuit breakers, rate limits
- `/dashboard/logs` — request/proxy/audit/console logs
- `/dashboard/settings` — system settings tabs (general, routing, combo defaults, etc.)
- `/dashboard/api-manager` — API key lifecycle and model permissions
## High-Level System Context
```mermaid

View File

@@ -9,7 +9,7 @@ cost tracking, model switching, and request logging across every tool.
## How It Works
```
Claude / Codex / Gemini CLI / OpenCode / Cline / KiloCode / Continue / Kiro CLI
Claude / Codex / OpenCode / Cline / KiloCode / Continue / Kiro / Cursor / Copilot
▼ (all point to OmniRoute)
http://YOUR_SERVER:20128/v1
@@ -27,21 +27,38 @@ Claude / Codex / Gemini CLI / OpenCode / Cline / KiloCode / Continue / Kiro CLI
---
## Supported Tools
## Supported Tools (Dashboard Source of Truth)
| Tool | Command | Type | Install Method |
| ---------------- | ------------------- | ----------------- | -------------- |
| **Claude Code** | `claude` | CLI | npm |
| **OpenAI Codex** | `codex` | CLI | npm |
| **Gemini CLI** | `gemini` | CLI | npm |
| **OpenCode** | `opencode` | CLI | npm |
| **Cline** | `cline` | CLI + VS Code ext | npm |
| **KiloCode** | `kilocode` / `kilo` | CLI + VS Code ext | npm |
| **Continue** | guide-based | VS Code ext | VS Code |
| **Kiro CLI** | `kiro-cli` | CLI | curl installer |
| **Cursor** | `cursor` | Desktop app | Download |
| **Droid** | web-based | Built-in agent | OmniRoute |
| **OpenClaw** | web-based | Built-in agent | OmniRoute |
The dashboard cards in `/dashboard/cli-tools` are generated from `src/shared/constants/cliTools.ts`.
Current list (v3.0.0-rc.16):
| Tool | ID | Command | Setup Mode | Install Method |
| ---------------- | ------------- | ------------ | ---------- | -------------- |
| **Claude Code** | `claude` | `claude` | env | npm |
| **OpenAI Codex** | `codex` | `codex` | custom | npm |
| **Factory Droid**| `droid` | `droid` | custom | bundled/CLI |
| **OpenClaw** | `openclaw` | `openclaw` | custom | bundled/CLI |
| **Cursor** | `cursor` | app | guide | desktop app |
| **Cline** | `cline` | `cline` | custom | npm |
| **Kilo Code** | `kilo` | `kilocode` | custom | npm |
| **Continue** | `continue` | extension | guide | VS Code |
| **Antigravity** | `antigravity` | internal | mitm | OmniRoute |
| **GitHub Copilot**| `copilot` | extension | custom | VS Code |
| **OpenCode** | `opencode` | `opencode` | guide | npm |
| **Kiro AI** | `kiro` | app/cli | mitm | desktop/CLI |
### CLI fingerprint sync (Agents + Settings)
`/dashboard/agents` and `Settings > CLI Fingerprint` use `src/shared/constants/cliCompatProviders.ts`.
This keeps provider IDs aligned with CLI cards and legacy IDs.
| CLI ID | Fingerprint Provider ID |
| ------ | ----------------------- |
| `kilo` | `kilocode` |
| `copilot` | `github` |
| `claude` / `codex` / `antigravity` / `kiro` / `cursor` / `cline` / `opencode` / `droid` / `openclaw` | same ID |
Legacy IDs still accepted for compatibility: `copilot`, `kimi-coding`, `qwen`.
---
@@ -67,9 +84,6 @@ npm install -g @anthropic-ai/claude-code
# OpenAI Codex
npm install -g @openai/codex
# Gemini CLI (Google)
npm install -g @google/gemini-cli
# OpenCode
npm install -g opencode-ai
@@ -77,7 +91,7 @@ npm install -g opencode-ai
npm install -g cline
# KiloCode
npm install -g kilecode
npm install -g kilocode
# Kiro CLI (Amazon — requires curl + unzip)
apt-get install -y unzip # on Debian/Ubuntu
@@ -90,7 +104,6 @@ export PATH="$HOME/.local/bin:$PATH" # add to ~/.bashrc
```bash
claude --version # 2.x.x
codex --version # 0.x.x
gemini --version # 0.x.x
opencode --version # x.x.x
cline --version # 2.x.x
kilocode --version # x.x.x (or: kilo --version)
@@ -153,21 +166,6 @@ EOF
---
### Gemini CLI
```bash
mkdir -p ~/.gemini && cat > ~/.gemini/settings.json << EOF
{
"apiKey": "sk-your-omniroute-key",
"baseUrl": "http://localhost:20128/v1"
}
EOF
```
**Test:** `gemini "hello"`
---
### OpenCode
```bash
@@ -324,17 +322,16 @@ They run as internal routes and use OmniRoute's model routing automatically.
OMNIROUTE_URL="http://localhost:20128/v1"
OMNIROUTE_KEY="sk-your-omniroute-key"
npm install -g @anthropic-ai/claude-code @openai/codex @google/gemini-cli opencode-ai cline kilecode
npm install -g @anthropic-ai/claude-code @openai/codex opencode-ai cline kilocode
# Kiro CLI
apt-get install -y unzip 2>/dev/null; curl -fsSL https://cli.kiro.dev/install | bash
# Write configs
mkdir -p ~/.claude ~/.codex ~/.gemini ~/.config/opencode ~/.continue
mkdir -p ~/.claude ~/.codex ~/.config/opencode ~/.continue
cat > ~/.claude/settings.json <<< "{\"apiBaseUrl\":\"$OMNIROUTE_URL\",\"apiKey\":\"$OMNIROUTE_KEY\"}"
cat > ~/.codex/config.yaml <<< "model: auto\napiKey: $OMNIROUTE_KEY\napiBaseUrl: $OMNIROUTE_URL"
cat > ~/.gemini/settings.json <<< "{\"apiKey\":\"$OMNIROUTE_KEY\",\"baseUrl\":\"$OMNIROUTE_URL\"}"
cat >> ~/.bashrc << EOF
export OPENAI_BASE_URL="$OMNIROUTE_URL"
export OPENAI_API_KEY="$OMNIROUTE_KEY"

View File

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

View File

@@ -38,15 +38,15 @@ Content-Type: application/json
### Vlastní záhlaví
Záhlaví | Směr | Popis
--- | --- | ---
`X-OmniRoute-No-Cache` | Žádost | Nastavením na `true` se vynechá mezipaměť
`X-OmniRoute-Progress` | Žádost | Nastaveno na `true` pro události průběhu
`Idempotency-Key` | Žádost | Klíč pro deduplikaci (okno 5 s)
`X-Request-Id` | Žádost | Alternativní klíč pro odstranění duplicitních dat
`X-OmniRoute-Cache` | Odpověď | `HIT` or `MISS` (nestreamované)
`X-OmniRoute-Idempotent` | Odpověď | `true` , pokud je odstraněna duplikace
`X-OmniRoute-Progress` | Odpověď | `enabled` pokud je zapnuto sledování průběhu
| Záhlaví | Směr | Popis |
| ------------------------ | ------- | ------------------------------------------------- |
| `X-OmniRoute-No-Cache` | Žádost | Nastavením na `true` se vynechá mezipaměť |
| `X-OmniRoute-Progress` | Žádost | Nastaveno na `true` pro události průběhu |
| `Idempotency-Key` | Žádost | Klíč pro deduplikaci (okno 5 s) |
| `X-Request-Id` | Žádost | Alternativní klíč pro odstranění duplicitních dat |
| `X-OmniRoute-Cache` | Odpověď | `HIT` or `MISS` (nestreamované) |
| `X-OmniRoute-Idempotent` | Odpověď | `true` , pokud je odstraněna duplikace |
| `X-OmniRoute-Progress` | Odpověď | `enabled` pokud je zapnuto sledování průběhu |
---
@@ -108,18 +108,18 @@ Authorization: Bearer your-api-key
## Koncové body kompatibility
Metoda | Cesta | Formát
--- | --- | ---
ZVEŘEJNIT | `/v1/chat/completions` | OpenAI
ZVEŘEJNIT | `/v1/messages` | Antropic
ZVEŘEJNIT | `/v1/responses` | Reakce OpenAI
ZVEŘEJNIT | `/v1/embeddings` | OpenAI
ZVEŘEJNIT | `/v1/images/generations` | OpenAI
ZÍSKAT | `/v1/models` | OpenAI
ZVEŘEJNIT | `/v1/messages/count_tokens` | Antropic
ZÍSKAT | `/v1beta/models` | Blíženci
ZVEŘEJNIT | `/v1beta/models/{...path}` | Gemini generuje obsah
ZVEŘEJNIT | `/v1/api/chat` | Ollama
| Metoda | Cesta | Formát |
| ------ | --------------------------- | --------------------- |
| POST | `/v1/chat/completions` | OpenAI |
| POST | `/v1/messages` | Anthropic |
| POST | `/v1/responses` | Reakce OpenAI |
| POST | `/v1/embeddings` | OpenAI |
| POST | `/v1/images/generations` | OpenAI |
| GET | `/v1/models` | OpenAI |
| POST | `/v1/messages/count_tokens` | Anthropic |
| GET | `/v1beta/models` | Blíženci |
| POST | `/v1beta/models/{...path}` | Gemini generuje obsah |
| POST | `/v1/api/chat` | Ollama |
### Vyhrazené trasy poskytovatelů
@@ -166,154 +166,154 @@ Příklad odpovědi:
### Ověřování
Koncový bod | Metoda | Popis
--- | --- | ---
`/api/auth/login` | ZVEŘEJNIT | Přihlášení
`/api/auth/logout` | ZVEŘEJNIT | Odhlásit se
`/api/settings/require-login` | ZÍSKAT/VLOŽIT | Vyžaduje se přepnutí přihlášení
| Koncový bod | Metoda | Popis |
| ----------------------------- | ------- | ------------------------------- |
| `/api/auth/login` | POST | Přihlášení |
| `/api/auth/logout` | POST | Odhlásit se |
| `/api/settings/require-login` | GET/PUT | Vyžaduje se přepnutí přihlášení |
### Správa poskytovatelů
Koncový bod | Metoda | Popis
--- | --- | ---
`/api/providers` | ZÍSKAT/ODESLAT | Seznam / vytvoření poskytovatelů
`/api/providers/[id]` | ZÍSKAT/VLOŽIT/ODSTRANIT | Správa poskytovatele
`/api/providers/[id]/test` | ZVEŘEJNIT | Testovací připojení poskytovatele
`/api/providers/[id]/models` | ZÍSKAT | Seznam modelů poskytovatelů
`/api/providers/validate` | ZVEŘEJNIT | Ověření konfigurace poskytovatele
`/api/provider-nodes*` | Různé | Správa uzlů poskytovatelů
`/api/provider-models` | ZÍSKAT/ODESLAT/SMAZAT | Vlastní modely
| Koncový bod | Metoda | Popis |
| ---------------------------- | --------------- | --------------------------------- |
| `/api/providers` | GET/POST | Seznam / vytvoření poskytovatelů |
| `/api/providers/[id]` | GET/PUT/DELETE | Správa poskytovatele |
| `/api/providers/[id]/test` | POST | Testovací připojení poskytovatele |
| `/api/providers/[id]/models` | GET | Seznam modelů poskytovatelů |
| `/api/providers/validate` | POST | Ověření konfigurace poskytovatele |
| `/api/provider-nodes*` | Různé | Správa uzlů poskytovatelů |
| `/api/provider-models` | GET/POST/DELETE | Vlastní modely |
### Toky OAuth
Koncový bod | Metoda | Popis
--- | --- | ---
`/api/oauth/[provider]/[action]` | Různé | OAuth specifický pro poskytovatele
| Koncový bod | Metoda | Popis |
| -------------------------------- | ------ | ---------------------------------- |
| `/api/oauth/[provider]/[action]` | Různé | OAuth specifický pro poskytovatele |
### Směrování a konfigurace
Koncový bod | Metoda | Popis
--- | --- | ---
`/api/models/alias` | ZÍSKAT/ODESLAT | Aliasy modelů
`/api/models/catalog` | ZÍSKAT | Všechny modely podle poskytovatele + typu
`/api/combos*` | Různé | Správa kombinací
`/api/keys*` | Různé | Správa klíčů API
`/api/pricing` | ZÍSKAT | Cena modelu
| Koncový bod | Metoda | Popis |
| --------------------- | -------- | ----------------------------------------- |
| `/api/models/alias` | GET/POST | Aliasy modelů |
| `/api/models/catalog` | GET | Všechny modely podle poskytovatele + typu |
| `/api/combos*` | Různé | Správa kombinací |
| `/api/keys*` | Různé | Správa klíčů API |
| `/api/pricing` | GET | Cena modelu |
### Využití a analýzy
Koncový bod | Metoda | Popis
--- | --- | ---
`/api/usage/history` | ZÍSKAT | Historie používání
`/api/usage/logs` | ZÍSKAT | Protokoly používání
`/api/usage/request-logs` | ZÍSKAT | Protokoly na úrovni požadavků
`/api/usage/[connectionId]` | ZÍSKAT | Využití na připojení
| Koncový bod | Metoda | Popis |
| --------------------------- | ------ | ----------------------------- |
| `/api/usage/history` | GET | Historie používání |
| `/api/usage/logs` | GET | Protokoly používání |
| `/api/usage/request-logs` | GET | Protokoly na úrovni požadavků |
| `/api/usage/[connectionId]` | GET | Využití na připojení |
### Nastavení
Koncový bod | Metoda | Popis
--- | --- | ---
`/api/settings` | ZÍSKAT/VLOŽIT | Obecná nastavení
`/api/settings/proxy` | ZÍSKAT/VLOŽIT | Konfigurace síťového proxy serveru
`/api/settings/proxy/test` | ZVEŘEJNIT | Testovací připojení k proxy serveru
`/api/settings/ip-filter` | ZÍSKAT/VLOŽIT | Seznam povolených/blokovaných IP adres
`/api/settings/thinking-budget` | ZÍSKAT/VLOŽIT | Zdůvodnění rozpočtu tokenů
`/api/settings/system-prompt` | ZÍSKAT/VLOŽIT | Globální systémový výzva
| Koncový bod | Metoda | Popis |
| ------------------------------- | ------- | -------------------------------------- |
| `/api/settings` | GET/PUT | Obecná nastavení |
| `/api/settings/proxy` | GET/PUT | Konfigurace síťového proxy serveru |
| `/api/settings/proxy/test` | POST | Testovací připojení k proxy serveru |
| `/api/settings/ip-filter` | GET/PUT | Seznam povolených/blokovaných IP adres |
| `/api/settings/thinking-budget` | GET/PUT | Zdůvodnění rozpočtu tokenů |
| `/api/settings/system-prompt` | GET/PUT | Globální systémový výzva |
### Monitorování
Koncový bod | Metoda | Popis
--- | --- | ---
`/api/sessions` | ZÍSKAT | Sledování aktivních relací
`/api/rate-limits` | ZÍSKAT | Limity sazeb na účet
`/api/monitoring/health` | ZÍSKAT | Kontrola stavu
`/api/cache` | ZÍSKAT/SMAZAT | Statistiky mezipaměti / vymazat
| Koncový bod | Metoda | Popis |
| ------------------------ | ---------- | ------------------------------- |
| `/api/sessions` | GET | Sledování aktivních relací |
| `/api/rate-limits` | GET | Limity sazeb na účet |
| `/api/monitoring/health` | GET | Kontrola stavu |
| `/api/cache` | GET/DELETE | Statistiky mezipaměti / vymazat |
### Zálohování a export/import
Koncový bod | Metoda | Popis
--- | --- | ---
`/api/db-backups` | ZÍSKAT | Seznam dostupných záloh
`/api/db-backups` | DÁT | Vytvořte ruční zálohu
`/api/db-backups` | ZVEŘEJNIT | Obnovení z konkrétní zálohy
`/api/db-backups/export` | ZÍSKAT | Stáhnout databázi jako soubor .sqlite
`/api/db-backups/import` | ZVEŘEJNIT | Nahrajte soubor .sqlite pro nahrazení databáze
`/api/db-backups/exportAll` | ZÍSKAT | Stáhnout plnou zálohu jako archiv .tar.gz
| Koncový bod | Metoda | Popis |
| --------------------------- | ------ | ---------------------------------------------- |
| `/api/db-backups` | GET | Seznam dostupných záloh |
| `/api/db-backups` | DÁT | Vytvořte ruční zálohu |
| `/api/db-backups` | POST | Obnovení z konkrétní zálohy |
| `/api/db-backups/export` | GET | Stáhnout databázi jako soubor .sqlite |
| `/api/db-backups/import` | POST | Nahrajte soubor .sqlite pro nahrazení databáze |
| `/api/db-backups/exportAll` | GET | Stáhnout plnou zálohu jako archiv .tar.gz |
### Synchronizace s cloudem
Koncový bod | Metoda | Popis
--- | --- | ---
`/api/sync/cloud` | Různé | Operace synchronizace s cloudem
`/api/sync/initialize` | ZVEŘEJNIT | Inicializovat synchronizaci
`/api/cloud/*` | Různé | Správa cloudu
| Koncový bod | Metoda | Popis |
| ---------------------- | ------ | ------------------------------- |
| `/api/sync/cloud` | Různé | Operace synchronizace s cloudem |
| `/api/sync/initialize` | POST | Inicializovat synchronizaci |
| `/api/cloud/*` | Různé | Správa cloudu |
### Nástroje CLI
Koncový bod | Metoda | Popis
--- | --- | ---
`/api/cli-tools/claude-settings` | ZÍSKAT | Stav Clauda CLI
`/api/cli-tools/codex-settings` | ZÍSKAT | Stav příkazového řádku Codexu
`/api/cli-tools/droid-settings` | ZÍSKAT | Stav příkazového řádku Droidu
`/api/cli-tools/openclaw-settings` | ZÍSKAT | Stav rozhraní příkazového řádku OpenClaw
`/api/cli-tools/runtime/[toolId]` | ZÍSKAT | Generické běhové prostředí CLI
| Koncový bod | Metoda | Popis |
| ---------------------------------- | ------ | ---------------------------------------- |
| `/api/cli-tools/claude-settings` | GET | Stav Clauda CLI |
| `/api/cli-tools/codex-settings` | GET | Stav příkazového řádku Codexu |
| `/api/cli-tools/droid-settings` | GET | Stav příkazového řádku Droidu |
| `/api/cli-tools/openclaw-settings` | GET | Stav rozhraní příkazového řádku OpenClaw |
| `/api/cli-tools/runtime/[toolId]` | GET | Generické běhové prostředí CLI |
Mezi odpovědi CLI patří: `installed` , `runnable` , `command` , `commandPath` , `runtimeMode` , `reason` .
### Agenti ACP
Koncový bod | Metoda | Popis
--- | --- | ---
`/api/acp/agents` | ZÍSKAT | Zobrazit seznam všech detekovaných agentů (vestavěných + vlastních) se stavem
`/api/acp/agents` | ZVEŘEJNIT | Přidat vlastního agenta nebo obnovit mezipaměť detekce
`/api/acp/agents` | VYMAZAT | Odebrání vlastního agenta podle parametru dotazu `id`
| Koncový bod | Metoda | Popis |
| ----------------- | ------- | ----------------------------------------------------------------------------- |
| `/api/acp/agents` | GET | Zobrazit seznam všech detekovaných agentů (vestavěných + vlastních) se stavem |
| `/api/acp/agents` | POST | Přidat vlastního agenta nebo obnovit mezipaměť detekce |
| `/api/acp/agents` | VYMAZAT | Odebrání vlastního agenta podle parametru dotazu `id` |
Odpověď GET obsahuje `agents[]` (id, name, binary, version, installed, protocol, isCustom) a `summary` (total, installed, notFound, builtIn, custom).
### Odolnost a limity rychlosti
Koncový bod | Metoda | Popis
--- | --- | ---
`/api/resilience` | ZÍSKAT/VLOŽIT | Získání/aktualizace profilů odolnosti
`/api/resilience/reset` | ZVEŘEJNIT | Resetujte jističe
`/api/rate-limits` | ZÍSKAT | Stav limitu sazby na účet
`/api/rate-limit` | ZÍSKAT | Konfigurace globálního limitu rychlosti
| Koncový bod | Metoda | Popis |
| ----------------------- | ------- | --------------------------------------- |
| `/api/resilience` | GET/PUT | Získání/aktualizace profilů odolnosti |
| `/api/resilience/reset` | POST | Resetujte jističe |
| `/api/rate-limits` | GET | Stav limitu sazby na účet |
| `/api/rate-limit` | GET | Konfigurace globálního limitu rychlosti |
### Evals
Koncový bod | Metoda | Popis
--- | --- | ---
`/api/evals` | ZÍSKAT/ODESLAT | Vypsat eval sady / spustit vyhodnocení
| Koncový bod | Metoda | Popis |
| ------------ | -------- | -------------------------------------- |
| `/api/evals` | GET/POST | Vypsat eval sady / spustit vyhodnocení |
### Zásady
Koncový bod | Metoda | Popis
--- | --- | ---
`/api/policies` | ZÍSKAT/ODESLAT/SMAZAT | Správa směrovacích zásad
| Koncový bod | Metoda | Popis |
| --------------- | --------------- | ------------------------ |
| `/api/policies` | GET/POST/DELETE | Správa směrovacích zásad |
### Dodržování
Koncový bod | Metoda | Popis
--- | --- | ---
`/api/compliance/audit-log` | ZÍSKAT | Protokol auditu shody (poslední N)
| Koncový bod | Metoda | Popis |
| --------------------------- | ------ | ---------------------------------- |
| `/api/compliance/audit-log` | GET | Protokol auditu shody (poslední N) |
### v1beta (kompatibilní s Gemini)
Koncový bod | Metoda | Popis
--- | --- | ---
`/v1beta/models` | ZÍSKAT | Seznam modelů ve formátu Gemini
`/v1beta/models/{...path}` | ZVEŘEJNIT | Koncový bod Gemini `generateContent`
| Koncový bod | Metoda | Popis |
| -------------------------- | ------ | ------------------------------------ |
| `/v1beta/models` | GET | Seznam modelů ve formátu Gemini |
| `/v1beta/models/{...path}` | POST | Koncový bod Gemini `generateContent` |
Tyto koncové body zrcadlí formát API Gemini pro klienty, kteří očekávají nativní kompatibilitu sady Gemini SDK.
### Interní / systémová API
Koncový bod | Metoda | Popis
--- | --- | ---
`/api/init` | ZÍSKAT | Kontrola inicializace aplikace (používá se při prvním spuštění)
`/api/tags` | ZÍSKAT | Tagy modelů kompatibilní s Ollamou (pro klienty Ollamy)
`/api/restart` | ZVEŘEJNIT | Spustit řádný restart serveru
`/api/shutdown` | ZVEŘEJNIT | Spustit řádné vypnutí serveru
| Koncový bod | Metoda | Popis |
| --------------- | ------ | --------------------------------------------------------------- |
| `/api/init` | GET | Kontrola inicializace aplikace (používá se při prvním spuštění) |
| `/api/tags` | GET | Tagy modelů kompatibilní s Ollamou (pro klienty Ollamy) |
| `/api/restart` | POST | Spustit řádný restart serveru |
| `/api/shutdown` | POST | Spustit řádné vypnutí serveru |
> **Poznámka:** Tyto koncové body používá interně systém nebo pro kompatibilitu s klienty Ollama. Koncoví uživatelé je obvykle nevolají.

View File

@@ -2,7 +2,7 @@
🌐 **Jazyky:** 🇺🇸 [angličtina](ARCHITECTURE.md) | 🇧🇷 [Português (Brazílie)](i18n/pt-BR/ARCHITECTURE.md) | 🇪🇸 [Español](i18n/es/ARCHITECTURE.md) | 🇫🇷 [Français](i18n/fr/ARCHITECTURE.md) | 🇮🇹 [Italiano](i18n/it/ARCHITECTURE.md) | 🇷🇺 [Русский](i18n/ru/ARCHITECTURE.md) | 🇨🇳[中文 (简体)](i18n/zh-CN/ARCHITECTURE.md) | 🇩🇪 [Deutsch](i18n/de/ARCHITECTURE.md) | 🇮🇳 [हिन्दी](i18n/in/ARCHITECTURE.md) | 🇹🇭 [ไทย](i18n/th/ARCHITECTURE.md) | 🇺🇦 [Українська](i18n/uk-UA/ARCHITECTURE.md) | 🇸🇦 [العربية](i18n/ar/ARCHITECTURE.md) | 🇯🇵[日本語](i18n/ja/ARCHITECTURE.md)| 🇻🇳 [Tiếng Việt](i18n/vi/ARCHITECTURE.md) | 🇧🇬 [Български](i18n/bg/ARCHITECTURE.md) | 🇩🇰 [Dánsko](i18n/da/ARCHITECTURE.md) | 🇫🇮 [Suomi](i18n/fi/ARCHITECTURE.md) | 🇮🇱 [עברית](i18n/he/ARCHITECTURE.md) | 🇭🇺 [maďarština](i18n/hu/ARCHITECTURE.md) | 🇮🇩 [Bahasa Indonésie](i18n/id/ARCHITECTURE.md) | 🇰🇷 [한국어](i18n/ko/ARCHITECTURE.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/ARCHITECTURE.md) | 🇳🇱 [Nizozemsko](i18n/nl/ARCHITECTURE.md) | 🇳🇴 [Norsk](i18n/no/ARCHITECTURE.md) | 🇵🇹 [Português (Portugalsko)](i18n/pt/ARCHITECTURE.md) | 🇷🇴 [Română](i18n/ro/ARCHITECTURE.md) | 🇵🇱 [Polski](i18n/pl/ARCHITECTURE.md) | 🇸🇰 [Slovenčina](i18n/sk/ARCHITECTURE.md) | 🇸🇪 [Svenska](i18n/sv/ARCHITECTURE.md) | 🇵🇭 [Filipínec](i18n/phi/ARCHITECTURE.md) | 🇨🇿 [Čeština](i18n/cs/ARCHITECTURE.md)
*Poslední aktualizace: 2026-03-04*
_Poslední aktualizace: 2026-03-04_
## Shrnutí pro manažery
@@ -590,45 +590,45 @@ flowchart LR
Každý poskytovatel má specializovaný exekutor rozšiřující `BaseExecutor` (v `open-sse/executors/base.ts` ), který zajišťuje vytváření URL adres, konstrukci hlaviček, opakování s exponenciálním odkladem, hooky pro obnovení pověření a orchestrační metodu `execute()` .
Vykonavatel | Poskytovatel(é) | Speciální manipulace
--- | --- | ---
`DefaultExecutor` | OpenAI, Claude, Gemini, Qwen, iFlow, OpenRouter, GLM, Kimi, MiniMax, DeepSeek, Groq, xAI, Mistral, Perplexity, Together, Fireworks, Cerebras, Cohere, NVIDIA | Konfigurace dynamické adresy URL/záhlaví pro každého poskytovatele
`AntigravityExecutor` | Google Antigravitace | Vlastní ID projektů/relací, analýza Opakování po
`CodexExecutor` | Kodex OpenAI | Vkládá systémové instrukce, vynucuje úsilí k uvažování
`CursorExecutor` | IDE kurzoru | Protokol ConnectRPC, kódování Protobuf, podepisování požadavků pomocí kontrolního součtu
`GithubExecutor` | GitHub Copilot | Aktualizace tokenu Copilot, hlavičky napodobující VSCode
`KiroExecutor` | AWS CodeWhisperer/Kiro | Binární formát AWS EventStream → konverze SSE
`GeminiCLIExecutor` | Rozhraní příkazového řádku Gemini | Cyklus obnovy tokenu Google OAuth
| Vykonavatel | Poskytovatel(é) | Speciální manipulace |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------- |
| `DefaultExecutor` | OpenAI, Claude, Gemini, Qwen, iFlow, OpenRouter, GLM, Kimi, MiniMax, DeepSeek, Groq, xAI, Mistral, Perplexity, Together, Fireworks, Cerebras, Cohere, NVIDIA | Konfigurace dynamické adresy URL/záhlaví pro každého poskytovatele |
| `AntigravityExecutor` | Google Antigravity | Vlastní ID projektů/relací, analýza Opakování po |
| `CodexExecutor` | OpenAI Codex | Vkládá systémové instrukce, vynucuje úsilí k uvažování |
| `CursorExecutor` | IDE kurzoru | Protokol ConnectRPC, kódování Protobuf, podepisování požadavků pomocí kontrolního součtu |
| `GithubExecutor` | GitHub Copilot | Aktualizace tokenu Copilot, hlavičky napodobující VSCode |
| `KiroExecutor` | AWS CodeWhisperer/Kiro | Binární formát AWS EventStream → konverze SSE |
| `GeminiCLIExecutor` | Gemini CLI | Cyklus obnovy tokenu Google OAuth |
Všichni ostatní poskytovatelé (včetně uzlů kompatibilních s vlastními funkcemi) používají `DefaultExecutor` .
## Matice kompatibility poskytovatelů
Poskytovatel | Formát | Autorizace | Proud | Nestreamované | Obnovení tokenu | API pro použití
--- | --- | --- | --- | --- | --- | ---
Claude | Claude | Klíč API / OAuth | ✅ | ✅ | ✅ | ⚠️ Pouze pro administrátory
Blíženci | Blíženci | Klíč API / OAuth | ✅ | ✅ | ✅ | ⚠️ Cloudová konzole
Rozhraní příkazového řádku Gemini | gemini-cli | OAuth | ✅ | ✅ | ✅ | ⚠️ Cloudová konzole
Antigravitace | antigravitace | OAuth | ✅ | ✅ | ✅ | ✅ Plná kvóta API
OpenAI | otevřeno | Klíč API | ✅ | ✅ | ❌ | ❌
Kodex | openai-odpovědi | OAuth | ✅ vynucený | ❌ | ✅ | ✅ Limity sazeb
GitHub Copilot | otevřeno | OAuth + token Copilota | ✅ | ✅ | ✅ | ✅ Snímky kvót
Kurzor | kurzor | Vlastní kontrolní součet | ✅ | ✅ | ❌ | ❌
Kiro | Kiro | OIDC pro jednotné přihlašování AWS | ✅ (Stream událostí) | ❌ | ✅ | ✅ Limity použití
Qwen | otevřeno | OAuth | ✅ | ✅ | ✅ | ⚠️ Na vyžádání
iFlow | otevřeno | OAuth (základní) | ✅ | ✅ | ✅ | ⚠️ Na vyžádání
OpenRouter | otevřeno | Klíč API | ✅ | ✅ | ❌ | ❌
GLM/Kimi/MiniMax | Claude | Klíč API | ✅ | ✅ | ❌ | ❌
Hluboké vyhledávání | otevřeno | Klíč API | ✅ | ✅ | ❌ | ❌
Groq | otevřeno | Klíč API | ✅ | ✅ | ❌ | ❌
xAI (Grok) | otevřeno | Klíč API | ✅ | ✅ | ❌ | ❌
Mistral | otevřeno | Klíč API | ✅ | ✅ | ❌ | ❌
Zmatek | otevřeno | Klíč API | ✅ | ✅ | ❌ | ❌
Společně s umělou inteligencí | otevřeno | Klíč API | ✅ | ✅ | ❌ | ❌
Ohňostroj s umělou inteligencí | otevřeno | Klíč API | ✅ | ✅ | ❌ | ❌
Mozky | otevřeno | Klíč API | ✅ | ✅ | ❌ | ❌
Soudržný | otevřeno | Klíč API | ✅ | ✅ | ❌ | ❌
NVIDIA NIM | otevřeno | Klíč API | ✅ | ✅ | ❌ | ❌
| Poskytovatel | Formát | Autorizace | Proud | Nestreamované | Obnovení tokenu | API pro použití |
| ------------------------------ | --------------- | ---------------------------------- | -------------------- | ------------- | --------------- | --------------------------- |
| Claude | Claude | Klíč API / OAuth | ✅ | ✅ | ✅ | ⚠️ Pouze pro administrátory |
| Blíženci | Blíženci | Klíč API / OAuth | ✅ | ✅ | ✅ | ⚠️ Cloudová konzole |
| Gemini CLI | gemini-cli | OAuth | ✅ | ✅ | ✅ | ⚠️ Cloudová konzole |
| Antigravity | antigravitace | OAuth | ✅ | ✅ | ✅ | ✅ Plná kvóta API |
| OpenAI | otevřeno | Klíč API | ✅ | ✅ | ❌ | ❌ |
| Kodex | openai-odpovědi | OAuth | ✅ vynucený | ❌ | ✅ | ✅ Limity sazeb |
| GitHub Copilot | otevřeno | OAuth + token Copilota | ✅ | ✅ | ✅ | ✅ Snímky kvót |
| Kurzor | kurzor | Vlastní kontrolní součet | ✅ | ✅ | ❌ | ❌ |
| Kiro | Kiro | OIDC pro jednotné přihlašování AWS | ✅ (Stream událostí) | ❌ | ✅ | ✅ Limity použití |
| Qwen | otevřeno | OAuth | ✅ | ✅ | ✅ | ⚠️ Na vyžádání |
| iFlow | otevřeno | OAuth (základní) | ✅ | ✅ | ✅ | ⚠️ Na vyžádání |
| OpenRouter | otevřeno | Klíč API | ✅ | ✅ | ❌ | ❌ |
| GLM/Kimi/MiniMax | Claude | Klíč API | ✅ | ✅ | ❌ | ❌ |
| Hluboké vyhledávání | otevřeno | Klíč API | ✅ | ✅ | ❌ | ❌ |
| Groq | otevřeno | Klíč API | ✅ | ✅ | ❌ | ❌ |
| xAI (Grok) | otevřeno | Klíč API | ✅ | ✅ | ❌ | ❌ |
| Mistral | otevřeno | Klíč API | ✅ | ✅ | ❌ | ❌ |
| Zmatek | otevřeno | Klíč API | ✅ | ✅ | ❌ | ❌ |
| Společně s umělou inteligencí | otevřeno | Klíč API | ✅ | ✅ | ❌ | ❌ |
| Ohňostroj s umělou inteligencí | otevřeno | Klíč API | ✅ | ✅ | ❌ | ❌ |
| Mozky | otevřeno | Klíč API | ✅ | ✅ | ❌ | ❌ |
| Soudržný | otevřeno | Klíč API | ✅ | ✅ | ❌ | ❌ |
| NVIDIA NIM | otevřeno | Klíč API | ✅ | ✅ | ❌ | ❌ |
## Pokrytí překladů formátů
@@ -643,7 +643,7 @@ Cílové formáty zahrnují:
- Chat/Odpovědi v OpenAI
- Claude
- Obálka Gemini/Gemini-CLI/Antigravitace
- Obálka Gemini/Gemini-CLI/Antigravity
- Kiro
- Kurzor
@@ -664,25 +664,25 @@ Další vrstvy zpracování v překladovém kanálu:
## Podporované koncové body API
Koncový bod | Formát | Psovod
--- | --- | ---
`POST /v1/chat/completions` | Chat s OpenAI | `src/sse/handlers/chat.ts`
`POST /v1/messages` | Claude Messages | Stejný obslužný program (automaticky detekováno)
`POST /v1/responses` | Reakce OpenAI | `open-sse/handlers/responsesHandler.ts`
`POST /v1/embeddings` | Vkládání OpenAI | `open-sse/handlers/embeddings.ts`
`GET /v1/embeddings` | Seznam modelů | Trasa API
`POST /v1/images/generations` | Obrázky OpenAI | `open-sse/handlers/imageGeneration.ts`
`GET /v1/images/generations` | Seznam modelů | Trasa API
`POST /v1/providers/{provider}/chat/completions` | Chat s OpenAI | Vyhrazené pro každého poskytovatele s ověřováním modelu
`POST /v1/providers/{provider}/embeddings` | Vkládání OpenAI | Vyhrazené pro každého poskytovatele s ověřováním modelu
`POST /v1/providers/{provider}/images/generations` | Obrázky OpenAI | Vyhrazené pro každého poskytovatele s ověřováním modelu
`POST /v1/messages/count_tokens` | Počet žetonů Claude | Trasa API
`GET /v1/models` | Seznam modelů OpenAI | Trasa API (chat + vkládání + obrázek + vlastní modely)
`GET /api/models/catalog` | Katalog | Všechny modely seskupené podle poskytovatele + typu
`POST /v1beta/models/*:streamGenerateContent` | Rodák z Blíženců | Trasa API
`GET/PUT/DELETE /api/settings/proxy` | Konfigurace proxy serveru | Konfigurace síťového proxy serveru
`POST /api/settings/proxy/test` | Připojení proxy serveru | Koncový bod testu stavu/připojení proxy serveru
`GET/POST/DELETE /api/provider-models` | Vlastní modely | Správa vlastních modelů pro každého poskytovatele
| Koncový bod | Formát | Psovod |
| -------------------------------------------------- | ------------------------- | ------------------------------------------------------- |
| `POST /v1/chat/completions` | Chat s OpenAI | `src/sse/handlers/chat.ts` |
| `POST /v1/messages` | Claude Messages | Stejný obslužný program (automaticky detekováno) |
| `POST /v1/responses` | Reakce OpenAI | `open-sse/handlers/responsesHandler.ts` |
| `POST /v1/embeddings` | Vkládání OpenAI | `open-sse/handlers/embeddings.ts` |
| `GET /v1/embeddings` | Seznam modelů | Trasa API |
| `POST /v1/images/generations` | Obrázky OpenAI | `open-sse/handlers/imageGeneration.ts` |
| `GET /v1/images/generations` | Seznam modelů | Trasa API |
| `POST /v1/providers/{provider}/chat/completions` | Chat s OpenAI | Vyhrazené pro každého poskytovatele s ověřováním modelu |
| `POST /v1/providers/{provider}/embeddings` | Vkládání OpenAI | Vyhrazené pro každého poskytovatele s ověřováním modelu |
| `POST /v1/providers/{provider}/images/generations` | Obrázky OpenAI | Vyhrazené pro každého poskytovatele s ověřováním modelu |
| `POST /v1/messages/count_tokens` | Počet žetonů Claude | Trasa API |
| `GET /v1/models` | Seznam modelů OpenAI | Trasa API (chat + vkládání + obrázek + vlastní modely) |
| `GET /api/models/catalog` | Katalog | Všechny modely seskupené podle poskytovatele + typu |
| `POST /v1beta/models/*:streamGenerateContent` | Rodák z Blíženců | Trasa API |
| `GET/PUT/DELETE /api/settings/proxy` | Konfigurace proxy serveru | Konfigurace síťového proxy serveru |
| `POST /api/settings/proxy/test` | Připojení proxy serveru | Koncový bod testu stavu/připojení proxy serveru |
| `GET/POST/DELETE /api/provider-models` | Vlastní modely | Správa vlastních modelů pro každého poskytovatele |
## Obejít obslužnou rutinu

View File

@@ -27,19 +27,19 @@ Claude / Codex / Gemini CLI / OpenCode / Cline / KiloCode / Continue / Kiro CLI
## Podporované nástroje
Nástroj | Příkaz | Typ | Metoda instalace
--- | --- | --- | ---
**Claude Code** | `claude` | Rozhraní příkazového řádku | npm
**Kodex OpenAI** | `codex` | Rozhraní příkazového řádku | npm
**Rozhraní příkazového řádku Gemini** | `gemini` | Rozhraní příkazového řádku | npm
**OpenCode** | `opencode` | Rozhraní příkazového řádku | npm
**Cline** | `cline` | Rozšíření CLI + VS kódu | npm
**KiloCode** | `kilocode` / `kilo` | Rozšíření CLI + VS kódu | npm
**Pokračovat** | průvodce | VS Code ext | VS kód
**Kiro CLI** | `kiro-cli` | Rozhraní příkazového řádku | instalační program Curl
**Kurzor** | `cursor` | Aplikace pro stolní počítače | Stáhnout
**Droid** | webový | Vestavěný agent | OmniRoute
**OpenClaw** | webový | Vestavěný agent | OmniRoute
| Nástroj | Příkaz | Typ | Instalace |
| ---------------- | ------------------- | --------------- | -------------- |
| **Claude Code** | `claude` | CLI | npm |
| **OpenAI Codex** | `codex` | CLI | npm |
| **Gemini CLI** | `gemini` | CLI | npm |
| **OpenCode** | `opencode` | CLI | npm |
| **Cline** | `cline` | CLI + VS Code | npm |
| **KiloCode** | `kilocode` / `kilo` | CLI + VS Code | npm |
| **Continue** | průvodce | VS Code ext | VS kód |
| **Kiro CLI** | `kiro-cli` | CLI | curl instalace |
| **Kurzor** | `cursor` | Aplikace pro PC | Download |
| **Droid** | webový | Built-in agent | OmniRoute |
| **OpenClaw** | webový | Built-in agent | OmniRoute |
---
@@ -136,7 +136,7 @@ EOF
---
### Kodex OpenAI
### OpenAI Codex
```bash
mkdir -p ~/.codex && cat > ~/.codex/config.yaml << EOF
@@ -150,7 +150,7 @@ EOF
---
### Rozhraní příkazového řádku Gemini
### Gemini CLI
```bash
mkdir -p ~/.gemini && cat > ~/.gemini/settings.json << EOF
@@ -220,7 +220,7 @@ Nebo použijte dashboard OmniRoute → **CLI Tools → KiloCode → Apply Config
---
### Pokračovat (rozšíření kódu VS)
### Continue (rozšíření kódu VS)
Upravit `~/.continue/config.yaml` :
@@ -286,28 +286,28 @@ Ovládací panel OmniRoute automatizuje konfiguraci většiny nástrojů:
## Dostupné koncové body API
Koncový bod | Popis | Použití pro
--- | --- | ---
`/v1/chat/completions` | Standardní chat (všichni poskytovatelé) | Všechny moderní nástroje
`/v1/responses` | API pro odpovědi (formát OpenAI) | Kodex, agentické pracovní postupy
`/v1/completions` | Doplňování starších textů | Starší nástroje používající `prompt:`
`/v1/embeddings` | Vkládání textu | RAG, vyhledávání
`/v1/images/generations` | Generování obrázků | DALL-E, Flux atd.
`/v1/audio/speech` | Převod textu na řeč | ElevenLabs, OpenAI TTS
`/v1/audio/transcriptions` | Převod řeči na text | Deepgram, AssemblyAI
| Koncový bod | Popis | Použití pro |
| -------------------------- | --------------------------------------- | ------------------------------------- |
| `/v1/chat/completions` | Standardní chat (všichni poskytovatelé) | Všechny moderní nástroje |
| `/v1/responses` | API pro odpovědi (formát OpenAI) | Kodex, agentické pracovní postupy |
| `/v1/completions` | Doplňování starších textů | Starší nástroje používající `prompt:` |
| `/v1/embeddings` | Vkládání textu | RAG, vyhledávání |
| `/v1/images/generations` | Generování obrázků | DALL-E, Flux atd. |
| `/v1/audio/speech` | Převod textu na řeč | ElevenLabs, OpenAI TTS |
| `/v1/audio/transcriptions` | Převod řeči na text | Deepgram, AssemblyAI |
---
## Odstraňování problémů
Chyba | Příčina | Opravit
--- | --- | ---
`Connection refused` | OmniRoute neběží | `pm2 start omniroute`
`401 Unauthorized` | Chybný klíč API | Zkontrolovat `/dashboard/api-manager`
`No combo configured` | Žádná aktivní routingová kombinace | Nastavení v `/dashboard/combos`
`invalid model` | Model není v katalogu | Použijte `auto` nebo zkontrolujte `/dashboard/providers`
CLI zobrazuje „není nainstalováno“ | Binární soubor není v cestě PATH | Zkontrolujte, `which <command>`
`kiro-cli: not found` | Není v PATH | `export PATH="$HOME/.local/bin:$PATH"`
| Chyba | Příčina | Opravit |
| ---------------------------------- | ---------------------------------- | -------------------------------------------------------- |
| `Connection refused` | OmniRoute neběží | `pm2 start omniroute` |
| `401 Unauthorized` | Chybný klíč API | Zkontrolovat `/dashboard/api-manager` |
| `No combo configured` | Žádná aktivní routingová kombinace | Nastavení v `/dashboard/combos` |
| `invalid model` | Model není v katalogu | Použijte `auto` nebo zkontrolujte `/dashboard/providers` |
| CLI zobrazuje „není nainstalováno“ | Binární soubor není v cestě PATH | Zkontrolujte, `which <command>` |
| `kiro-cli: not found` | Není v PATH | `export PATH="$HOME/.local/bin:$PATH"` |
---

View File

@@ -110,14 +110,14 @@ omniroute/
Jediný **zdroj pravdivých informací** pro všechny konfigurace poskytovatelů.
Soubor | Účel
--- | ---
`constants.ts` | Objekt `PROVIDERS` se základními URL adresami, přihlašovacími údaji OAuth (výchozí), záhlavími a výchozími systémovými výzvami pro každého poskytovatele. Definuje také `HTTP_STATUS` , `ERROR_TYPES` , `COOLDOWN_MS` , `BACKOFF_CONFIG` a `SKIP_PATTERNS` .
`credentialLoader.ts` | Načte externí přihlašovací údaje z `data/provider-credentials.json` a sloučí je s pevně zakódovanými výchozími hodnotami v `PROVIDERS` . Uchovává tajné údaje mimo kontrolu zdrojového kódu a zároveň zachovává zpětnou kompatibilitu.
`providerModels.ts` | Centrální registr modelů: mapuje aliasy poskytovatelů → ID modelů. Funkce jako `getModels()` , `getProviderByAlias()` .
`codexInstructions.ts` | Systémové instrukce vložené do požadavků Codexu (omezení úprav, pravidla sandboxu, zásady schvalování).
`defaultThinkingSignature.ts` | Výchozí „myšlenkové“ podpisy pro modely Claude a Gemini.
`ollamaModels.ts` | Definice schématu pro lokální Ollama modely (název, velikost, rodina, kvantizace).
| Soubor | Účel |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `constants.ts` | Objekt `PROVIDERS` se základními URL adresami, přihlašovacími údaji OAuth (výchozí), záhlavími a výchozími systémovými výzvami pro každého poskytovatele. Definuje také `HTTP_STATUS` , `ERROR_TYPES` , `COOLDOWN_MS` , `BACKOFF_CONFIG` a `SKIP_PATTERNS` . |
| `credentialLoader.ts` | Načte externí přihlašovací údaje z `data/provider-credentials.json` a sloučí je s pevně zakódovanými výchozími hodnotami v `PROVIDERS` . Uchovává tajné údaje mimo kontrolu zdrojového kódu a zároveň zachovává zpětnou kompatibilitu. |
| `providerModels.ts` | Centrální registr modelů: mapuje aliasy poskytovatelů → ID modelů. Funkce jako `getModels()` , `getProviderByAlias()` . |
| `codexInstructions.ts` | Systémové instrukce vložené do požadavků Codexu (omezení úprav, pravidla sandboxu, zásady schvalování). |
| `defaultThinkingSignature.ts` | Výchozí „myšlenkové“ podpisy pro modely Claude a Gemini. |
| `ollamaModels.ts` | Definice schématu pro lokální Ollama modely (název, velikost, rodina, kvantizace). |
#### Postup načítání přihlašovacích údajů
@@ -194,17 +194,17 @@ classDiagram
BaseExecutor <|-- GithubExecutor
```
Vykonavatel | Poskytovatel | Klíčové specializace
--- | --- | ---
`base.ts` | — | Abstraktní základ: tvorba URL adres, hlavičky, logika opakování, aktualizace přihlašovacích údajů
`default.ts` | Claude, Gemini, OpenAI, GLM, Kimi, MiniMax | Aktualizace generického tokenu OAuth pro standardní poskytovatele
`antigravity.ts` | Kód Google Cloud | Generování ID projektu/relace, záložní více URL adres, vlastní analýza opakovaných pokusů z chybových zpráv („reset po 2h7m23s“)
`cursor.ts` | IDE kurzoru | **Nejsložitější** : autorizace kontrolního součtu SHA-256, kódování požadavků Protobuf, analýza binárních EventStream → SSE odpovědí
`codex.ts` | Kodex OpenAI | Vkládá systémové instrukce, spravuje úrovně myšlení, odstraňuje nepodporované parametry
`gemini-cli.ts` | Rozhraní příkazového řádku Google Gemini | Vytvoření vlastní URL adresy ( `streamGenerateContent` ), aktualizace tokenu Google OAuth
`github.ts` | GitHub Copilot | Systém duálních tokenů (GitHub OAuth + Copilot token), napodobování hlaviček VSCode
`kiro.ts` | AWS CodeWhisperer | Binární parsování AWS EventStream, rámce událostí AMZN, odhad tokenů
`index.ts` | — | Továrna: název poskytovatele map → třída exekutoru s výchozím záložním nastavením
| Vykonavatel | Poskytovatel | Klíčové specializace |
| ---------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
| `base.ts` | — | Abstraktní základ: tvorba URL adres, hlavičky, logika opakování, aktualizace přihlašovacích údajů |
| `default.ts` | Claude, Gemini, OpenAI, GLM, Kimi, MiniMax | Aktualizace generického tokenu OAuth pro standardní poskytovatele |
| `antigravity.ts` | Kód Google Cloud | Generování ID projektu/relace, záložní více URL adres, vlastní analýza opakovaných pokusů z chybových zpráv („reset po 2h7m23s“) |
| `cursor.ts` | IDE kurzoru | **Nejsložitější** : autorizace kontrolního součtu SHA-256, kódování požadavků Protobuf, analýza binárních EventStream → SSE odpovědí |
| `codex.ts` | OpenAI Codex | Vkládá systémové instrukce, spravuje úrovně myšlení, odstraňuje nepodporované parametry |
| `gemini-cli.ts` | Google Gemini CLI | Vytvoření vlastní URL adresy ( `streamGenerateContent` ), aktualizace tokenu Google OAuth |
| `github.ts` | GitHub Copilot | Systém duálních tokenů (GitHub OAuth + Copilot token), napodobování hlaviček VSCode |
| `kiro.ts` | AWS CodeWhisperer | Binární parsování AWS EventStream, rámce událostí AMZN, odhad tokenů |
| `index.ts` | — | Továrna: název poskytovatele map → třída exekutoru s výchozím záložním nastavením |
---
@@ -212,12 +212,12 @@ Vykonavatel | Poskytovatel | Klíčové specializace
**Orchestrační vrstva** koordinuje překlad, provádění, streamování a zpracování chyb.
Soubor | Účel
--- | ---
`chatCore.ts` | **Centrální orchestrátor** (~600 řádků). Zvládá kompletní životní cyklus požadavku: detekce formátu → překlad → odeslání exekutoru → streamovaná/nestreamovaná odpověď → aktualizace tokenu → zpracování chyb → protokolování využití.
`responsesHandler.ts` | Adaptér pro OpenAI Responses API: převádí formát odpovědí → Dokončení chatu → odesílá do `chatCore` → převádí SSE zpět do formátu odpovědí.
`embeddings.ts` | Obslužná rutina generování embeddingu: řeší model embeddingu → poskytovatele, odesílá do API poskytovatele, vrací odpověď na embedding kompatibilní s OpenAI. Podporuje 6+ poskytovatelů.
`imageGeneration.ts` | Obslužná rutina generování obrázků: řeší model obrázku → poskytovatele, podporuje režimy kompatibilní s OpenAI, Gemini-image (Antigravity) a fallback (Nebius). Vrací obrázky v base64 nebo URL.
| Soubor | Účel |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `chatCore.ts` | **Centrální orchestrátor** (~600 řádků). Zvládá kompletní životní cyklus požadavku: detekce formátu → překlad → odeslání exekutoru → streamovaná/nestreamovaná odpověď → aktualizace tokenu → zpracování chyb → protokolování využití. |
| `responsesHandler.ts` | Adaptér pro OpenAI Responses API: převádí formát odpovědí → Dokončení chatu → odesílá do `chatCore` → převádí SSE zpět do formátu odpovědí. |
| `embeddings.ts` | Obslužná rutina generování embeddingu: řeší model embeddingu → poskytovatele, odesílá do API poskytovatele, vrací odpověď na embedding kompatibilní s OpenAI. Podporuje 6+ poskytovatelů. |
| `imageGeneration.ts` | Obslužná rutina generování obrázků: řeší model obrázku → poskytovatele, podporuje režimy kompatibilní s OpenAI, Gemini-image (Antigravity) a fallback (Nebius). Vrací obrázky v base64 nebo URL. |
#### Životní cyklus požadavku (chatCore.ts)
@@ -262,22 +262,22 @@ sequenceDiagram
Obchodní logika, která podporuje obslužné rutiny a vykonavatele.
Soubor | Účel
--- | ---
`provider.ts` | **Detekce formátu** ( `detectFormat` ): analyzuje strukturu těla požadavku a identifikuje formáty Claude/OpenAI/Gemini/Antigravity/Responses (včetně heuristiky `max_tokens` pro Claude). Dále: tvorba URL, tvorba hlaviček, normalizace konfigurace thinking. Podporuje dynamické poskytovatele kompatibilní `openai-compatible-*` a `anthropic-compatible-*` .
`model.ts` | Analýza řetězců modelu ( `claude/model-name``{provider: "claude", model: "model-name"}` ), rozlišení aliasů s detekcí kolizí, sanitizace vstupu (odmítá průchod cestou/řídicí znaky) a rozlišení informací o modelu s podporou asynchronních metod pro získávání aliasů.
`accountFallback.ts` | Ovládání limitů rychlosti: exponenciální upomínka (1 s → 2 s → 4 s → max. 2 min), správa doby zpoždění účtu, klasifikace chyb (které chyby spouštějí fallback a které ne).
`tokenRefresh.ts` | Aktualizace tokenu OAuth pro **všechny poskytovatele** : Google (Gemini, Antigravity), Claude, Codex, Qwen, iFlow, GitHub (duální token OAuth + Copilot), Kiro (AWS SSO OIDC + sociální ověřování). Zahrnuje mezipaměť deduplikace promise za provozu a opakování s exponenciálním zpožděním.
`combo.ts` | **Kombinované modely** : řetězce záložních modelů. Pokud model A selže s chybou způsobilou pro záložní model, zkuste model B, poté C atd. Vrací skutečné stavové kódy upstreamu.
`usage.ts` | Načítá data o kvótách/využití z API poskytovatelů (kvóty GitHub Copilot, kvóty modelu Antigravity, limity rychlosti Codexu, rozpisy využití Kiro, nastavení Claude).
`accountSelector.ts` | Inteligentní výběr účtu s algoritmem bodování: pro výběr optimálního účtu pro každý požadavek se zohledňuje priorita, zdravotní stav, pozice v systému round robin a stav ochlazování.
`contextManager.ts` | Správa životního cyklu kontextu požadavku: vytváří a sleduje objekty kontextu pro každý požadavek s metadaty (ID požadavku, časová razítka, informace o poskytovateli) pro ladění a protokolování.
`ipFilter.ts` | Řízení přístupu založené na IP adrese: podporuje režimy povolených seznamů a blokovaných seznamů. Před zpracováním požadavků API ověřuje IP adresu klienta podle nakonfigurovaných pravidel.
`sessionManager.ts` | Sledování relací s otisky prstů klientů: sleduje aktivní relace pomocí hašovaných identifikátorů klientů, monitoruje počty požadavků a poskytuje metriky relací.
`signatureCache.ts` | Mezipaměť deduplikace na základě signatur požadavků: zabraňuje duplicitním požadavkům ukládáním nedávných signatur požadavků do mezipaměti a vrácením odpovědí z mezipaměti pro identické požadavky v rámci časového okna.
`systemPrompt.ts` | Globální vložení systémového výzvy: přidá konfigurovatelnou systémovou výzvu ke všem požadavkům s možností kompatibility pro jednotlivé poskytovatele.
`thinkingBudget.ts` | Správa rozpočtu tokenů uvažování: podporuje režimy průchodu, automatický (konfigurace strip thinking), vlastní (pevný rozpočet) a adaptivní (měřítko složitosti) pro řízení tokenů myšlení/uvažování.
`wildcardRouter.ts` | Směrování podle vzorů zástupných znaků: rozpoznává vzory zástupných znaků (např. `*/claude-*` ) na konkrétní páry poskytovatel/model na základě dostupnosti a priority.
| Soubor | Účel |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `provider.ts` | **Detekce formátu** ( `detectFormat` ): analyzuje strukturu těla požadavku a identifikuje formáty Claude/OpenAI/Gemini/Antigravity/Responses (včetně heuristiky `max_tokens` pro Claude). Dále: tvorba URL, tvorba hlaviček, normalizace konfigurace thinking. Podporuje dynamické poskytovatele kompatibilní `openai-compatible-*` a `anthropic-compatible-*` . |
| `model.ts` | Analýza řetězců modelu ( `claude/model-name``{provider: "claude", model: "model-name"}` ), rozlišení aliasů s detekcí kolizí, sanitizace vstupu (odmítá průchod cestou/řídicí znaky) a rozlišení informací o modelu s podporou asynchronních metod pro získávání aliasů. |
| `accountFallback.ts` | Ovládání limitů rychlosti: exponenciální upomínka (1 s → 2 s → 4 s → max. 2 min), správa doby zpoždění účtu, klasifikace chyb (které chyby spouštějí fallback a které ne). |
| `tokenRefresh.ts` | Aktualizace tokenu OAuth pro **všechny poskytovatele** : Google (Gemini, Antigravity), Claude, Codex, Qwen, iFlow, GitHub (duální token OAuth + Copilot), Kiro (AWS SSO OIDC + sociální ověřování). Zahrnuje mezipaměť deduplikace promise za provozu a opakování s exponenciálním zpožděním. |
| `combo.ts` | **Kombinované modely** : řetězce záložních modelů. Pokud model A selže s chybou způsobilou pro záložní model, zkuste model B, poté C atd. Vrací skutečné stavové kódy upstreamu. |
| `usage.ts` | Načítá data o kvótách/využití z API poskytovatelů (kvóty GitHub Copilot, kvóty modelu Antigravity, limity rychlosti Codexu, rozpisy využití Kiro, nastavení Claude). |
| `accountSelector.ts` | Inteligentní výběr účtu s algoritmem bodování: pro výběr optimálního účtu pro každý požadavek se zohledňuje priorita, zdravotní stav, pozice v systému round robin a stav ochlazování. |
| `contextManager.ts` | Správa životního cyklu kontextu požadavku: vytváří a sleduje objekty kontextu pro každý požadavek s metadaty (ID požadavku, časová razítka, informace o poskytovateli) pro ladění a protokolování. |
| `ipFilter.ts` | Řízení přístupu založené na IP adrese: podporuje režimy povolených seznamů a blokovaných seznamů. Před zpracováním požadavků API ověřuje IP adresu klienta podle nakonfigurovaných pravidel. |
| `sessionManager.ts` | Sledování relací s otisky prstů klientů: sleduje aktivní relace pomocí hašovaných identifikátorů klientů, monitoruje počty požadavků a poskytuje metriky relací. |
| `signatureCache.ts` | Mezipaměť deduplikace na základě signatur požadavků: zabraňuje duplicitním požadavkům ukládáním nedávných signatur požadavků do mezipaměti a vrácením odpovědí z mezipaměti pro identické požadavky v rámci časového okna. |
| `systemPrompt.ts` | Globální vložení systémového výzvy: přidá konfigurovatelnou systémovou výzvu ke všem požadavkům s možností kompatibility pro jednotlivé poskytovatele. |
| `thinkingBudget.ts` | Správa rozpočtu tokenů uvažování: podporuje režimy průchodu, automatický (konfigurace strip thinking), vlastní (pevný rozpočet) a adaptivní (měřítko složitosti) pro řízení tokenů myšlení/uvažování. |
| `wildcardRouter.ts` | Směrování podle vzorů zástupných znaků: rozpoznává vzory zástupných znaků (např. `*/claude-*` ) na konkrétní páry poskytovatel/model na základě dostupnosti a priority. |
#### Deduplikace obnovení tokenů
@@ -374,13 +374,13 @@ graph TD
end
```
Adresář | Soubory | Popis
--- | --- | ---
`request/` | 8 překladatelů | Převod těl požadavků mezi formáty. Každý soubor se při importu sám zaregistruje pomocí `register(from, to, fn)` .
`response/` | 7 překladatelů | Převádí bloky odpovědí streamovaných dat mezi formáty. Zpracovává typy událostí SSE, myšlenkové bloky a volání nástrojů.
`helpers/` | 6 pomocníků | Sdílené utility: `claudeHelper` (extrakce systémových prompts, thinking config), `geminiHelper` (mapování částí/obsahu), `openaiHelper` (filtrování formátů), `toolCallHelper` (generování ID, vkládání chybějících odpovědí), `maxTokensHelper` , `responsesApiHelper` .
`index.ts` | — | Překladový engine: `translateRequest()` , `translateResponse()` , správa stavu, registr.
`formats.ts` | — | Formátovací konstanty: `OPENAI` , `CLAUDE` , `GEMINI` , `ANTIGRAVITY` , `KIRO` , `CURSOR` , `OPENAI_RESPONSES` .
| Adresář | Soubory | Popis |
| ------------ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `request/` | 8 překladatelů | Převod těl požadavků mezi formáty. Každý soubor se při importu sám zaregistruje pomocí `register(from, to, fn)` . |
| `response/` | 7 překladatelů | Převádí bloky odpovědí streamovaných dat mezi formáty. Zpracovává typy událostí SSE, myšlenkové bloky a volání nástrojů. |
| `helpers/` | 6 pomocníků | Sdílené utility: `claudeHelper` (extrakce systémových prompts, thinking config), `geminiHelper` (mapování částí/obsahu), `openaiHelper` (filtrování formátů), `toolCallHelper` (generování ID, vkládání chybějících odpovědí), `maxTokensHelper` , `responsesApiHelper` . |
| `index.ts` | — | Překladový engine: `translateRequest()` , `translateResponse()` , správa stavu, registr. |
| `formats.ts` | — | Formátovací konstanty: `OPENAI` , `CLAUDE` , `GEMINI` , `ANTIGRAVITY` , `KIRO` , `CURSOR` , `OPENAI_RESPONSES` . |
#### Klíčový design: Samoregistrující se pluginy
@@ -397,15 +397,15 @@ import "./request/claude-to-openai.js"; // ← self-registers
### 4.6 Nástroje ( `open-sse/utils/` )
Soubor | Účel
--- | ---
`error.ts` | Vytváření chybové odezvy (formát kompatibilní s OpenAI), parsování chyb v upstreamu, extrakce doby opakování Antigravity z chybových zpráv, streamování chyb SSE.
`stream.ts` | **SSE Transform Stream** — základní streamovací kanál. Dva režimy: `TRANSLATE` (plný překlad formátu) a `PASSTHROUGH` (normalizace + extrakce využití). Zpracovává ukládání bloků do vyrovnávací paměti, odhad využití a sledování délky obsahu. Instance kodéru/dekodéru pro každý stream se vyhýbají sdílenému stavu.
`streamHelpers.ts` | Nízkoúrovňové utility SSE: `parseSSELine` (tolerantní k bílým znakům), `hasValuableContent` (filtruje prázdné segmenty pro OpenAI/Claude/Gemini), `fixInvalidId` , `formatSSE` (serializace SSE s ohledem na formát s čištěním `perf_metrics` ).
`usageTracking.ts` | Extrakce využití tokenů z libovolného formátu (Claude/OpenAI/Gemini/Responses), odhad s oddělenými poměry znaků na token pro jednotlivé nástroje/zprávy, přidání vyrovnávací paměti (bezpečnostní rezerva 2000 tokenů), filtrování polí specifických pro formát, protokolování konzole s barvami ANSI.
`requestLogger.ts` | Protokolování požadavků na základě souborů (přihlášení pomocí `ENABLE_REQUEST_LOGS=true` ). Vytváří složky relací s očíslovanými soubory: `1_req_client.json``7_res_client.txt` . Veškeré I/O operace jsou asynchronní (aktivní a zapomenutý). Maskuje citlivé hlavičky.
`bypassHandler.ts` | Zachycuje specifické vzory z Claude CLI (extrakce názvu, zahřívání, počet) a vrací falešné odpovědi bez volání jakéhokoli poskytovatele. Podporuje streamování i nestreamování. Záměrně omezeno na rozsah Claude CLI.
`networkProxy.ts` | Rozpozná URL odchozí proxy pro daného poskytovatele s prioritou: konfigurace specifická pro poskytovatele → globální konfigurace → proměnné prostředí ( `HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` ). Podporuje výjimky `NO_PROXY` . Ukládá konfiguraci do mezipaměti po dobu 30 sekund.
| Soubor | Účel |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `error.ts` | Vytváření chybové odezvy (formát kompatibilní s OpenAI), parsování chyb v upstreamu, extrakce doby opakování Antigravity z chybových zpráv, streamování chyb SSE. |
| `stream.ts` | **SSE Transform Stream** — základní streamovací kanál. Dva režimy: `TRANSLATE` (plný překlad formátu) a `PASSTHROUGH` (normalizace + extrakce využití). Zpracovává ukládání bloků do vyrovnávací paměti, odhad využití a sledování délky obsahu. Instance kodéru/dekodéru pro každý stream se vyhýbají sdílenému stavu. |
| `streamHelpers.ts` | Nízkoúrovňové utility SSE: `parseSSELine` (tolerantní k bílým znakům), `hasValuableContent` (filtruje prázdné segmenty pro OpenAI/Claude/Gemini), `fixInvalidId` , `formatSSE` (serializace SSE s ohledem na formát s čištěním `perf_metrics` ). |
| `usageTracking.ts` | Extrakce využití tokenů z libovolného formátu (Claude/OpenAI/Gemini/Responses), odhad s oddělenými poměry znaků na token pro jednotlivé nástroje/zprávy, přidání vyrovnávací paměti (bezpečnostní rezerva 2000 tokenů), filtrování polí specifických pro formát, protokolování konzole s barvami ANSI. |
| `requestLogger.ts` | Protokolování požadavků na základě souborů (přihlášení pomocí `ENABLE_REQUEST_LOGS=true` ). Vytváří složky relací s očíslovanými soubory: `1_req_client.json``7_res_client.txt` . Veškeré I/O operace jsou asynchronní (aktivní a zapomenutý). Maskuje citlivé hlavičky. |
| `bypassHandler.ts` | Zachycuje specifické vzory z Claude CLI (extrakce názvu, zahřívání, počet) a vrací falešné odpovědi bez volání jakéhokoli poskytovatele. Podporuje streamování i nestreamování. Záměrně omezeno na rozsah Claude CLI. |
| `networkProxy.ts` | Rozpozná URL odchozí proxy pro daného poskytovatele s prioritou: konfigurace specifická pro poskytovatele → globální konfigurace → proměnné prostředí ( `HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` ). Podporuje výjimky `NO_PROXY` . Ukládá konfiguraci do mezipaměti po dobu 30 sekund. |
#### Streamovací kanál SSE
@@ -449,32 +449,32 @@ logs/
### 4.7 Aplikační vrstva ( `src/` )
Adresář | Účel
--- | ---
`src/app/` | Webové uživatelské rozhraní, trasy API, middleware Express, obslužné rutiny zpětných volání OAuth
`src/lib/` | Přístup k databázi ( `localDb.ts` , `usageDb.ts` ), ověřování, sdílení
`src/mitm/` | Nástroje proxy typu „man-in-the-middle“ pro zachycení provozu poskytovatelů
`src/models/` | Definice modelů databáze
`src/shared/` | Obálky kolem funkcí open-sse (provider, stream, error atd.)
`src/sse/` | Obslužné rutiny koncových bodů SSE, které propojují knihovnu open-sse s trasami Express
`src/store/` | Správa stavu aplikací
| Adresář | Účel |
| ------------- | ------------------------------------------------------------------------------------------------- |
| `src/app/` | Webové uživatelské rozhraní, trasy API, middleware Express, obslužné rutiny zpětných volání OAuth |
| `src/lib/` | Přístup k databázi ( `localDb.ts` , `usageDb.ts` ), ověřování, sdílení |
| `src/mitm/` | Nástroje proxy typu „man-in-the-middle“ pro zachycení provozu poskytovatelů |
| `src/models/` | Definice modelů databáze |
| `src/shared/` | Obálky kolem funkcí open-sse (provider, stream, error atd.) |
| `src/sse/` | Obslužné rutiny koncových bodů SSE, které propojují knihovnu open-sse s trasami Express |
| `src/store/` | Správa stavu aplikací |
#### Významné trasy API
Trasa | Metody | Účel
--- | --- | ---
`/api/provider-models` | ZÍSKAT/ODESLAT/SMAZAT | CRUD pro vlastní modely na poskytovatele
`/api/models/catalog` | ZÍSKAT | Agregovaný katalog všech modelů (chat, embedding, image, custom) seskupených podle poskytovatele
`/api/settings/proxy` | ZÍSKAT/VLOŽIT/ODSTRANIT | Konfigurace hierarchické odchozí proxy ( `global/providers/combos/keys` )
`/api/settings/proxy/test` | ZVEŘEJNIT | Ověřuje připojení proxy a vrací veřejnou IP adresu/latenci
`/v1/providers/[provider]/chat/completions` | ZVEŘEJNIT | Vyhrazené dokončování chatu pro jednotlivé poskytovatele s ověřováním modelu
`/v1/providers/[provider]/embeddings` | ZVEŘEJNIT | Vyhrazené vkládání pro jednotlivé poskytovatele s ověřováním modelu
`/v1/providers/[provider]/images/generations` | ZVEŘEJNIT | Vyhrazené generování obrázků pro každého poskytovatele s ověřováním modelu
`/api/settings/ip-filter` | ZÍSKAT/VLOŽIT | Správa povolených/blokovaných IP adres
`/api/settings/thinking-budget` | ZÍSKAT/VLOŽIT | Konfigurace rozpočtu tokenů zdůvodnění (průchozí/automatická/vlastní/adaptivní)
`/api/settings/system-prompt` | ZÍSKAT/VLOŽIT | Globální vložení systémového promptu pro všechny požadavky
`/api/sessions` | ZÍSKAT | Sledování a metriky aktivních relací
`/api/rate-limits` | ZÍSKAT | Stav limitu sazby na účet
| Trasa | Metody | Účel |
| --------------------------------------------- | --------------- | ------------------------------------------------------------------------------------------------ |
| `/api/provider-models` | GET/POST/DELETE | CRUD pro vlastní modely na poskytovatele |
| `/api/models/catalog` | GET | Agregovaný katalog všech modelů (chat, embedding, image, custom) seskupených podle poskytovatele |
| `/api/settings/proxy` | GET/PUT/DELETE | Konfigurace hierarchické odchozí proxy ( `global/providers/combos/keys` ) |
| `/api/settings/proxy/test` | POST | Ověřuje připojení proxy a vrací veřejnou IP adresu/latenci |
| `/v1/providers/[provider]/chat/completions` | POST | Vyhrazené dokončování chatu pro jednotlivé poskytovatele s ověřováním modelu |
| `/v1/providers/[provider]/embeddings` | POST | Vyhrazené vkládání pro jednotlivé poskytovatele s ověřováním modelu |
| `/v1/providers/[provider]/images/generations` | POST | Vyhrazené generování obrázků pro každého poskytovatele s ověřováním modelu |
| `/api/settings/ip-filter` | GET/PUT | Správa povolených/blokovaných IP adres |
| `/api/settings/thinking-budget` | GET/PUT | Konfigurace rozpočtu tokenů zdůvodnění (průchozí/automatická/vlastní/adaptivní) |
| `/api/settings/system-prompt` | GET/PUT | Globální vložení systémového promptu pro všechny požadavky |
| `/api/sessions` | GET | Sledování a metriky aktivních relací |
| `/api/rate-limits` | GET | Stav limitu sazby na účet |
---
@@ -512,38 +512,38 @@ K hlášenému využití je přidána vyrovnávací paměť o kapacitě 2000 tok
## 6. Podporované formáty
Formát | Směr | Identifikátor
--- | --- | ---
Dokončení chatu OpenAI | zdroj + cíl | `openai`
API pro odpovědi OpenAI | zdroj + cíl | `openai-responses`
Antropic Claude | zdroj + cíl | `claude`
Google Gemini | zdroj + cíl | `gemini`
Rozhraní příkazového řádku Google Gemini | pouze cíl | `gemini-cli`
Antigravitace | zdroj + cíl | `antigravity`
AWS Kiro | pouze cíl | `kiro`
Kurzor | pouze cíl | `cursor`
| Formát | Směr | Identifikátor |
| ----------------------- | ----------- | ------------------ |
| OpenAI Chat Completions | zdroj + cíl | `openai` |
| OpenAI Responses API | zdroj + cíl | `openai-responses` |
| Anthropic Claude | zdroj + cíl | `claude` |
| Google Gemini | zdroj + cíl | `gemini` |
| Google Gemini CLI | jen cíl | `gemini-cli` |
| Antigravity | zdroj + cíl | `antigravity` |
| AWS Kiro | jen cíl | `kiro` |
| Cursor | jen cíl | `cursor` |
---
## 7. Podporovaní poskytovatelé
Poskytovatel | Metoda ověřování | Vykonavatel | Klíčové poznámky
--- | --- | --- | ---
Antropic Claude | Klíč API nebo OAuth | Výchozí | Používá hlavičku `x-api-key`
Google Gemini | Klíč API nebo OAuth | Výchozí | Používá hlavičku `x-goog-api-key`
Rozhraní příkazového řádku Google Gemini | OAuth | GeminiCLI | Používá koncový bod `streamGenerateContent`
Antigravitace | OAuth | Antigravitace | Záložní více URL adres, vlastní analýza opakovaných pokusů
OpenAI | Klíč API | Výchozí | Autorizace standardního nosiče
Kodex | OAuth | Kodex | Vkládá systémové instrukce, řídí myšlení
GitHub Copilot | OAuth + token Copilot | Github | Duální token, napodobování záhlaví VSCode
Kiro (AWS) | AWS SSO OIDC nebo sociální sítě | Kiro | Analýza binárního EventStreamu
IDE kurzoru | Autorizace kontrolního součtu | Kurzor | Kódování Protobuf, kontrolní součty SHA-256
Qwen | OAuth | Výchozí | Standardní ověřování
iFlow | OAuth (základní + nosič) | Výchozí | Duální hlavička pro autorizaci
OpenRouter | Klíč API | Výchozí | Autorizace standardního nosiče
GLM, Kimi, MiniMax | Klíč API | Výchozí | Kompatibilní s Claude, použijte `x-api-key`
`openai-compatible-*` | Klíč API | Výchozí | Dynamické: jakýkoli koncový bod kompatibilní s OpenAI
`anthropic-compatible-*` | Klíč API | Výchozí | Dynamický: jakýkoli koncový bod kompatibilní s Claude
| Poskytovatel | Metoda ověřování | Vykonavatel | Klíčové poznámky |
| ------------------------ | ------------------------ | ----------- | -------------------------------------------- |
| Anthropic Claude | API klíč nebo OAuth | Výchozí | Používá hlavičku `x-api-key` |
| Google Gemini | API klíč nebo OAuth | Výchozí | Používá hlavičku `x-goog-api-key` |
| Google Gemini CLI | OAuth | GeminiCLI | Používá koncový bod `streamGenerateContent` |
| Antigravity | OAuth | Antigravity | Záložní více URL, analýza opakovaných pokusů |
| OpenAI | API klíč | Výchozí | Autorizace standardního nosiče |
| Codex | OAuth | Codex | Vkládá systémové instrukce, řídí myšlení |
| GitHub Copilot | OAuth + Copilot token | Github | Duální token, napodobování záhlaví VSCode |
| Kiro (AWS) | AWS SSO OIDC nebo Social | Kiro | Analýza binárního EventStreamu |
| Cursor IDE | Checksum auth | Cursor | Kódování Protobuf, kontrolní součty SHA-256 |
| Qwen | OAuth | Výchozí | Standardní ověřování |
| iFlow | OAuth (Basic + Bearer) | Výchozí | Duální hlavička pro autorizaci |
| OpenRouter | API klíč | Výchozí | Autorizace standardního nosiče |
| GLM, Kimi, MiniMax | API klíč | Výchozí | Kompatibilní s Claude, použijte `x-api-key` |
| `openai-compatible-*` | API klíč | Výchozí | Dynamické: jakýkoli OpenAI kompatibilní |
| `anthropic-compatible-*` | API klíč | Výchozí | Dynamické: jakýkoli Claude kompatibilní |
---

View File

@@ -58,9 +58,9 @@ _Připojte libovolný nástroj IDE nebo CLI s umělou inteligencí přes OmniRou
</tr>
<tr>
<td align="center" width="110"> <a href="https://github.com/anomalyco/opencode"><img src="./public/providers/opencode.svg" alt="OpenCode" width="48"><br><b>OpenCode</b></a><br> <sub>⭐ 106 tisíc</sub> </td>
<td align="center" width="110"> <a href="https://github.com/openai/codex"><img src="./public/providers/codex.png" alt="CLI Codexu" width="48"><br><b>CLI Codexu</b></a><br> <sub>⭐ 60,8 tisíc</sub> </td>
<td align="center" width="110"> <a href="https://github.com/openai/codex"><img src="./public/providers/codex.png" alt="Codex CLI" width="48"><br><b>Codex CLI</b></a><br> <sub>⭐ 60,8 tisíc</sub> </td>
<td align="center" width="110"> <a href="https://github.com/anthropics/claude-code"><img src="./public/providers/claude.png" alt="Claude Code" width="48"><br><b>Claude Code</b></a><br> <sub>⭐ 67,3 tisíc</sub> </td>
<td align="center" width="110"> <a href="https://github.com/google-gemini/gemini-cli"><img src="./public/providers/gemini-cli.png" alt="Rozhraní příkazového řádku Gemini" width="48"><br> <b>Rozhraní příkazového řádku Gemini</b></a><br> <sub>⭐ 94,7 tisíc</sub> </td>
<td align="center" width="110"> <a href="https://github.com/google-gemini/gemini-cli"><img src="./public/providers/gemini-cli.png" alt="Gemini CLI" width="48"><br> <b>Gemini CLI</b></a><br> <sub>⭐ 94,7 tisíc</sub> </td>
<td align="center" width="110"> <a href="https://github.com/Kilo-Org/kilocode"><img src="./public/providers/kilocode.png" alt="Kilo kód" width="48"><br><b>Kilo kód</b></a><br> <sub>⭐ 15,5 tisíc</sub> </td>
</tr>
</table>
@@ -799,29 +799,29 @@ Po minimalizaci se OmniRoute nachází v systémové liště a nabízí rychlé
## 💰 Přehled cen
| Úroveň | Poskytovatel | Náklady | Obnovení kvóty | Nejlepší pro |
| --------------------------------- | -------------------------------- | ------------------------------------ | ------------------------------------------ | --------------------------------------------------------- |
| **💳 PŘEDPLATNÉ** | Claude Code (profesionál) | 20 dolarů měsíčně | 5 hodin + týdně | Již přihlášen/a k odběru |
| Kodex (Plus/Pro) | 20200 USD/měsíc | 5 hodin + týdně | Uživatelé OpenAI |
| Rozhraní příkazového řádku Gemini | **UVOLNIT** | 180 tisíc měsíčně + 1 tisíc denně | Každý! |
| GitHub Copilot | 1019 USD/měsíc | Měsíční | Uživatelé GitHubu |
| **🔑 KLÍČ API** | NVIDIA NIM | **ZDARMA** (vývoj navždy) | ~40 ot./min | 70+ otevřených modelů |
| Mozky | **ZDARMA** (1 milion tok/den) | 60 000 otáček za minutu / 30 ot./min | Nejrychlejší na světě |
| Groq | **ZDARMA** (30 ot./min.) | 14,4 tisíc otáček za minutu | Ultrarychlá lama/gema |
| DeepSeek V3.2 | 0,27/1,10 USD za 1 milion | Žádný | Nejlepší zdůvodnění ceny a kvality |
| xAI Grok-4 Rychlý | **0,20/0,50 USD za 1 milion** 🆕 | Žádný | Nejrychlejší + volání nástroje, ultranízké |
| xAI Grok-4 (standardní) | 0,20/1,50 USD za 1 milion 🆕 | Žádný | Vlajková loď Reasoning od xAI |
| Mistral | Zkušební verze zdarma + placené | Omezená sazba | Evropská umělá inteligence |
| OpenRouter | Platba za použití | Žádný | Více než 100 modelů agregováno. |
| **💰 LEVNÉ** | GLM-5 (přes Z.AI) 🆕 | 0,5 USD/1 milion | Denně v 10:00 | Výstup 128 tisíc obrazových bodů, nejnovější vlajková loď |
| GLM-4.7 | 0,6 USD/1 milion | Denně v 10:00 | Záloha rozpočtu |
| MiniMax M2.5 🆕 | Vstup 0,3 USD/1 milion | 5hodinové válcování | Úvaha + agentní úkoly |
| MiniMax M2.1 | 0,2 USD/1 milion | 5hodinové válcování | Nejlevnější varianta |
| Kimi K2.5 (Moonshot API) 🆕 | Platba za použití | Žádný | Přímý přístup k Moonshot API |
| Kimi K2 | 9 dolarů měsíčně bez závazků | 10 milionů tokenů/měsíc | Předvídatelné náklady |
| **🆓 ZDARMA** | iFlow | **0 dolarů** | Neomezený | 5 modelů neomezeně |
| Qwen | **0 dolarů** | Neomezený | 4 modely neomezeně |
| Kiro | **0 dolarů** | Neomezený | Claude Sonnet/Haiku (tvorce AWS) |
| Úroveň | Poskytovatel | Náklady | Obnovení kvóty | Nejlepší pro |
| --------------------------- | -------------------------------- | ------------------------------------ | ------------------------------------------ | --------------------------------------------------------- |
| **💳 PŘEDPLATNÉ** | Claude Code (profesionál) | 20 dolarů měsíčně | 5 hodin + týdně | Již přihlášen/a k odběru |
| Kodex (Plus/Pro) | 20200 USD/měsíc | 5 hodin + týdně | Uživatelé OpenAI |
| Gemini CLI | **UVOLNIT** | 180 tisíc měsíčně + 1 tisíc denně | Každý! |
| GitHub Copilot | 1019 USD/měsíc | Měsíční | Uživatelé GitHubu |
| **🔑 KLÍČ API** | NVIDIA NIM | **ZDARMA** (vývoj navždy) | ~40 ot./min | 70+ otevřených modelů |
| Mozky | **ZDARMA** (1 milion tok/den) | 60 000 otáček za minutu / 30 ot./min | Nejrychlejší na světě |
| Groq | **ZDARMA** (30 ot./min.) | 14,4 tisíc otáček za minutu | Ultrarychlá lama/gema |
| DeepSeek V3.2 | 0,27/1,10 USD za 1 milion | Žádný | Nejlepší zdůvodnění ceny a kvality |
| xAI Grok-4 Rychlý | **0,20/0,50 USD za 1 milion** 🆕 | Žádný | Nejrychlejší + volání nástroje, ultranízké |
| xAI Grok-4 (standardní) | 0,20/1,50 USD za 1 milion 🆕 | Žádný | Vlajková loď Reasoning od xAI |
| Mistral | Zkušební verze zdarma + placené | Omezená sazba | Evropská umělá inteligence |
| OpenRouter | Platba za použití | Žádný | Více než 100 modelů agregováno. |
| **💰 LEVNÉ** | GLM-5 (přes Z.AI) 🆕 | 0,5 USD/1 milion | Denně v 10:00 | Výstup 128 tisíc obrazových bodů, nejnovější vlajková loď |
| GLM-4.7 | 0,6 USD/1 milion | Denně v 10:00 | Záloha rozpočtu |
| MiniMax M2.5 🆕 | Vstup 0,3 USD/1 milion | 5hodinové válcování | Úvaha + agentní úkoly |
| MiniMax M2.1 | 0,2 USD/1 milion | 5hodinové válcování | Nejlevnější varianta |
| Kimi K2.5 (Moonshot API) 🆕 | Platba za použití | Žádný | Přímý přístup k Moonshot API |
| Kimi K2 | 9 dolarů měsíčně bez závazků | 10 milionů tokenů/měsíc | Předvídatelné náklady |
| **🆓 ZDARMA** | iFlow | **0 dolarů** | Neomezený | 5 modelů neomezeně |
| Qwen | **0 dolarů** | Neomezený | 4 modely neomezeně |
| Kiro | **0 dolarů** | Neomezený | Claude Sonnet/Haiku (tvorce AWS) |
> 🆕 **Přidány nové modely (březen 2026):** řada Grok-4 Fast za 0,20 USD/0,50 USD/M (benchmarkováno na 1143 ms o 30 % rychlejší než Gemini 2.5 Flash), GLM-5 přes Z.AI s výstupem 128K, uvažování MiniMax M2.5, aktualizované ceny DeepSeek V3.2, Kimi K2.5 přes Moonshot Direct API.
@@ -1439,7 +1439,7 @@ Settings → Models → Advanced:
Pro konfiguraci jedním kliknutím použijte stránku **Nástroje CLI** na řídicím panelu nebo ručně upravte soubor `~/.claude/settings.json` .
### CLI Codexu
### Codex CLI
```bash
export OPENAI_BASE_URL="http://localhost:20128"
@@ -1676,94 +1676,17 @@ Pokud si teď nechcete nastavovat vlastní přihlašovací údaje, můžete stá
---
<details>
<summary><b>🇧🇷 Versão em Português</b></summary>
</details>
#### Dočasné řešení (bez vlastních přihlašovacích údajů)
#### Pokud používáte OAuth pro Antigravity / Gemini CLI?
Chcete-li získat přístup k přihlašovacím údajům bez vlastní konfigurace, můžete použít následující postup:
Ověřeno **Antigravity** a **Gemini CLI** pomocí **Google OAuth 2.0** pro autenticitu. O Google exige que a `redirect_uri` usada no fluxo OAuth seja **exatamente** uma das URIs pre-cadastradas no Google Cloud Console to use.
1. OmniRoute otevře URL autorizace Google
2. Po autorizaci se Google pokusí přesměrovat na `localhost` (což selže na vzdáleném serveru)
3. **Zkopírujte celou URL adresu** z adresního řádku prohlížeče
4. Vložte tuto URL adresu do pole zobrazeného v modálním okně připojení OmniRoute
5. Klikněte na **„Připojit"**
Jako credenciais OAuth embutidas no OmniRoute estão cadastradas **apenas para `localhost`** . Quando você acessa o OmniRoute em um servidor remote (ex: `https://omniroute.meuservidor.com` ), o Google rejeita a autenticação com:
```
Error 400: redirect_uri_mismatch
```
#### Řešení: Nakonfigurujte souas próprias credenciais OAuth
Você precisa criar um **OAuth 2.0 Client ID** no Google Cloud Console com a URI do seu server.
#### Přejít na přejezd
**1. Přístup ke službě Google Cloud Console**
Abra: [https://console.cloud.google.com/apis/credentials](https://console.cloud.google.com/apis/credentials)
**2. Crie um novo OAuth 2.0 Client ID**
- Klikněte na **„+ Vytvořit přihlašovací údaje“** → **„ID klienta OAuth“**
- Typ aplikace: **"Webová aplikace"**
- Název: escolha qualquer nome (např.: `OmniRoute Remote` )
**3. Adicione jako autorizované URI pro přesměrování**
Žádné pole **"URI autorizovaného přesměrování"** , adicione:
```
https://seu-servidor.com/callback
```
> Substitua `seu-servidor.com` pelo domínio ou IP do seu servidor (včetně portu se necessário, např.: `http://45.33.32.156:20128/callback` ).
**4. Uložte a kopii jako credenciais**
Após criar, o Google Mostrará o **Client ID** eo **Client Secret** .
**5. Nakonfigurujte jako variáveis de ambiente**
No seu `.env` (ou nas variáveis de ambiente do Docker):
```bash
# Para Antigravity:
ANTIGRAVITY_OAUTH_CLIENT_ID=seu-client-id.apps.googleusercontent.com
ANTIGRAVITY_OAUTH_CLIENT_SECRET=GOCSPX-seu-secret
# Para Gemini CLI:
GEMINI_OAUTH_CLIENT_ID=seu-client-id.apps.googleusercontent.com
GEMINI_OAUTH_CLIENT_SECRET=GOCSPX-seu-secret
GEMINI_CLI_OAUTH_CLIENT_SECRET=GOCSPX-seu-secret
```
**6. Reinicie o OmniRoute**
```bash
# Se usando npm:
npm run dev
# Se usando Docker:
docker restart omniroute
```
**7. Připojte se znovu**
Řídicí panel → Poskytovatelé → Antigravity (nebo Gemini CLI) → OAuth
Agora nebo Google redirecionará corretamente para `https://seu-servidor.com/callback` ea autenticação funcionará.
---
#### Řešení temporário (sem configurar credenciais próprias)
Chcete-li získat přístup k kriterii pověření, můžete použít adresu **URL** :
1. O OmniRoute abrirá a URL autorização Google
2. Após você autorizar, nebo Google tentará redirecionar para `localhost` (que falha no servidor remoto)
3. **Zkopírujte úplnou** adresu URL prohlížeče do svého prohlížeče (mesmo que a pagina não carregue)
4. Cole essa URL no campo que aparece no modal de conexão do OmniRoute
5. Klikněte na **„Připojit se“**
> Toto řešení funguje na základě autorizačního kódu na adrese URL a nezávislého přesměrování přesměrování nebo jiného.
> Toto řešení funguje, protože autorizační kód v URL adrese je platný bez ohledu na načtení přesměrovací stránky.
---

View File

@@ -20,30 +20,30 @@ Kompletní průvodce konfigurací poskytovatelů, vytvářením kombinací, inte
## 💰 Přehled cen
Úroveň | Poskytovatel | Náklady | Obnovení kvóty | Nejlepší pro
--- | --- | --- | --- | ---
**💳 PŘEDPLATNÉ** | Claude Code (profesionál) | 20 dolarů měsíčně | 5 hodin + týdně | Již přihlášen/a k odběru
| Kodex (Plus/Pro) | 20200 USD/měsíc | 5 hodin + týdně | Uživatelé OpenAI
| Rozhraní příkazového řádku Gemini | **UVOLNIT** | 180 tisíc měsíčně + 1 tisíc denně | Každý!
| GitHub Copilot | 1019 USD/měsíc | Měsíční | Uživatelé GitHubu
**🔑 KLÍČ API** | Hluboké vyhledávání | Platba za použití | Žádný | Laciné uvažování
| Groq | Platba za použití | Žádný | Ultrarychlá inference
| xAI (Grok) | Platba za použití | Žádný | Grok 4 uvažování
| Mistral | Platba za použití | Žádný | Modely hostované v EU
| Zmatek | Platba za použití | Žádný | Rozšířené vyhledávání
| Společně s umělou inteligencí | Platba za použití | Žádný | Modely s otevřeným zdrojovým kódem
| Ohňostroj s umělou inteligencí | Platba za použití | Žádný | Rychlé snímky FLUX
| Mozky | Platba za použití | Žádný | Rychlost v měřítku destičky
| Soudržný | Platba za použití | Žádný | Příkaz R+ RAG
| NVIDIA NIM | Platba za použití | Žádný | Podnikové modely
**💰 LEVNÉ** | GLM-4.7 | 0,6 USD/1 milion | Denně v 10:00 | Záloha rozpočtu
| MiniMax M2.1 | 0,2 USD/1 milion | 5hodinové válcování | Nejlevnější varianta
| Kimi K2 | 9 dolarů měsíčně bez závazků | 10 milionů tokenů/měsíc | Předvídatelné náklady
**🆓 ZDARMA** | iFlow | 0 dolarů | Neomezený | 8 modelů zdarma
| Qwen | 0 dolarů | Neomezený | 3 modely zdarma
| Kiro | 0 dolarů | Neomezený | Claude zdarma
| Úroveň | Poskytovatel | Náklady | Obnovení kvóty | Nejlepší pro |
| ----------------- | ----------------- | ---------------- | ------------------- | -------------------------- |
| **💳 PŘEDPLATNÉ** | Claude Code (pro) | 20 USD měsíc | 5h + týdně | Již přihlášené |
| | Kodex (Plus/Pro) | 20200 USD/měsíc | 5h + týdně | Uživatele OpenAI |
| | Gemini CLI | **ZDARMA** | 180K/mo + 1K/den | Každého! |
| | GitHub Copilot | 1019 USD/měsíc | Měsíční | Uživatele GitHubu |
| **🔑 KLÍČ API** | DeepSeek | Dle užití | Žádné | Laciné uvažování |
| | Groq | Dle užití | Žádné | Ultrarychlá inference |
| | xAI (Grok) | Dle užití | Žádné | Grok 4 uvažování |
| | Mistral | Dle užití | Žádné | Modely hostované v EU |
| | Perplexity | Dle užití | Žádné | Rozšířené vyhledávání |
| | Together AI | Dle užití | Žádné | Open Source modely |
| | Fireworks AI | Dle užití | Žádné | Rychlé FLUX obrázky |
| | Cerebras | Dle užití | Žádné | Rychlost destičkového čipu |
| | Cohere | Dle užití | Žádné | Command R+ RAG |
| | NVIDIA NIM | Dle užití | Žádné | Podnikové modely |
| **💰 LEVNÉ** | GLM-4.7 | $0.6/1M | Denně 10:00 | Levná záloha |
| | MiniMax M2.1 | $0.2/1M | 5hodinové válcování | Nejlevnější varianta |
| | Kimi K2 | 9 USD měsíc | 10M tokens/měsíc | Předvídatelné náklady |
| **🆓 ZDARMA** | iFlow | $0 | Neomezený | 8 modelů zdarma |
| | Qwen | $0 | Neomezený | 3 modely zdarma |
| | Kiro | $0 | Neomezený | Claude zdarma |
**💡 Tip pro profesionály:** Začněte s kombinací Gemini CLI (180 tisíc zdarma/měsíc) + iFlow (neomezeně zdarma) = 0 dolarů!
**💡 Pro Tip:** Začněte s kombinací Gemini CLI (180K zdarma/měsíc) + iFlow (neomezeně zdarma) = $0!
---
@@ -271,7 +271,7 @@ Upravit `~/.claude/config.json` :
}
```
### CLI Codexu
### Codex CLI
```bash
export OPENAI_BASE_URL="http://localhost:20128"
@@ -335,7 +335,7 @@ omniroute
omniroute --port 3000
```
Rozhraní příkazového řádku automaticky načte `.env` z adresáře `~/.omniroute/.env` nebo `./.env` .
CLI automaticky načte `.env` z adresáře `~/.omniroute/.env` nebo `./.env` .
### Nasazení VPS
@@ -407,23 +407,23 @@ Informace o režimu integrovaném s hostitelem s binárními soubory CLI nalezne
### Proměnné prostředí
Proměnná | Výchozí | Popis
--- | --- | ---
`JWT_SECRET` | `omniroute-default-secret-change-me` | Tajný klíč podpisu JWT ( **změna v produkčním prostředí** )
`INITIAL_PASSWORD` | `123456` | První přihlašovací heslo
`DATA_DIR` | `~/.omniroute` | Datový adresář (db, využití, protokoly)
`PORT` | výchozí nastavení rámce | Servisní port ( `20128` v příkladech)
`HOSTNAME` | výchozí nastavení rámce | Vázat hostitele (Docker má výchozí hodnotu `0.0.0.0` )
`NODE_ENV` | výchozí nastavení za běhu | Nastavení `production` pro nasazení
`BASE_URL` | `http://localhost:20128` | Interní základní URL na straně serveru
`CLOUD_URL` | `https://omniroute.dev` | Základní adresa URL koncového bodu synchronizace s cloudem
`API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | Tajný klíč HMAC pro generované klíče API
`REQUIRE_API_KEY` | `false` | Vynutit klíč rozhraní Bearer API na `/v1/*`
`ENABLE_REQUEST_LOGS` | `false` | Povoluje protokolování požadavků/odpovědí
`AUTH_COOKIE_SECURE` | `false` | Vynutit soubor cookie `Secure` ověřování (za reverzní proxy HTTPS)
`OMNIROUTE_MEMORY_MB` | `512` | Limit haldy Node.js v MB
`PROMPT_CACHE_MAX_SIZE` | `50` | Maximální počet položek mezipaměti výzev
`SEMANTIC_CACHE_MAX_SIZE` | `100` | Maximální počet položek sémantické mezipaměti
| Proměnná | Výchozí | Popis |
| ------------------------- | ------------------------------------ | ------------------------------------------------------------------ |
| `JWT_SECRET` | `omniroute-default-secret-change-me` | Tajný klíč podpisu JWT ( **změna v produkčním prostředí** ) |
| `INITIAL_PASSWORD` | `123456` | První přihlašovací heslo |
| `DATA_DIR` | `~/.omniroute` | Datový adresář (db, využití, protokoly) |
| `PORT` | výchozí nastavení rámce | Servisní port ( `20128` v příkladech) |
| `HOSTNAME` | výchozí nastavení rámce | Vázat hostitele (Docker má výchozí hodnotu `0.0.0.0` ) |
| `NODE_ENV` | výchozí nastavení za běhu | Nastavení `production` pro nasazení |
| `BASE_URL` | `http://localhost:20128` | Interní základní URL na straně serveru |
| `CLOUD_URL` | `https://omniroute.dev` | Základní adresa URL koncového bodu synchronizace s cloudem |
| `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | Tajný klíč HMAC pro generované klíče API |
| `REQUIRE_API_KEY` | `false` | Vynutit klíč rozhraní Bearer API na `/v1/*` |
| `ENABLE_REQUEST_LOGS` | `false` | Povoluje protokolování požadavků/odpovědí |
| `AUTH_COOKIE_SECURE` | `false` | Vynutit soubor cookie `Secure` ověřování (za reverzní proxy HTTPS) |
| `OMNIROUTE_MEMORY_MB` | `512` | Limit haldy Node.js v MB |
| `PROMPT_CACHE_MAX_SIZE` | `50` | Maximální počet položek mezipaměti výzev |
| `SEMANTIC_CACHE_MAX_SIZE` | `100` | Maximální počet položek sémantické mezipaměti |
Úplný přehled proměnných prostředí naleznete v souboru [README](../README.md) .
@@ -439,7 +439,7 @@ Proměnná | Výchozí | Popis
**Codex ( `cx/` )** — Plus/Pro: `cx/gpt-5.2-codex` , `cx/gpt-5.1-codex-max`
**Rozhraní příkazového řádku Gemini ( `gc/` )** — ZDARMA: `gc/gemini-3-flash-preview` , `gc/gemini-2.5-pro`
**Gemini CLI ( `gc/` )** — ZDARMA: `gc/gemini-3-flash-preview` , `gc/gemini-2.5-pro`
**GitHub Copilot ( `gh/` )** : `gh/gpt-5` , `gh/claude-4.5-sonnet`
@@ -473,9 +473,6 @@ Proměnná | Výchozí | Popis
**NVIDIA NIM ( `nvidia/` )** : `nvidia/nvidia/llama-3.3-70b-instruct`
---
## 🧩 Pokročilé funkce
@@ -552,12 +549,12 @@ Vrátí modely seskupené podle poskytovatele s typy ( `chat` , `embedding` , `i
Přístup přes **Dashboard → Translator** . Ladění a vizualizace toho, jak OmniRoute překládá požadavky API mezi poskytovateli.
Režim | Účel
--- | ---
**Dětské hřiště** | Vyberte zdrojový/cílový formát, vložte požadavek a okamžitě si prohlédněte přeložený výstup
**Tester chatu** | Odesílejte zprávy živého chatu přes proxy a kontrolujte celý cyklus požadavku/odpovědi
**Zkušební stolice** | Spusťte dávkové testy napříč různými kombinacemi formátů pro ověření správnosti překladu
**Živý monitor** | Sledujte překlady v reálném čase, jak požadavky procházejí proxy serverem
| Režim | Účel |
| -------------------- | ------------------------------------------------------------------------------------------- |
| **Dětské hřiště** | Vyberte zdrojový/cílový formát, vložte požadavek a okamžitě si prohlédněte přeložený výstup |
| **Tester chatu** | Odesílejte zprávy živého chatu přes proxy a kontrolujte celý cyklus požadavku/odpovědi |
| **Zkušební stolice** | Spusťte dávkové testy napříč různými kombinacemi formátů pro ověření správnosti překladu |
| **Živý monitor** | Sledujte překlady v reálném čase, jak požadavky procházejí proxy serverem |
**Případy použití:**
@@ -571,14 +568,14 @@ Režim | Účel
Konfigurace přes **Dashboard → Nastavení → Routing** .
Strategie | Popis
--- | ---
**Nejprve vyplňte** | Používá účty podle priority primární účet zpracovává všechny požadavky, dokud není k dispozici.
**Round Robin** | Cykluje mezi všemi účty s nastavitelným trvalým limitem (výchozí: 3 volání na účet)
**P2C (Síla dvou možností)** | Vybere 2 náhodné účty a nasměruje je k tomu zdravějšímu vyvažuje zátěž s povědomím o zdraví
**Náhodný** | Náhodně vybere účet pro každý požadavek pomocí Fisher-Yatesova náhodného výběru.
**Nejméně používané** | Směruje k účtu s nejstarším časovým razítkem `lastUsedAt` a rovnoměrně rozděluje provoz.
**Optimalizované náklady** | Směruje k účtu s nejnižší prioritou a optimalizuje pro poskytovatele s nejnižšími náklady.
| Strategie | Popis |
| ---------------------------- | ------------------------------------------------------------------------------------------------- |
| **Nejprve vyplňte** | Používá účty podle priority primární účet zpracovává všechny požadavky, dokud není k dispozici. |
| **Round Robin** | Cykluje mezi všemi účty s nastavitelným trvalým limitem (výchozí: 3 volání na účet) |
| **P2C (Síla dvou možností)** | Vybere 2 náhodné účty a nasměruje je k tomu zdravějšímu vyvažuje zátěž s povědomím o zdraví |
| **Náhodný** | Náhodně vybere účet pro každý požadavek pomocí Fisher-Yatesova náhodného výběru. |
| **Nejméně používané** | Směruje k účtu s nejstarším časovým razítkem `lastUsedAt` a rovnoměrně rozděluje provoz. |
| **Optimalizované náklady** | Směruje k účtu s nejnižší prioritou a optimalizuje pro poskytovatele s nejnižšími náklady. |
#### Aliasy zástupných znaků modelů
@@ -611,24 +608,21 @@ Konfigurace přes **Dashboard → Settings → Resilience** .
OmniRoute implementuje odolnost na úrovni poskytovatele se čtyřmi komponentami:
1. **Profily poskytovatelů** Konfigurace pro jednotlivé poskytovatele pro:
- Práh selhání (počet selhání před otevřením)
- Doba zchlazení
- Citlivost detekce limitu frekvence
- Exponenciální backoff parametry
- Práh selhání (počet selhání před otevřením)
- Doba zchlazení
- Citlivost detekce limitu frekvence
- Exponenciální backoff parametry
2. **Upravitelné limity rychlosti** Výchozí nastavení na úrovni systému konfigurovatelná na řídicím panelu:
- **Požadavky za minutu (RPM)** — Maximální počet požadavků za minutu na účet
- **Minimální doba mezi požadavky** — Minimální mezera v milisekundách mezi požadavky
- **Max. počet souběžných požadavků** — Maximální počet souběžných požadavků na účet
- Klikněte na **Upravit** pro úpravu a poté **na Uložit** nebo **Zrušit** . Hodnoty se ukládají prostřednictvím rozhraní API pro odolnost.
- **Požadavky za minutu (RPM)** — Maximální počet požadavků za minutu na účet
- **Minimální doba mezi požadavky** — Minimální mezera v milisekundách mezi požadavky
- **Max. počet souběžných požadavků** — Maximální počet souběžných požadavků na účet
- Klikněte na **Upravit** pro úpravu a poté **na Uložit** nebo **Zrušit** . Hodnoty se ukládají prostřednictvím rozhraní API pro odolnost.
3. **Jistič** Sleduje poruchy u jednotlivých poskytovatelů a automaticky rozpojuje obvod, když je dosaženo prahové hodnoty:
- **ZAVŘENO** (v pořádku) Požadavky probíhají normálně.
- **OTEVŘENO** — Poskytovatel je dočasně zablokován po opakovaných selháních
- **HALF_OPEN** — Testování, zda se poskytovatel zotavil
- **ZAVŘENO** (v pořádku) Požadavky probíhají normálně.
- **OTEVŘENO** — Poskytovatel je dočasně zablokován po opakovaných selháních
- **HALF_OPEN** — Testování, zda se poskytovatel zotavil
4. **Zásady a uzamčené identifikátory** Zobrazuje stav jističe a uzamčené identifikátory s možností vynuceného odemčení.
@@ -642,11 +636,11 @@ OmniRoute implementuje odolnost na úrovni poskytovatele se čtyřmi komponentam
Správa záloh databáze se provádí v **nabídce Ovládací panel → Nastavení → Systém a úložiště** .
Akce | Popis
--- | ---
**Exportovat databázi** | Stáhne aktuální databázi SQLite jako soubor `.sqlite`
**Exportovat vše (.tar.gz)** | Stáhne kompletní zálohu včetně: databáze, nastavení, kombinací, připojení k poskytovatelům (bez přihlašovacích údajů) a metadat klíče API.
**Importovat databázi** | Nahrajte soubor `.sqlite` , který nahradí aktuální databázi. Záloha před importem se vytvoří automaticky.
| Akce | Popis |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| **Exportovat databázi** | Stáhne aktuální databázi SQLite jako soubor `.sqlite` |
| **Exportovat vše (.tar.gz)** | Stáhne kompletní zálohu včetně: databáze, nastavení, kombinací, připojení k poskytovatelům (bez přihlašovacích údajů) a metadat klíče API. |
| **Importovat databázi** | Nahrajte soubor `.sqlite` , který nahradí aktuální databázi. Záloha před importem se vytvoří automaticky. |
```bash
# API: Export database
@@ -674,13 +668,13 @@ curl -X POST http://localhost:20128/api/db-backups/import \
Stránka nastavení je pro snadnou navigaci uspořádána do 5 záložek:
Záložka | Obsah
--- | ---
**Zabezpečení** | Nastavení přihlášení/hesla, řízení přístupu k IP adrese, autorizace API pro `/models` a blokování poskytovatelů
**Směrování** | Globální strategie směrování (6 možností), aliasy zástupných znaků, záložní řetězce, kombinované výchozí hodnoty
**Odolnost** | Profily poskytovatelů, upravitelné limity sazeb, stav jističů, zásady a uzamčené identifikátory
**Umělá inteligence** | Konfigurace rozpočtu promyšleného projektu, globální vkládání promptu do systému, statistiky mezipaměti promptu
**Moderní** | Globální konfigurace proxy (HTTP/SOCKS5)
| Záložka | Obsah |
| --------------------- | ---------------------------------------------------------------------------------------------------------------- |
| **Zabezpečení** | Nastavení přihlášení/hesla, řízení přístupu k IP adrese, autorizace API pro `/models` a blokování poskytovatelů |
| **Směrování** | Globální strategie směrování (6 možností), aliasy zástupných znaků, záložní řetězce, kombinované výchozí hodnoty |
| **Odolnost** | Profily poskytovatelů, upravitelné limity sazeb, stav jističů, zásady a uzamčené identifikátory |
| **Umělá inteligence** | Konfigurace rozpočtu promyšleného projektu, globální vkládání promptu do systému, statistiky mezipaměti promptu |
| **Moderní** | Globální konfigurace proxy (HTTP/SOCKS5) |
---
@@ -688,10 +682,10 @@ Záložka | Obsah
Přístup přes **Dashboard → Náklady** .
Záložka | Účel
--- | ---
**Rozpočet** | Nastavte limity útrat pro každý klíč API s denními/týdenními/měsíčními rozpočty a sledováním v reálném čase
**Ceny** | Zobrazení a úprava cenových položek modelu cena za 1000 vstupních/výstupních tokenů na poskytovatele
| Záložka | Účel |
| ------------ | ----------------------------------------------------------------------------------------------------------- |
| **Rozpočet** | Nastavte limity útrat pro každý klíč API s denními/týdenními/měsíčními rozpočty a sledováním v reálném čase |
| **Ceny** | Zobrazení a úprava cenových položek modelu cena za 1000 vstupních/výstupních tokenů na poskytovatele |
```bash
# API: Set a budget
@@ -733,14 +727,14 @@ Podporované zvukové formáty: `mp3` , `wav` , `m4a` , `flac` , `ogg` , `webm`
Nastavte vyvažování jednotlivých kombinací v **nabídce Dashboard → Kombinace → Vytvořit/Upravit → Strategie** .
Strategie | Popis
--- | ---
**Round-Robin** | Postupně prochází modely
**Přednost** | Vždy se pokusí o první model; vrací se pouze v případě chyby.
**Náhodný** | Pro každý požadavek vybere náhodný model z komba
**Vážené** | Trasy proporcionálně na základě přiřazených vah pro každý model
**Nejméně používané** | Směruje k modelu s nejmenším počtem nedávných požadavků (používá kombinované metriky)
**Optimalizované z hlediska nákladů** | Trasy k nejlevnějšímu dostupnému modelu (používá ceník)
| Strategie | Popis |
| ------------------------------------- | ------------------------------------------------------------------------------------- |
| **Round-Robin** | Postupně prochází modely |
| **Přednost** | Vždy se pokusí o první model; vrací se pouze v případě chyby. |
| **Náhodný** | Pro každý požadavek vybere náhodný model z komba |
| **Vážené** | Trasy proporcionálně na základě přiřazených vah pro každý model |
| **Nejméně používané** | Směruje k modelu s nejmenším počtem nedávných požadavků (používá kombinované metriky) |
| **Optimalizované z hlediska nákladů** | Trasy k nejlevnějšímu dostupnému modelu (používá ceník) |
Globální výchozí hodnoty kombinací lze nastavit v **nabídce Dashboard → Settings → Routing → Combo Defaults** .
@@ -750,14 +744,14 @@ Globální výchozí hodnoty kombinací lze nastavit v **nabídce Dashboard →
Přístup přes **Dashboard → Stav** . Přehled stavu systému v reálném čase se 6 kartami:
Karta | Co to ukazuje
--- | ---
**Stav systému** | Doba provozuschopnosti, verze, využití paměti, datový adresář
**Zdraví poskytovatelů** | Stav jističe podle dodavatele (Zapnuto/Vypnuto/Napůl vypnuto)
**Limity sazeb** | Aktivní limit rychlosti cooldownů na účet se zbývajícím časem
**Aktivní výluky** | Poskytovatelé dočasně blokovaní politikou uzamčení
**Mezipaměť podpisů** | Statistiky mezipaměti pro deduplikaci (aktivní klíče, míra zásahů)
**Telemetrie latence** | Agregace latence p50/p95/p99 na poskytovatele
| Karta | Co to ukazuje |
| ------------------------ | ------------------------------------------------------------------ |
| **Stav systému** | Doba provozuschopnosti, verze, využití paměti, datový adresář |
| **Zdraví poskytovatelů** | Stav jističe podle dodavatele (Zapnuto/Vypnuto/Napůl vypnuto) |
| **Limity sazeb** | Aktivní limit rychlosti cooldownů na účet se zbývajícím časem |
| **Aktivní výluky** | Poskytovatelé dočasně blokovaní politikou uzamčení |
| **Mezipaměť podpisů** | Statistiky mezipaměti pro deduplikaci (aktivní klíče, míra zásahů) |
| **Telemetrie latence** | Agregace latence p50/p95/p99 na poskytovatele |
**Tip pro profesionály:** Stránka Zdraví se automaticky obnovuje každých 10 sekund. Pomocí karty jističe můžete zjistit, kteří poskytovatelé mají problémy.
@@ -795,20 +789,20 @@ Výstup → `electron/dist-electron/`
### Klíčové vlastnosti
Funkce | Popis
--- | ---
**Připravenost serveru** | Před zobrazením okna se dotazuje server (žádná prázdná obrazovka)
**Systémový zásobník** | Minimalizovat do zásobníku, změnit port, ukončit menu v zásobníku
**Správa přístavů** | Změna portu serveru z panelu úloh (automatické restartování serveru)
**Zásady zabezpečení obsahu** | Omezující CSP prostřednictvím záhlaví relace
**Jedna instance** | V daném okamžiku může běžet pouze jedna instance aplikace
**Offline režim** | Dodávaný server Next.js funguje bez internetu
| Funkce | Popis |
| ----------------------------- | -------------------------------------------------------------------- |
| **Připravenost serveru** | Před zobrazením okna se dotazuje server (žádná prázdná obrazovka) |
| **Systémový zásobník** | Minimalizovat do zásobníku, změnit port, ukončit menu v zásobníku |
| **Správa přístavů** | Změna portu serveru z panelu úloh (automatické restartování serveru) |
| **Zásady zabezpečení obsahu** | Omezující CSP prostřednictvím záhlaví relace |
| **Jedna instance** | V daném okamžiku může běžet pouze jedna instance aplikace |
| **Offline režim** | Dodávaný server Next.js funguje bez internetu |
### Proměnné prostředí
Proměnná | Výchozí | Popis
--- | --- | ---
`OMNIROUTE_PORT` | `20128` | Port serveru
`OMNIROUTE_MEMORY_MB` | `512` | Limit haldy Node.js (6416384 MB)
| Proměnná | Výchozí | Popis |
| --------------------- | ------- | --------------------------------- |
| `OMNIROUTE_PORT` | `20128` | Port serveru |
| `OMNIROUTE_MEMORY_MB` | `512` | Limit haldy Node.js (6416384 MB) |
📖 Úplná dokumentace: [`electron/README.md`](../electron/README.md)

View File

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

View File

@@ -29,6 +29,7 @@ const eslintConfig = [
ignores: [
// Next.js build output
".next/**",
"src/.next/**",
"out/**",
"build/**",
"next-env.d.ts",

View File

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

View File

@@ -16,6 +16,7 @@
import { readFileSync, existsSync } from "fs";
import { join } from "path";
import { resolveDataDir } from "../../src/lib/dataPaths";
// Fields that can be overridden per provider
const CREDENTIAL_FIELDS = ["clientId", "clientSecret", "tokenUrl", "authUrl", "refreshUrl"];
@@ -25,13 +26,18 @@ const CONFIG_TTL_MS = 60_000;
let lastLoadTime = 0;
let cachedProviders = null;
// Survives Next.js dev HMR: module-level cache resets but process is the same (V4 pattern).
type CredGlobals = typeof globalThis & { __omnirouteCredNoFileLogged?: boolean };
function credGlobals(): CredGlobals {
return globalThis as CredGlobals;
}
/**
* Resolve the path to provider-credentials.json
* Priority: DATA_DIR env → ./data (project root)
*/
function resolveCredentialsPath() {
const dataDir = process.env.DATA_DIR || join(process.cwd(), "data");
return join(dataDir, "provider-credentials.json");
return join(resolveDataDir(), "provider-credentials.json");
}
/**
@@ -51,8 +57,9 @@ export function loadProviderCredentials(providers) {
const credPath = resolveCredentialsPath();
if (!existsSync(credPath)) {
if (!cachedProviders) {
if (!credGlobals().__omnirouteCredNoFileLogged) {
console.log("[CREDENTIALS] No external credentials file found, using defaults.");
credGlobals().__omnirouteCredNoFileLogged = true;
}
cachedProviders = providers;
lastLoadTime = Date.now();
@@ -93,7 +100,11 @@ export function loadProviderCredentials(providers) {
`[CREDENTIALS] ${isReload ? "Reloaded" : "Loaded"} external credentials: ${overrideCount} field(s) from ${credPath}`
);
} catch (err) {
console.log(`[CREDENTIALS] Error reading credentials file: ${err.message}. Using defaults.`);
const reason =
err instanceof SyntaxError
? "Invalid JSON format"
: (err as NodeJS.ErrnoException).code || "read error";
console.log(`[CREDENTIALS] Error reading credentials file (${reason}). Using defaults.`);
}
cachedProviders = providers;

View File

@@ -6,6 +6,8 @@
* is auto-generated from this registry.
*/
import { platform, arch } from "os";
// ── Types ─────────────────────────────────────────────────────────────────
export interface RegistryModel {
@@ -14,6 +16,8 @@ export interface RegistryModel {
toolCalling?: boolean;
targetFormat?: string;
unsupportedParams?: readonly string[];
/** Maximum context window in tokens */
contextLength?: number;
}
// Reasoning models reject temperature, top_p, penalties, logprobs, n.
@@ -63,6 +67,8 @@ export interface RegistryEntry {
chatPath?: string;
clientVersion?: string;
passthroughModels?: boolean;
/** Default context window for all models in this provider (can be overridden per-model) */
defaultContextLength?: number;
}
interface LegacyProvider {
@@ -96,6 +102,32 @@ const KIMI_CODING_SHARED = {
] as RegistryModel[],
} as const;
function mapStainlessOs() {
switch (platform()) {
case "darwin":
return "MacOS";
case "win32":
return "Windows";
case "linux":
return "Linux";
default:
return `Other::${platform()}`;
}
}
function mapStainlessArch() {
switch (arch()) {
case "x64":
return "x64";
case "arm64":
return "arm64";
case "ia32":
return "x86";
default:
return `other::${arch()}`;
}
}
// ── Registry ──────────────────────────────────────────────────────────────
export const REGISTRY: Record<string, RegistryEntry> = {
@@ -109,22 +141,23 @@ export const REGISTRY: Record<string, RegistryEntry> = {
urlSuffix: "?beta=true",
authType: "oauth",
authHeader: "x-api-key",
defaultContextLength: 200000,
headers: {
"Anthropic-Version": "2023-06-01",
"Anthropic-Beta":
"claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14,context-management-2025-06-27",
"claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05",
"Anthropic-Dangerous-Direct-Browser-Access": "true",
"User-Agent": "claude-cli/1.0.83 (external, cli)",
"User-Agent": "claude-cli/2.1.63 (external, cli)",
"X-App": "cli",
"X-Stainless-Helper-Method": "stream",
"X-Stainless-Retry-Count": "0",
"X-Stainless-Runtime-Version": "v24.3.0",
"X-Stainless-Package-Version": "0.55.1",
"X-Stainless-Package-Version": "0.74.0",
"X-Stainless-Runtime": "node",
"X-Stainless-Lang": "js",
"X-Stainless-Arch": "arm64",
"X-Stainless-Os": "MacOS",
"X-Stainless-Timeout": "60",
"X-Stainless-Arch": mapStainlessArch(),
"X-Stainless-Os": mapStainlessOs(),
"X-Stainless-Timeout": "600",
},
oauth: {
clientIdEnv: "CLAUDE_OAUTH_CLIENT_ID",
@@ -152,6 +185,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
},
authType: "apikey",
authHeader: "x-goog-api-key",
defaultContextLength: 1000000,
oauth: {
clientIdEnv: "GEMINI_OAUTH_CLIENT_ID",
clientIdDefault: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com",
@@ -159,9 +193,13 @@ export const REGISTRY: Record<string, RegistryEntry> = {
clientSecretDefault: "",
},
models: [
{ id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro High" },
{ id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro Low" },
{ id: "gemini-3.1-pro", name: "Gemini 3.1 Pro" },
{ id: "gemini-3-1-pro", name: "Gemini 3.1 Pro (Alt ID)" },
{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" },
{ id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview" },
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" },
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
{ id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite" },
@@ -184,6 +222,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
},
authType: "oauth",
authHeader: "bearer",
defaultContextLength: 1000000,
oauth: {
clientIdEnv: "GEMINI_CLI_OAUTH_CLIENT_ID",
clientIdDefault: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com",
@@ -191,9 +230,13 @@ export const REGISTRY: Record<string, RegistryEntry> = {
clientSecretDefault: "",
},
models: [
{ id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro High" },
{ id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro Low" },
{ id: "gemini-3.1-pro", name: "Gemini 3.1 Pro" },
{ id: "gemini-3-1-pro", name: "Gemini 3.1 Pro (Alt ID)" },
{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" },
{ id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview" },
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" },
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
{ id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite" },
@@ -211,6 +254,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
baseUrl: "https://chatgpt.com/backend-api/codex/responses",
authType: "oauth",
authHeader: "bearer",
defaultContextLength: 400000,
headers: {
Version: "0.92.0",
"Openai-Beta": "responses=experimental",
@@ -322,7 +366,11 @@ export const REGISTRY: Record<string, RegistryEntry> = {
alias: "ag",
format: "antigravity",
executor: "antigravity",
baseUrls: ["https://daily-cloudcode-pa.googleapis.com", "https://cloudcode-pa.googleapis.com"],
baseUrls: [
"https://daily-cloudcode-pa.googleapis.com",
"https://daily-cloudcode-pa.sandbox.googleapis.com",
"https://cloudcode-pa.googleapis.com",
],
urlBuilder: (base, model, stream) => {
const path = stream
? "/v1internal:streamGenerateContent?alt=sse"
@@ -332,7 +380,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
authType: "oauth",
authHeader: "bearer",
headers: {
"User-Agent": "antigravity/1.104.0 darwin/arm64",
"User-Agent": `antigravity/1.107.0 ${platform()}/${arch()}`,
},
oauth: {
clientIdEnv: "ANTIGRAVITY_OAUTH_CLIENT_ID",
@@ -359,11 +407,12 @@ export const REGISTRY: Record<string, RegistryEntry> = {
responsesBaseUrl: "https://api.githubcopilot.com/responses",
authType: "oauth",
authHeader: "bearer",
defaultContextLength: 128000,
headers: {
"copilot-integration-id": "vscode-chat",
"editor-version": "vscode/1.107.1",
"editor-plugin-version": "copilot-chat/0.26.7",
"user-agent": "GitHubCopilotChat/0.26.7",
"editor-version": "vscode/1.110.0",
"editor-plugin-version": "copilot-chat/0.38.0",
"user-agent": "GitHubCopilotChat/0.38.0",
"openai-intent": "conversation-panel",
"x-github-api-version": "2025-04-01",
"x-vscode-user-agent-library-version": "electron-fetch",
@@ -407,6 +456,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
baseUrl: "https://codewhisperer.us-east-1.amazonaws.com/generateAssistantResponse",
authType: "oauth",
authHeader: "bearer",
defaultContextLength: 200000,
headers: {
"Content-Type": "application/json",
Accept: "application/vnd.amazon.eventstream",
@@ -433,6 +483,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
chatPath: "/aiserver.v1.ChatService/StreamUnifiedChatWithTools",
authType: "oauth",
authHeader: "bearer",
defaultContextLength: 200000,
headers: {
"connect-accept-encoding": "gzip",
"connect-protocol-version": "1",
@@ -461,6 +512,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
baseUrl: "https://api.openai.com/v1/chat/completions",
authType: "apikey",
authHeader: "bearer",
defaultContextLength: 128000,
models: [
{ id: "gpt-4o", name: "GPT-4o" },
{ id: "gpt-4o-mini", name: "GPT-4o Mini" },
@@ -482,6 +534,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
urlSuffix: "?beta=true",
authType: "apikey",
authHeader: "x-api-key",
defaultContextLength: 200000,
headers: {
"Anthropic-Version": "2023-06-01",
},
@@ -508,6 +561,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
authType: "apikey",
authHeader: "Authorization",
authPrefix: "Bearer",
defaultContextLength: 200000,
models: [
{ id: "glm-5", name: "GLM-5" },
{ id: "kimi-k2.5", name: "Kimi K2.5" },
@@ -525,6 +579,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
authType: "apikey",
authHeader: "Authorization",
authPrefix: "Bearer",
defaultContextLength: 200000,
models: [
{ id: "minimax-m2.5-free", name: "MiniMax M2.5 Free" },
{ id: "big-pickle", name: "Big Pickle" },
@@ -540,6 +595,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
baseUrl: "https://openrouter.ai/api/v1/chat/completions",
authType: "apikey",
authHeader: "bearer",
defaultContextLength: 128000,
headers: {
"HTTP-Referer": "https://endpoint-proxy.local",
"X-Title": "Endpoint Proxy",
@@ -553,6 +609,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
format: "claude",
executor: "default",
baseUrl: "https://api.z.ai/api/anthropic/v1/messages",
defaultContextLength: 200000,
urlSuffix: "?beta=true",
authType: "apikey",
authHeader: "x-api-key",
@@ -746,6 +803,10 @@ export const REGISTRY: Record<string, RegistryEntry> = {
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
},
models: [
// T12/T28: MiniMax default upgraded from M2.5 to M2.7
{ id: "minimax-m2.7", name: "MiniMax M2.7" },
{ id: "MiniMax-M2.7", name: "MiniMax M2.7 (Legacy Alias)" },
{ id: "minimax-m2.7-highspeed", name: "MiniMax M2.7 Highspeed" },
{ id: "minimax-m2.5", name: "MiniMax M2.5" },
{ id: "MiniMax-M2.5", name: "MiniMax M2.5 (Legacy Alias)" },
{ id: "MiniMax-M2.1", name: "MiniMax M2.1" },
@@ -767,6 +828,9 @@ export const REGISTRY: Record<string, RegistryEntry> = {
},
models: [
// Keep parity with minimax to ensure model discovery works for minimax-cn connections.
{ id: "minimax-m2.7", name: "MiniMax M2.7" },
{ id: "MiniMax-M2.7", name: "MiniMax M2.7 (Legacy Alias)" },
{ id: "minimax-m2.7-highspeed", name: "MiniMax M2.7 Highspeed" },
{ id: "minimax-m2.5", name: "MiniMax M2.5" },
{ id: "MiniMax-M2.5", name: "MiniMax M2.5 (Legacy Alias)" },
{ id: "MiniMax-M2.1", name: "MiniMax M2.1" },
@@ -1146,7 +1210,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
alias: "vertex",
// Vertex AI uses Google's generateContent format (same as Gemini)
format: "gemini",
executor: "default",
executor: "vertex",
// URL uses {project_id} and {region} from providerSpecificData — handled by custom executor or fallback
// Default to us-central1 / generic endpoint; users configure project via providerSpecificData
baseUrl: "https://us-central1-aiplatform.googleapis.com/v1/projects",
@@ -1160,10 +1224,16 @@ export const REGISTRY: Record<string, RegistryEntry> = {
authType: "apikey",
authHeader: "bearer",
models: [
{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview (Vertex)" },
{ id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview (Vertex)" },
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview (Vertex)" },
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro (Vertex)" },
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash (Vertex)" },
{ id: "gemini-2.0-flash-thinking-exp", name: "Gemini 2.0 Flash Thinking Exp (Vertex)" },
{ id: "gemma-2-27b-it", name: "Gemma 2 27B (Vertex)" },
{ id: "deepseek-v3.2", name: "DeepSeek V3.2 (Vertex Partner)" },
{ id: "qwen3-next-80b", name: "Qwen3 Next 80B (Vertex Partner)" },
{ id: "glm-5", name: "GLM-5 (Vertex Partner)" },
{ id: "claude-opus-4-5@20251101", name: "Claude Opus 4.5 (Vertex)" },
{ id: "claude-sonnet-4-5@20251101", name: "Claude Sonnet 4.5 (Vertex)" },
],
@@ -1300,7 +1370,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
{ 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
passthroughModels: true, // 500+ models available — users can type arbitrary Puter model IDs
},
"cloudflare-ai": {

View File

@@ -1,5 +1,5 @@
import crypto from "crypto";
import { BaseExecutor } from "./base.ts";
import { BaseExecutor, mergeUpstreamExtraHeaders } from "./base.ts";
import { PROVIDERS, OAUTH_ENDPOINTS, HTTP_STATUS } from "../config/constants.ts";
const MAX_RETRY_AFTER_MS = 10000;
@@ -198,7 +198,7 @@ export class AntigravityExecutor extends BaseExecutor {
return totalMs > 0 ? totalMs : null;
}
async execute({ model, body, stream, credentials, signal, log }) {
async execute({ model, body, stream, credentials, signal, log, upstreamExtraHeaders }) {
const fallbackCount = this.getFallbackCount();
let lastError = null;
let lastStatus = 0;
@@ -208,6 +208,7 @@ export class AntigravityExecutor extends BaseExecutor {
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
const url = this.buildUrl(model, stream, urlIndex);
const headers = this.buildHeaders(credentials, stream);
mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders);
const transformedBody = this.transformRequest(model, body, stream, credentials);
// Initialize retry counter for this URL

View File

@@ -58,8 +58,23 @@ export type ExecuteInput = {
signal?: AbortSignal | null;
log?: ExecutorLog | null;
extendedContext?: boolean;
/** Merged after auth + CLI fingerprint headers (values override same-named defaults). */
upstreamExtraHeaders?: Record<string, string> | null;
};
/** Apply model-level extra upstream headers (e.g. Authentication, X-Custom-Auth). */
export function mergeUpstreamExtraHeaders(
headers: Record<string, string>,
extra?: Record<string, string> | null
): void {
if (!extra) return;
for (const [k, v] of Object.entries(extra)) {
if (typeof k === "string" && k.length > 0 && typeof v === "string") {
headers[k] = v;
}
}
}
function mergeAbortSignals(primary: AbortSignal, secondary: AbortSignal): AbortSignal {
const controller = new AbortController();
@@ -204,7 +219,16 @@ export class BaseExecutor {
return { status: response.status, message: bodyText || `HTTP ${response.status}` };
}
async execute({ model, body, stream, credentials, signal, log, extendedContext }: ExecuteInput) {
async execute({
model,
body,
stream,
credentials,
signal,
log,
extendedContext,
upstreamExtraHeaders,
}: ExecuteInput) {
const fallbackCount = this.getFallbackCount();
let lastError: unknown = null;
let lastStatus = 0;
@@ -258,6 +282,8 @@ export class BaseExecutor {
bodyString = fingerprinted.bodyString;
}
mergeUpstreamExtraHeaders(finalHeaders, upstreamExtraHeaders);
const fetchOptions: RequestInit = {
method: "POST",
headers: finalHeaders,
@@ -289,7 +315,7 @@ export class BaseExecutor {
continue;
}
return { response, url, headers, transformedBody };
return { response, url, headers: finalHeaders, transformedBody };
} catch (error) {
// Distinguish timeout errors from other abort errors
const err = error instanceof Error ? error : new Error(String(error));

View File

@@ -20,7 +20,7 @@ declare const EdgeRuntime: string | undefined;
* @see cursorProtobuf.js for Protobuf encoding/decoding utilities
*/
import { BaseExecutor } from "./base.ts";
import { BaseExecutor, mergeUpstreamExtraHeaders } from "./base.ts";
import { PROVIDERS, HTTP_STATUS } from "../config/constants.ts";
import {
generateCursorBody,
@@ -363,9 +363,10 @@ export class CursorExecutor extends BaseExecutor {
});
}
async execute({ model, body, stream, credentials, signal, log }) {
async execute({ model, body, stream, credentials, signal, log, upstreamExtraHeaders }) {
const url = this.buildUrl();
const headers = this.buildHeaders(credentials);
mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders);
const transformedBody = this.transformRequest(model, body, stream, credentials);
try {

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import {
BaseExecutor,
mergeUpstreamExtraHeaders,
type ExecuteInput,
type ExecutorLog,
type ProviderCredentials,
@@ -89,9 +90,18 @@ export class KiroExecutor extends BaseExecutor {
/**
* Custom execute for Kiro - handles AWS EventStream binary response
*/
async execute({ model, body, stream, credentials, signal, log }: ExecuteInput) {
async execute({
model,
body,
stream,
credentials,
signal,
log,
upstreamExtraHeaders,
}: ExecuteInput) {
const url = this.buildUrl(model, stream, 0);
const headers = this.buildHeaders(credentials, stream);
mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders);
const transformedBody = this.transformRequest(model, body, stream, credentials);
const response = await fetch(url, {

View File

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

View File

@@ -1,5 +1,5 @@
import { getCorsOrigin } from "../utils/cors.ts";
import { detectFormat, getTargetFormat } from "../services/provider.ts";
import { detectFormatFromEndpoint, getTargetFormat } from "../services/provider.ts";
import { translateRequest, needsTranslation } from "../translator/index.ts";
import { FORMATS } from "../translator/formats.ts";
import {
@@ -16,6 +16,9 @@ import { resolveModelAlias } from "../services/modelDeprecation.ts";
import { getUnsupportedParams } from "../config/providerRegistry.ts";
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.ts";
import { HTTP_STATUS } from "../config/constants.ts";
import { classifyProviderError, PROVIDER_ERROR_TYPES } from "../services/errorClassifier.ts";
import { updateProviderConnection } from "@/lib/db/providers";
import { logAuditEvent } from "@/lib/compliance";
import { handleBypassRequest } from "../utils/bypassHandler.ts";
import {
saveRequestUsage,
@@ -23,8 +26,17 @@ import {
appendRequestLog,
saveCallLog,
} from "@/lib/usageDb";
import { getModelNormalizeToolCallId, getModelPreserveOpenAIDeveloperRole } from "@/lib/localDb";
import {
getModelNormalizeToolCallId,
getModelPreserveOpenAIDeveloperRole,
getModelUpstreamExtraHeaders,
} from "@/lib/localDb";
import { getExecutor } from "../executors/index.ts";
import {
parseCodexQuotaHeaders,
getCodexResetTime,
getCodexModelScope,
} from "../executors/codex.ts";
import { translateNonStreamingResponse } from "./responseTranslator.ts";
import { extractUsageFromResponse } from "./usageExtractor.ts";
import { parseSSEToOpenAIResponse, parseSSEToResponsesOutput } from "./sseParser.ts";
@@ -44,11 +56,17 @@ import { getIdempotencyKey, checkIdempotency, saveIdempotency } from "@/lib/idem
import { createProgressTransform, wantsProgress } from "../utils/progressTracker.ts";
import { isModelUnavailableError, getNextFamilyFallback } from "../services/modelFamilyFallback.ts";
import { computeRequestHash, deduplicate, shouldDeduplicate } from "../services/requestDedup.ts";
import {
getBackgroundTaskReason,
getDegradedModel,
getBackgroundDegradationConfig,
} from "../services/backgroundTaskDetector.ts";
import {
shouldUseFallback,
isFallbackDecision,
EMERGENCY_FALLBACK_CONFIG,
} from "../services/emergencyFallback.ts";
import { resolveStreamFlag, stripMarkdownCodeFence } from "../utils/aiSdkCompat.ts";
export function shouldUseNativeCodexPassthrough({
provider,
@@ -93,7 +111,9 @@ export async function handleChatCore({
userAgent,
comboName,
}) {
const { provider, model, extendedContext } = modelInfo;
let { provider, model, extendedContext } = modelInfo;
const requestedModel =
typeof body?.model === "string" && body.model.trim().length > 0 ? body.model : model;
const startTime = Date.now();
const persistFailureUsage = (statusCode: number, errorCode?: string | null) => {
saveRequestUsage({
@@ -112,6 +132,67 @@ export async function handleChatCore({
}).catch(() => {});
};
const persistCodexQuotaState = async (
headers: Headers | Record<string, string> | null,
status = 0
) => {
if (provider !== "codex" || !connectionId || !headers) return;
try {
const quota = parseCodexQuotaHeaders(headers as Headers);
if (!quota) return;
const existingProviderData =
credentials?.providerSpecificData && typeof credentials.providerSpecificData === "object"
? credentials.providerSpecificData
: {};
const scope = getCodexModelScope(model || requestedModel || "");
const quotaState = {
usage5h: quota.usage5h,
limit5h: quota.limit5h,
resetAt5h: quota.resetAt5h,
usage7d: quota.usage7d,
limit7d: quota.limit7d,
resetAt7d: quota.resetAt7d,
scope,
updatedAt: new Date().toISOString(),
};
const nextProviderData: Record<string, unknown> = {
...existingProviderData,
codexQuotaState: quotaState,
};
// T03/T09: on 429, persist exact reset time per scope to avoid global over-blocking.
if (status === 429) {
const resetTimeMs = getCodexResetTime(quota);
if (resetTimeMs && resetTimeMs > Date.now()) {
const scopeUntil = new Date(resetTimeMs).toISOString();
const scopeMapRaw =
existingProviderData &&
typeof existingProviderData === "object" &&
existingProviderData.codexScopeRateLimitedUntil &&
typeof existingProviderData.codexScopeRateLimitedUntil === "object"
? existingProviderData.codexScopeRateLimitedUntil
: {};
nextProviderData.codexScopeRateLimitedUntil = {
...(scopeMapRaw as Record<string, unknown>),
[scope]: scopeUntil,
};
}
}
await updateProviderConnection(connectionId, {
providerSpecificData: nextProviderData,
});
credentials.providerSpecificData = nextProviderData;
} catch (err) {
log?.debug?.("CODEX", `Failed to persist codex quota state: ${err?.message || err}`);
}
};
// ── Phase 9.2: Idempotency check ──
const idempotencyKey = getIdempotencyKey(clientRawRequest?.headers);
const cachedIdemp = checkIdempotency(idempotencyKey);
@@ -139,8 +220,8 @@ export async function handleChatCore({
credentials.connectionId = connectionId;
}
const sourceFormat = detectFormat(body);
const endpointPath = String(clientRawRequest?.endpoint || "");
const sourceFormat = detectFormatFromEndpoint(body, endpointPath);
const isResponsesEndpoint = /(?:^|\/)responses(?:\/.*)?$/i.test(endpointPath);
const nativeCodexPassthrough = shouldUseNativeCodexPassthrough({
provider,
@@ -157,6 +238,37 @@ export async function handleChatCore({
// Detect source format and get target format
// Model-specific targetFormat takes priority over provider default
// ── Background Task Redirection (T41) ──
const bgConfig = getBackgroundDegradationConfig();
const backgroundReason = bgConfig.enabled
? getBackgroundTaskReason(body, clientRawRequest?.headers)
: null;
if (backgroundReason) {
const degradedModel = getDegradedModel(model);
if (degradedModel !== model) {
const originalModel = model;
log?.info?.(
"BACKGROUND",
`Background task redirect (${backgroundReason}): ${originalModel}${degradedModel}`
);
model = degradedModel;
if (body && typeof body === "object") {
body.model = model;
}
logAuditEvent({
action: "routing.background_task_redirect",
actor: apiKeyInfo?.name || "system",
target: connectionId || provider || "chat",
details: {
original_model: originalModel,
redirected_to: degradedModel,
reason: backgroundReason,
},
});
}
}
// Apply custom model aliases (Settings → Model Aliases → Pattern→Target) before routing (#315, #472)
// Custom aliases take priority over built-in and must be resolved here so the
// downstream getModelTargetFormat() lookup AND the actual provider request use
@@ -172,8 +284,30 @@ export async function handleChatCore({
const modelTargetFormat = getModelTargetFormat(alias, resolvedModel);
const targetFormat = modelTargetFormat || getTargetFormat(provider);
// Primary path: merge client model id + alias target so config on either key applies; resolved
// id wins on same header name. T5 family fallback uses only (nextModel, resolveModelAlias(next))
// so A-model headers are not sent to B — see buildUpstreamHeadersForExecute.
const buildUpstreamHeadersForExecute = (modelToCall: string): Record<string, string> => {
if (modelToCall === effectiveModel) {
return {
...getModelUpstreamExtraHeaders(provider || "", model || "", sourceFormat),
...getModelUpstreamExtraHeaders(provider || "", resolvedModel || "", sourceFormat),
};
}
const r = resolveModelAlias(modelToCall);
return {
...getModelUpstreamExtraHeaders(provider || "", modelToCall || "", sourceFormat),
...getModelUpstreamExtraHeaders(provider || "", r || "", sourceFormat),
};
};
// Default to false unless client explicitly sets stream: true (OpenAI spec compliant)
const stream = body.stream === true;
const acceptHeader =
clientRawRequest?.headers && typeof clientRawRequest.headers.get === "function"
? clientRawRequest.headers.get("accept") || clientRawRequest.headers.get("Accept")
: (clientRawRequest?.headers || {})["accept"] || (clientRawRequest?.headers || {})["Accept"];
const stream = resolveStreamFlag(body?.stream, acceptHeader);
// ── Phase 9.1: Semantic cache check (non-streaming, temp=0 only) ──
if (isCacheable(body, clientRawRequest?.headers)) {
@@ -219,11 +353,42 @@ export async function handleChatCore({
translatedBody = { ...body, _nativeCodexPassthrough: true };
log?.debug?.("FORMAT", "native codex passthrough enabled");
} else if (isClaudePassthrough) {
// Claude-to-Claude passthrough: forward body completely untouched.
// No translation, no field stripping, no thinking normalization.
// We are just a gateway -- do not interfere with the request in the slightest.
translatedBody = { ...body };
log?.debug?.("FORMAT", "claude->claude passthrough -- forwarding untouched");
// Claude OAuth expects the same Claude Code prompt + structural normalization
// as the OpenAI-compatible chat path. Round-trip through OpenAI to reuse the
// working Claude translator instead of forwarding raw Messages payloads.
const normalizeToolCallId = getModelNormalizeToolCallId(
provider || "",
model || "",
sourceFormat
);
const preserveDeveloperRole = getModelPreserveOpenAIDeveloperRole(
provider || "",
model || "",
sourceFormat
);
translatedBody = translateRequest(
FORMATS.CLAUDE,
FORMATS.OPENAI,
model,
{ ...body },
stream,
credentials,
provider,
reqLogger,
{ normalizeToolCallId, preserveDeveloperRole }
);
translatedBody = translateRequest(
FORMATS.OPENAI,
FORMATS.CLAUDE,
model,
translatedBody,
stream,
credentials,
provider,
reqLogger,
{ normalizeToolCallId, preserveDeveloperRole }
);
log?.debug?.("FORMAT", "claude->openai->claude normalized passthrough");
} else {
translatedBody = { ...body };
@@ -449,6 +614,7 @@ export async function handleChatCore({
signal: streamController.signal,
log,
extendedContext,
upstreamExtraHeaders: buildUpstreamHeadersForExecute(modelToCall),
})
);
@@ -457,7 +623,7 @@ export async function handleChatCore({
// Non-stream responses need cloning for shared dedup consumers.
const status = rawResult.response.status;
const statusText = rawResult.response.statusText;
const headers = Array.from(rawResult.response.headers.entries());
const headers = Array.from(rawResult.response.headers.entries()) as [string, string][];
const payload = await rawResult.response.text();
return {
@@ -532,6 +698,7 @@ export async function handleChatCore({
path: clientRawRequest?.endpoint || "/v1/chat/completions",
status: error.name === "AbortError" ? 499 : HTTP_STATUS.BAD_GATEWAY,
model,
requestedModel,
provider,
connectionId,
duration: Date.now() - startTime,
@@ -579,16 +746,19 @@ export async function handleChatCore({
await onCredentialsRefreshed(newCredentials);
}
// Retry with new credentials
// Retry with new credentials — model + extra headers follow translatedBody.model so they
// stay aligned if this block ever runs after a path that mutates body.model (e.g. fallback).
try {
const retryModelId = String(translatedBody.model || effectiveModel);
const retryResult = await executor.execute({
model,
model: retryModelId,
body: translatedBody,
stream,
credentials: getExecutionCredentials(),
signal: streamController.signal,
log,
extendedContext,
upstreamExtraHeaders: buildUpstreamHeadersForExecute(retryModelId),
});
if (retryResult.response.ok) {
@@ -603,6 +773,8 @@ export async function handleChatCore({
}
}
await persistCodexQuotaState(providerResponse.headers, providerResponse.status);
// Check provider response - return error info for fallback handling
if (!providerResponse.ok) {
trackPendingRequest(model, provider, connectionId, false);
@@ -610,6 +782,54 @@ export async function handleChatCore({
providerResponse,
provider
);
// T06/T10/T36: classify provider errors and persist terminal account states.
const errorType = classifyProviderError(statusCode, message);
if (connectionId && errorType) {
try {
if (errorType === PROVIDER_ERROR_TYPES.FORBIDDEN) {
await updateProviderConnection(connectionId, {
isActive: false,
testStatus: "banned",
lastErrorType: errorType,
lastError: message,
errorCode: statusCode,
});
console.warn(
`[provider] Node ${connectionId} banned (${statusCode}) — disabling permanently`
);
} else if (errorType === PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED) {
await updateProviderConnection(connectionId, {
testStatus: "credits_exhausted",
lastErrorType: errorType,
lastError: message,
errorCode: statusCode,
});
console.warn(`[provider] Node ${connectionId} exhausted quota (${statusCode})`);
} else if (errorType === PROVIDER_ERROR_TYPES.ACCOUNT_DEACTIVATED) {
await updateProviderConnection(connectionId, {
isActive: false,
testStatus: "expired",
lastErrorType: errorType,
lastError: message,
errorCode: statusCode,
});
console.warn(
`[provider] Node ${connectionId} account deactivated (${statusCode}) — marked expired`
);
} else if (errorType === PROVIDER_ERROR_TYPES.UNAUTHORIZED) {
// Normal 401 (token/session auth issue): keep account active for refresh/re-auth.
await updateProviderConnection(connectionId, {
lastErrorType: errorType,
lastError: message,
errorCode: statusCode,
});
}
} catch {
// Best-effort state update; request flow should continue with fallback handling.
}
}
appendRequestLog({ model, provider, connectionId, status: `FAILED ${statusCode}` }).catch(
() => {}
);
@@ -618,6 +838,7 @@ export async function handleChatCore({
path: clientRawRequest?.endpoint || "/v1/chat/completions",
status: statusCode,
model,
requestedModel,
provider,
connectionId,
duration: Date.now() - startTime,
@@ -808,6 +1029,7 @@ export async function handleChatCore({
path: clientRawRequest?.endpoint || "/v1/chat/completions",
status: 200,
model,
requestedModel,
provider,
connectionId,
duration: Date.now() - startTime,
@@ -848,6 +1070,28 @@ export async function handleChatCore({
? translateNonStreamingResponse(responseBody, targetFormat, sourceFormat)
: responseBody;
// T26: Strip markdown code blocks if provider format is Claude
if (sourceFormat === "claude" && !stream) {
if (typeof translatedResponse?.choices?.[0]?.message?.content === "string") {
translatedResponse.choices[0].message.content = stripMarkdownCodeFence(
translatedResponse.choices[0].message.content
) as string;
}
}
// T18: Normalize finish_reason to 'tool_calls' if tool calls are present
if (translatedResponse?.choices) {
for (const choice of translatedResponse.choices) {
if (
choice.message?.tool_calls &&
choice.message.tool_calls.length > 0 &&
choice.finish_reason !== "tool_calls"
) {
choice.finish_reason = "tool_calls";
}
}
}
// Sanitize response for OpenAI SDK compatibility
// Strips non-standard fields (x_groq, usage_breakdown, service_tier, etc.)
// Extracts <think> tags into reasoning_content
@@ -921,6 +1165,7 @@ export async function handleChatCore({
path: clientRawRequest?.endpoint || "/v1/chat/completions",
status: streamStatus || 200,
model,
requestedModel,
provider,
connectionId,
duration: Date.now() - startTime,

View File

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

View File

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

View File

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

View File

@@ -36,6 +36,7 @@ export {
// Services
export {
detectFormat,
detectFormatFromEndpoint,
getProviderConfig,
buildProviderUrl,
buildProviderHeaders,

View File

@@ -1,5 +1,5 @@
/**
* MCP HTTP Transport Layer — Singleton server + SSE/Streamable HTTP handlers.
* MCP HTTP Transport Layer — session-aware handlers for SSE and Streamable HTTP.
*
* Runs the MCP server **inside** the Next.js process so it can be toggled
* from the dashboard without requiring `omniroute --mcp`.
@@ -14,58 +14,188 @@ import { createMcpServer } from "./server.ts";
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
// ────── Singleton ──────────────────────────────────────────
let _sseServer: McpServer | null = null;
let _sseTransport: WebStandardStreamableHTTPServerTransport | null = null;
let _sseStartedAt: number | null = null;
let _server: McpServer | null = null;
let _transport: WebStandardStreamableHTTPServerTransport | null = null;
let _startedAt: number | null = null;
let _activeTransportMode: "sse" | "streamable-http" | null = null;
type StreamableSession = {
sessionId: string;
server: McpServer;
transport: WebStandardStreamableHTTPServerTransport;
startedAt: number;
};
function ensureServer(mode: "sse" | "streamable-http"): {
const _streamableSessions = new Map<string, StreamableSession>();
function closeSseTransport(): void {
if (_sseTransport) {
try {
_sseTransport.close();
} catch {
// ignore shutdown errors
}
}
_sseServer = null;
_sseTransport = null;
_sseStartedAt = null;
}
function closeStreamableSession(sessionId: string): void {
const session = _streamableSessions.get(sessionId);
if (!session) {
return;
}
try {
session.transport.close();
} catch {
// ignore shutdown errors
}
_streamableSessions.delete(sessionId);
}
function closeAllStreamableSessions(): void {
for (const sessionId of _streamableSessions.keys()) {
closeStreamableSession(sessionId);
}
}
function ensureSseServer(): {
server: McpServer;
transport: WebStandardStreamableHTTPServerTransport;
} {
if (_server && _transport && _activeTransportMode === mode) {
return { server: _server, transport: _transport };
if (_sseServer && _sseTransport) {
return { server: _sseServer, transport: _sseTransport };
}
// Shutdown previous if switching modes
if (_transport) {
try { _transport.close(); } catch { /* ignore */ }
}
closeAllStreamableSessions();
_server = createMcpServer();
_transport = new WebStandardStreamableHTTPServerTransport({
_sseServer = createMcpServer();
_sseTransport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
});
_activeTransportMode = mode;
_startedAt = Date.now();
_sseStartedAt = Date.now();
// Connect server to transport (fire-and-forget, will be ready by first request)
void _server.connect(_transport);
void _sseServer.connect(_sseTransport);
console.log(`[MCP] HTTP transport started (${mode})`);
return { server: _server, transport: _transport };
console.log("[MCP] HTTP transport started (sse)");
return { server: _sseServer, transport: _sseTransport };
}
// ────── Streamable HTTP Handler ────────────────────────────
function createStreamableSession(): StreamableSession {
closeSseTransport();
const sessionId = randomUUID();
const server = createMcpServer();
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: () => sessionId,
});
const session = {
sessionId,
server,
transport,
startedAt: Date.now(),
};
void server.connect(transport);
_streamableSessions.set(sessionId, session);
console.log(`[MCP] HTTP transport started (streamable-http:${sessionId})`);
return session;
}
async function isInitializeRequest(request: Request): Promise<boolean> {
if (request.method !== "POST") {
return false;
}
try {
const body = (await request.clone().json()) as { method?: unknown };
return body?.method === "initialize";
} catch {
return false;
}
}
function errorResponse(message: string, code: number, status = 400): Response {
return new Response(
JSON.stringify({
jsonrpc: "2.0",
error: { code, message },
id: null,
}),
{
status,
headers: { "Content-Type": "application/json" },
}
);
}
function withSessionHeader(response: Response, sessionId: string): Response {
if (response.headers.get("mcp-session-id")) {
return response;
}
const headers = new Headers(response.headers);
headers.set("mcp-session-id", sessionId);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
async function handleStreamableRequest(request: Request): Promise<Response> {
const sessionId = request.headers.get("mcp-session-id");
if (sessionId) {
const session = _streamableSessions.get(sessionId);
if (!session) {
return errorResponse("Bad Request: Unknown Mcp-Session-Id header", -32000);
}
try {
const response = await session.transport.handleRequest(request);
if (request.method === "DELETE") {
closeStreamableSession(sessionId);
}
return withSessionHeader(response, sessionId);
} catch (err) {
console.error("[MCP] Streamable HTTP error:", err);
if (request.method === "DELETE") {
closeStreamableSession(sessionId);
}
return new Response(JSON.stringify({ error: "MCP transport error" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
}
if (!(await isInitializeRequest(request))) {
return errorResponse("Bad Request: Mcp-Session-Id header is required", -32000);
}
const session = createStreamableSession();
try {
const response = await session.transport.handleRequest(request);
return withSessionHeader(response, session.sessionId);
} catch (err) {
closeStreamableSession(session.sessionId);
console.error("[MCP] Streamable HTTP error:", err);
return new Response(JSON.stringify({ error: "MCP transport error" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
}
/**
* Handle Streamable HTTP requests (POST / GET / DELETE).
* Used by the Next.js route at /api/mcp/stream.
*/
export async function handleMcpStreamableHTTP(request: Request): Promise<Response> {
const { transport } = ensureServer("streamable-http");
try {
return await transport.handleRequest(request);
} catch (err) {
console.error("[MCP] Streamable HTTP error:", err);
return new Response(
JSON.stringify({ error: "MCP transport error" }),
{ status: 500, headers: { "Content-Type": "application/json" } },
);
}
return handleStreamableRequest(request);
}
/**
@@ -74,47 +204,47 @@ export async function handleMcpStreamableHTTP(request: Request): Promise<Respons
* and POST for messages (the Streamable HTTP transport supports both patterns).
*/
export async function handleMcpSSE(request: Request): Promise<Response> {
const { transport } = ensureServer("sse");
const { transport } = ensureSseServer();
try {
return await transport.handleRequest(request);
} catch (err) {
console.error("[MCP] SSE error:", err);
return new Response(
JSON.stringify({ error: "MCP SSE transport error" }),
{ status: 500, headers: { "Content-Type": "application/json" } },
);
return new Response(JSON.stringify({ error: "MCP SSE transport error" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
}
// ────── Status & Lifecycle ─────────────────────────────────
export function getMcpHttpStatus(): {
online: boolean;
transport: string | null;
startedAt: number | null;
uptime: string | null;
} {
const online = _transport !== null && _activeTransportMode !== null;
const streamableStartedAt =
_streamableSessions.size > 0
? Math.min(...Array.from(_streamableSessions.values(), (session) => session.startedAt))
: null;
const startedAt = streamableStartedAt ?? _sseStartedAt;
const transport = _streamableSessions.size > 0 ? "streamable-http" : _sseTransport ? "sse" : null;
const online = transport !== null;
return {
online,
transport: _activeTransportMode,
startedAt: _startedAt,
uptime: _startedAt ? `${Math.floor((Date.now() - _startedAt) / 1000)}s` : null,
transport,
startedAt,
uptime: startedAt ? `${Math.floor((Date.now() - startedAt) / 1000)}s` : null,
};
}
export function shutdownMcpHttp(): void {
if (_transport) {
try { _transport.close(); } catch { /* ignore */ }
}
_server = null;
_transport = null;
_activeTransportMode = null;
_startedAt = null;
closeSseTransport();
closeAllStreamableSessions();
console.log("[MCP] HTTP transport shutdown");
}
export function isMcpHttpActive(): boolean {
return _transport !== null;
return _sseTransport !== null || _streamableSessions.size > 0;
}

View File

@@ -57,7 +57,7 @@ export const TaskInputSchema = z.object({
role: z
.enum(["coding", "review", "planning", "analysis", "debugging", "documentation"])
.optional(),
metadata: z.record(z.unknown()).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
});
export const CostEnvelopeSchema = z.object({
@@ -120,7 +120,7 @@ export type PolicyVerdict = z.infer<typeof PolicyVerdictSchema>;
export const JsonRpcRequestSchema = z.object({
jsonrpc: z.literal("2.0"),
method: z.enum(["message/send", "message/stream", "tasks/get", "tasks/cancel"]),
params: z.record(z.unknown()),
params: z.record(z.string(), z.unknown()),
id: z.union([z.string(), z.number()]),
});
@@ -151,7 +151,7 @@ export const MessageSendParamsSchema = z.object({
message: z.object({
role: z.string().default("user"),
content: z.string(),
metadata: z.record(z.unknown()).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
}),
config: z
.object({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,14 +5,34 @@
* 3 layers: trim tool messages, compress thinking, aggressive purification.
*/
// Default token limits per provider (rough estimates based on model context windows)
const DEFAULT_LIMITS = {
import { REGISTRY } from "../config/providerRegistry.ts";
// Default token limits per provider (fallbacks when not in registry)
const DEFAULT_LIMITS: Record<string, number> = {
claude: 200000,
openai: 128000,
gemini: 1000000,
codex: 400000,
default: 128000,
};
// Environment variable overrides (highest priority)
function getEnvOverride(provider: string): number | null {
const envKey = `CONTEXT_LENGTH_${provider.toUpperCase().replace(/[^A-Z0-9]/g, "_")}`;
const envValue = process.env[envKey];
if (envValue) {
const parsed = parseInt(envValue, 10);
if (!isNaN(parsed) && parsed > 0) return parsed;
}
// Global override
const globalValue = process.env.CONTEXT_LENGTH_DEFAULT;
if (globalValue) {
const parsed = parseInt(globalValue, 10);
if (!isNaN(parsed) && parsed > 0) return parsed;
}
return null;
}
// Rough chars-per-token ratio for quick estimation
const CHARS_PER_TOKEN = 4;
@@ -27,9 +47,20 @@ export function estimateTokens(text) {
/**
* Get token limit for a provider/model combination
* Priority: Env override > Registry defaultContextLength > DEFAULT_LIMITS
*/
export function getTokenLimit(provider, model = null) {
// Check if model has a known limit
// 1. Check environment variable override first
const envOverride = getEnvOverride(provider);
if (envOverride) return envOverride;
// 2. Check registry for provider default
const registryEntry = REGISTRY[provider];
if (registryEntry?.defaultContextLength) {
return registryEntry.defaultContextLength;
}
// 3. Check if model name hints at a known limit
if (model) {
const lower = model.toLowerCase();
if (lower.includes("claude")) return DEFAULT_LIMITS.claude;
@@ -38,10 +69,13 @@ export function getTokenLimit(provider, model = null) {
lower.includes("gpt") ||
lower.includes("o1") ||
lower.includes("o3") ||
lower.includes("o4")
lower.includes("o4") ||
lower.includes("codex")
)
return DEFAULT_LIMITS.openai;
return DEFAULT_LIMITS.codex;
}
// 4. Fallback to DEFAULT_LIMITS or default
return DEFAULT_LIMITS[provider] || DEFAULT_LIMITS.default;
}

View File

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

View File

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

View File

@@ -242,8 +242,8 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) {
// FIX #73: Models like claude-haiku-4-5-20251001 sent without provider prefix
// would incorrectly route to OpenAI. Use heuristic prefix detection first.
if (/^claude-/i.test(modelId)) {
// Claude models → Antigravity (Anthropic) provider
return { provider: "antigravity", model: modelId, extendedContext };
// Claude models → Anthropic provider (canonical source for Claude models)
return { provider: "anthropic", model: modelId, extendedContext };
}
if (/^gemini-/i.test(modelId) || /^gemma-/i.test(modelId)) {
// Gemini/Gemma models → Gemini provider

View File

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

View File

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

View File

@@ -35,6 +35,27 @@ function buildAnthropicCompatibleUrl(baseUrl) {
return `${normalized}/messages`;
}
// Detect request format from endpoint first when the route is known.
// This avoids ambiguous bodies like OpenAI /chat/completions requests that also
// contain max_tokens or Claude model names.
export function detectFormatFromEndpoint(body, endpointPath = "") {
const path = String(endpointPath || "");
if (/(?:^|\/)responses(?:\/.*)?$/i.test(path)) {
return "openai-responses";
}
if (/(?:^|\/)messages(?:\/.*)?$/i.test(path)) {
return "claude";
}
if (/(?:^|\/)(?:chat\/completions|completions)(?:\/.*)?$/i.test(path)) {
return "openai";
}
return detectFormat(body);
}
// Detect request format from body structure
export function detectFormat(body) {
// OpenAI Responses API:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -144,7 +144,7 @@ export function translateRequest(
}
// Final step: prepare request for Claude format endpoints
if (targetFormat === FORMATS.CLAUDE && sourceFormat !== FORMATS.CLAUDE) {
if (targetFormat === FORMATS.CLAUDE) {
result = prepareClaudeRequest(result, provider);
}

View File

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

View File

@@ -29,7 +29,7 @@ type ClaudeTool = {
/**
* T02: Recursively strips empty text blocks from content arrays.
* Anthropic returns 400 "text content blocks must be non-empty" if any
* Anthropic returns 400 "text content blocks must be non-empty" when a
* text block has text: "". Must also recurse into nested tool_result.content.
* Ref: sub2api PR #1212
*/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -57,6 +57,8 @@ type TranslateState = ReturnType<typeof initState> & {
accumulatedContent?: string;
};
type UsageTokenRecord = Record<string, number>;
function getOpenAIIntermediateChunks(value: unknown): unknown[] {
if (!value || typeof value !== "object") return [];
const candidate = (value as JsonRecord)._openaiIntermediate;
@@ -108,7 +110,9 @@ export function createSSEStream(options: StreamOptions = {}) {
} = options;
let buffer = "";
let usage = null;
let usage: UsageTokenRecord | null = null;
/** Passthrough (OpenAI CC shape): saw tool_calls in stream before finish_reason */
let passthroughHasToolCalls = false;
// State for translate mode (accumulatedContent for call log response body)
const state: TranslateState | null =
@@ -222,16 +226,17 @@ export function createSSEStream(options: StreamOptions = {}) {
const extracted = extractUsage(parsed);
if (extracted) {
// Non-destructive merge: never overwrite a positive value with 0
// message_start carries input_tokens, message_delta carries output_tokens
// message_start carries input_tokens, message_delta carries output_tokens;
if (!usage) usage = {};
if (extracted.prompt_tokens > 0) usage.prompt_tokens = extracted.prompt_tokens;
if (extracted.completion_tokens > 0)
usage.completion_tokens = extracted.completion_tokens;
if (extracted.total_tokens > 0) usage.total_tokens = extracted.total_tokens;
if (extracted.cache_read_input_tokens)
usage.cache_read_input_tokens = extracted.cache_read_input_tokens;
if (extracted.cache_creation_input_tokens)
usage.cache_creation_input_tokens = extracted.cache_creation_input_tokens;
const u = usage;
const eu = extracted as UsageTokenRecord;
if (eu.prompt_tokens > 0) u.prompt_tokens = eu.prompt_tokens;
if (eu.completion_tokens > 0) u.completion_tokens = eu.completion_tokens;
if (eu.total_tokens > 0) u.total_tokens = eu.total_tokens;
if (eu.cache_read_input_tokens)
u.cache_read_input_tokens = eu.cache_read_input_tokens;
if (eu.cache_creation_input_tokens)
u.cache_creation_input_tokens = eu.cache_creation_input_tokens;
}
// Track content length and accumulate from Claude format
if (parsed.delta?.text) {
@@ -263,6 +268,11 @@ export function createSSEStream(options: StreamOptions = {}) {
}
}
// T18: Track if we saw tool calls
if (delta?.tool_calls && delta.tool_calls.length > 0) {
passthroughHasToolCalls = true;
}
const content = delta?.content || delta?.reasoning_content;
if (content && typeof content === "string") {
totalContentLength += content.length;
@@ -278,6 +288,20 @@ export function createSSEStream(options: StreamOptions = {}) {
}
const isFinishChunk = parsed.choices?.[0]?.finish_reason;
// T18: Normalize finish_reason to 'tool_calls' if tool calls were used
if (
isFinishChunk &&
passthroughHasToolCalls &&
parsed.choices[0].finish_reason !== "tool_calls"
) {
parsed.choices[0].finish_reason = "tool_calls";
// If we modify it, we must output the modified object
if (!injectedUsage && hasValidUsage(parsed.usage)) {
output = `data: ${JSON.stringify(parsed)}\n`;
injectedUsage = true;
}
}
if (isFinishChunk && !hasValidUsage(parsed.usage)) {
const estimated = estimateUsage(body, totalContentLength, FORMATS.OPENAI);
parsed.usage = filterUsageForFormat(estimated, FORMATS.OPENAI);
@@ -529,19 +553,17 @@ export function createSSEStream(options: StreamOptions = {}) {
if (!state.usage) {
state.usage = extracted;
} else {
if (extracted.prompt_tokens > 0)
state.usage.prompt_tokens = extracted.prompt_tokens;
if (extracted.completion_tokens > 0)
state.usage.completion_tokens = extracted.completion_tokens;
if (extracted.total_tokens > 0) state.usage.total_tokens = extracted.total_tokens;
if (extracted.cache_read_input_tokens > 0)
state.usage.cache_read_input_tokens = extracted.cache_read_input_tokens;
if (extracted.cache_creation_input_tokens > 0)
state.usage.cache_creation_input_tokens = extracted.cache_creation_input_tokens;
if (extracted.cached_tokens > 0)
state.usage.cached_tokens = extracted.cached_tokens;
if (extracted.reasoning_tokens > 0)
state.usage.reasoning_tokens = extracted.reasoning_tokens;
const su = state.usage as Record<string, number>;
const eu = extracted as Record<string, number>;
if (eu.prompt_tokens > 0) su.prompt_tokens = eu.prompt_tokens;
if (eu.completion_tokens > 0) su.completion_tokens = eu.completion_tokens;
if (eu.total_tokens > 0) su.total_tokens = eu.total_tokens;
if (eu.cache_read_input_tokens > 0)
su.cache_read_input_tokens = eu.cache_read_input_tokens;
if (eu.cache_creation_input_tokens > 0)
su.cache_creation_input_tokens = eu.cache_creation_input_tokens;
if (eu.cached_tokens > 0) su.cached_tokens = eu.cached_tokens;
if (eu.reasoning_tokens > 0) su.reasoning_tokens = eu.reasoning_tokens;
}
}

View File

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

273
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "3.0.0-rc.5",
"version": "3.0.0-rc.16",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "3.0.0-rc.5",
"version": "3.0.0-rc.16",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -26,9 +26,10 @@
"http-proxy-middleware": "^3.0.5",
"https-proxy-agent": "^8.0.0",
"jose": "^6.1.3",
"keytar": "^7.9.0",
"lowdb": "^7.0.1",
"monaco-editor": "^0.55.1",
"next": "^16.1.6",
"next": "^16.0.10",
"next-intl": "^4.8.3",
"node-machine-id": "^1.1.12",
"open": "^11.0.0",
@@ -55,13 +56,14 @@
"@tailwindcss/postcss": "^4.1.18",
"@types/bcryptjs": "^3.0.0",
"@types/better-sqlite3": "^7.6.13",
"@types/keytar": "^4.4.0",
"@types/node": "^25.2.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"eslint": "^9.39.2",
"eslint-config-next": "16.1.6",
"eslint-config-next": "^16.0.10",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"prettier": "^3.8.1",
@@ -1785,6 +1787,9 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1801,6 +1806,9 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1817,6 +1825,9 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1833,6 +1844,9 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1849,6 +1863,9 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1881,6 +1898,9 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1913,6 +1933,9 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1935,6 +1958,9 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1957,6 +1983,9 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1979,6 +2008,9 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2001,6 +2033,9 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2045,6 +2080,9 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2554,16 +2592,20 @@
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
"integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
"integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.4.3",
"@emnapi/runtime": "^1.4.3",
"@tybys/wasm-util": "^0.10.0"
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@next/env": {
@@ -2621,6 +2663,9 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2637,6 +2682,9 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2916,6 +2964,9 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2936,6 +2987,9 @@
"cpu": [
"arm"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2956,6 +3010,9 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2976,6 +3033,9 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4870,6 +4930,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4887,6 +4950,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4904,6 +4970,9 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4921,6 +4990,9 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4998,23 +5070,6 @@
"node": ">=14.0.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
"integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz",
@@ -5241,6 +5296,9 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -5257,6 +5315,9 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -5503,6 +5564,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5520,6 +5584,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5593,70 +5660,6 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.8.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.8.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz",
@@ -6110,6 +6113,13 @@
"license": "MIT",
"peer": true
},
"node_modules/@types/keytar": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/@types/keytar/-/keytar-4.4.0.tgz",
"integrity": "sha512-cq/NkUUy6rpWD8n7PweNQQBpw2o0cf5v6fbkUVEpOB9VzzIvyPvSEId1/goIj+MciW2v1Lw5mRimKO01XgE9EA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/mdast": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
@@ -6595,6 +6605,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -6609,6 +6622,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -6623,6 +6639,9 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -6637,6 +6656,9 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -6651,6 +6673,9 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -6665,6 +6690,9 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -6716,6 +6744,19 @@
"node": ">=14.0.0"
}
},
"node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
"integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.4.3",
"@emnapi/runtime": "^1.4.3",
"@tybys/wasm-util": "^0.10.0"
}
},
"node_modules/@unrs/resolver-binding-win32-arm64-msvc": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz",
@@ -6946,9 +6987,9 @@
}
},
"node_modules/ahooks": {
"version": "3.9.6",
"resolved": "https://registry.npmjs.org/ahooks/-/ahooks-3.9.6.tgz",
"integrity": "sha512-Mr7f05swd5SmKlR9SZo5U6M0LsL4ErweLzpdgXjA1JPmnZ78Vr6wzx0jUtvoxrcqGKYnX0Yjc02iEASVxHFPjQ==",
"version": "3.9.7",
"resolved": "https://registry.npmjs.org/ahooks/-/ahooks-3.9.7.tgz",
"integrity": "sha512-S0lvzhbdlhK36RFBkGv+RbOM/dbbweym+BIHM/bwwuWVSVN5TuVErHPMWo4w0t1NDYg5KPp2iEf7Y7E5LASYiw==",
"license": "MIT",
"peer": true,
"dependencies": {
@@ -9149,9 +9190,9 @@
}
},
"node_modules/delaunator": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
"integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==",
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz",
"integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==",
"license": "ISC",
"peer": true,
"dependencies": {
@@ -12470,6 +12511,23 @@
"node": ">= 12"
}
},
"node_modules/keytar": {
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz",
"integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^4.3.0",
"prebuild-install": "^7.0.1"
}
},
"node_modules/keytar/node_modules/node-addon-api": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz",
"integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==",
"license": "MIT"
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -12730,6 +12788,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -12751,6 +12812,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -14542,15 +14606,6 @@
"marked": "14.0.0"
}
},
"node_modules/monaco-editor/node_modules/dompurify": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/motion": {
"version": "12.38.0",
"resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz",
@@ -19300,6 +19355,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -19321,6 +19379,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [

View File

@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "3.0.0-rc.7",
"version": "3.0.0-rc.16",
"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": {
@@ -47,7 +47,7 @@
"homepage": "https://omniroute.online",
"scripts": {
"dev": "node scripts/run-next.mjs dev",
"build": "next build",
"build": "node scripts/build-next-isolated.mjs",
"build:cli": "node scripts/prepublish.mjs",
"start": "node scripts/run-next.mjs start",
"lint": "eslint .",
@@ -94,9 +94,10 @@
"http-proxy-middleware": "^3.0.5",
"https-proxy-agent": "^8.0.0",
"jose": "^6.1.3",
"keytar": "^7.9.0",
"lowdb": "^7.0.1",
"monaco-editor": "^0.55.1",
"next": "^16.1.6",
"next": "^16.0.10",
"next-intl": "^4.8.3",
"node-machine-id": "^1.1.12",
"open": "^11.0.0",
@@ -119,13 +120,14 @@
"@tailwindcss/postcss": "^4.1.18",
"@types/bcryptjs": "^3.0.0",
"@types/better-sqlite3": "^7.6.13",
"@types/keytar": "^4.4.0",
"@types/node": "^25.2.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"eslint": "^9.39.2",
"eslint-config-next": "16.1.6",
"eslint-config-next": "^16.0.10",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"prettier": "^3.8.1",
@@ -153,5 +155,8 @@
"omniroute",
"sharp"
]
},
"overrides": {
"dompurify": "^3.3.2"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" fill="none"><rect width="128" height="128" rx="24" fill="#1e293b"/><path d="M48 60a16 16 0 1 1 12 15.5V88h-8V75.5A16 16 0 0 1 48 60z" fill="none" stroke="#94a3b8" stroke-width="3"/><circle cx="56" cy="56" r="5" fill="#94a3b8"/><path d="M64 76h24M76 72v8M84 72v8" stroke="#94a3b8" stroke-width="3"/></svg>

After

Width:  |  Height:  |  Size: 367 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" fill="none"><rect width="128" height="128" rx="24" fill="#0f172a"/><path d="M40 40h48v48H40z" fill="none" stroke="#3b82f6" stroke-width="3"/><path d="M52 52h24v24H52z" fill="#3b82f6" opacity="0.4"/><path d="M58 58h12v12H58z" fill="#60a5fa"/></svg>

After

Width:  |  Height:  |  Size: 310 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" fill="none"><rect width="128" height="128" rx="24" fill="#1a1a2e"/><path d="M32 48h20v8H32zm44 0h20v8H76zm-22 24h20v8H54z" fill="#7c3aed"/><circle cx="52" cy="52" r="4" fill="#a78bfa"/><circle cx="76" cy="76" r="4" fill="#a78bfa"/><path d="M52 52l22 24" stroke="#7c3aed" stroke-width="2"/></svg>

After

Width:  |  Height:  |  Size: 358 B

View File

@@ -0,0 +1,52 @@
<html>
<head>
<title>404</title>
<style>
@font-face {
font-family: 'colfax-web';
font-display: swap;
src: url('https://www.datocms.com/fonts/colfax-web-regular.woff2') format('woff2'), url('https://www.datocms.com/fonts/colfax-web-regular.woff') format('woff');
}
@font-face {
font-family: 'colfax-web';
font-display: swap;
font-weight: 700;
src: url('https://www.datocms.com/fonts/colfax-web-bold.woff2') format('woff2'), url('https://www.datocms.com/fonts/colfax-web-bold.woff') format('woff');
}
* {
margin: 0;
}
body {
box-sizing: border-box;
font-family: colfax;
display: grid;
align-items: center;
justify-content: center;
height: 100%;
text-align: center;
padding: 20px;
}
main {
display: flex;
flex-direction: column;
gap: 30px;
}
h1 {
font-size: 50px;
}
</style>
</head>
<body>
<main>
<h1>404</h1>
<p>Not Found</p>
</main>
</body>
</html>

View File

@@ -0,0 +1,37 @@
<svg xmlns="http://www.w3.org/2000/svg" width="95" height="88" fill="none">
<path fill="#FFD21E" d="M47.21 76.5a34.75 34.75 0 1 0 0-69.5 34.75 34.75 0 0 0 0 69.5Z" />
<path
fill="#FF9D0B"
d="M81.96 41.75a34.75 34.75 0 1 0-69.5 0 34.75 34.75 0 0 0 69.5 0Zm-73.5 0a38.75 38.75 0 1 1 77.5 0 38.75 38.75 0 0 1-77.5 0Z"
/>
<path
fill="#3A3B45"
d="M58.5 32.3c1.28.44 1.78 3.06 3.07 2.38a5 5 0 1 0-6.76-2.07c.61 1.15 2.55-.72 3.7-.32ZM34.95 32.3c-1.28.44-1.79 3.06-3.07 2.38a5 5 0 1 1 6.76-2.07c-.61 1.15-2.56-.72-3.7-.32Z"
/>
<path
fill="#FF323D"
d="M46.96 56.29c9.83 0 13-8.76 13-13.26 0-2.34-1.57-1.6-4.09-.36-2.33 1.15-5.46 2.74-8.9 2.74-7.19 0-13-6.88-13-2.38s3.16 13.26 13 13.26Z"
/>
<path
fill="#3A3B45"
fill-rule="evenodd"
d="M39.43 54a8.7 8.7 0 0 1 5.3-4.49c.4-.12.81.57 1.24 1.28.4.68.82 1.37 1.24 1.37.45 0 .9-.68 1.33-1.35.45-.7.89-1.38 1.32-1.25a8.61 8.61 0 0 1 5 4.17c3.73-2.94 5.1-7.74 5.1-10.7 0-2.34-1.57-1.6-4.09-.36l-.14.07c-2.31 1.15-5.39 2.67-8.77 2.67s-6.45-1.52-8.77-2.67c-2.6-1.29-4.23-2.1-4.23.29 0 3.05 1.46 8.06 5.47 10.97Z"
clip-rule="evenodd"
/>
<path
fill="#FF9D0B"
d="M70.71 37a3.25 3.25 0 1 0 0-6.5 3.25 3.25 0 0 0 0 6.5ZM24.21 37a3.25 3.25 0 1 0 0-6.5 3.25 3.25 0 0 0 0 6.5ZM17.52 48c-1.62 0-3.06.66-4.07 1.87a5.97 5.97 0 0 0-1.33 3.76 7.1 7.1 0 0 0-1.94-.3c-1.55 0-2.95.59-3.94 1.66a5.8 5.8 0 0 0-.8 7 5.3 5.3 0 0 0-1.79 2.82c-.24.9-.48 2.8.8 4.74a5.22 5.22 0 0 0-.37 5.02c1.02 2.32 3.57 4.14 8.52 6.1 3.07 1.22 5.89 2 5.91 2.01a44.33 44.33 0 0 0 10.93 1.6c5.86 0 10.05-1.8 12.46-5.34 3.88-5.69 3.33-10.9-1.7-15.92-2.77-2.78-4.62-6.87-5-7.77-.78-2.66-2.84-5.62-6.25-5.62a5.7 5.7 0 0 0-4.6 2.46c-1-1.26-1.98-2.25-2.86-2.82A7.4 7.4 0 0 0 17.52 48Zm0 4c.51 0 1.14.22 1.82.65 2.14 1.36 6.25 8.43 7.76 11.18.5.92 1.37 1.31 2.14 1.31 1.55 0 2.75-1.53.15-3.48-3.92-2.93-2.55-7.72-.68-8.01.08-.02.17-.02.24-.02 1.7 0 2.45 2.93 2.45 2.93s2.2 5.52 5.98 9.3c3.77 3.77 3.97 6.8 1.22 10.83-1.88 2.75-5.47 3.58-9.16 3.58-3.81 0-7.73-.9-9.92-1.46-.11-.03-13.45-3.8-11.76-7 .28-.54.75-.76 1.34-.76 2.38 0 6.7 3.54 8.57 3.54.41 0 .7-.17.83-.6.79-2.85-12.06-4.05-10.98-8.17.2-.73.71-1.02 1.44-1.02 3.14 0 10.2 5.53 11.68 5.53.11 0 .2-.03.24-.1.74-1.2.33-2.04-4.9-5.2-5.21-3.16-8.88-5.06-6.8-7.33.24-.26.58-.38 1-.38 3.17 0 10.66 6.82 10.66 6.82s2.02 2.1 3.25 2.1c.28 0 .52-.1.68-.38.86-1.46-8.06-8.22-8.56-11.01-.34-1.9.24-2.85 1.31-2.85Z"
/>
<path
fill="#FFD21E"
d="M38.6 76.69c2.75-4.04 2.55-7.07-1.22-10.84-3.78-3.77-5.98-9.3-5.98-9.3s-.82-3.2-2.69-2.9c-1.87.3-3.24 5.08.68 8.01 3.91 2.93-.78 4.92-2.29 2.17-1.5-2.75-5.62-9.82-7.76-11.18-2.13-1.35-3.63-.6-3.13 2.2.5 2.79 9.43 9.55 8.56 11-.87 1.47-3.93-1.71-3.93-1.71s-9.57-8.71-11.66-6.44c-2.08 2.27 1.59 4.17 6.8 7.33 5.23 3.16 5.64 4 4.9 5.2-.75 1.2-12.28-8.53-13.36-4.4-1.08 4.11 11.77 5.3 10.98 8.15-.8 2.85-9.06-5.38-10.74-2.18-1.7 3.21 11.65 6.98 11.76 7.01 4.3 1.12 15.25 3.49 19.08-2.12Z"
/>
<path
fill="#FF9D0B"
d="M77.4 48c1.62 0 3.07.66 4.07 1.87a5.97 5.97 0 0 1 1.33 3.76 7.1 7.1 0 0 1 1.95-.3c1.55 0 2.95.59 3.94 1.66a5.8 5.8 0 0 1 .8 7 5.3 5.3 0 0 1 1.78 2.82c.24.9.48 2.8-.8 4.74a5.22 5.22 0 0 1 .37 5.02c-1.02 2.32-3.57 4.14-8.51 6.1-3.08 1.22-5.9 2-5.92 2.01a44.33 44.33 0 0 1-10.93 1.6c-5.86 0-10.05-1.8-12.46-5.34-3.88-5.69-3.33-10.9 1.7-15.92 2.78-2.78 4.63-6.87 5.01-7.77.78-2.66 2.83-5.62 6.24-5.62a5.7 5.7 0 0 1 4.6 2.46c1-1.26 1.98-2.25 2.87-2.82A7.4 7.4 0 0 1 77.4 48Zm0 4c-.51 0-1.13.22-1.82.65-2.13 1.36-6.25 8.43-7.76 11.18a2.43 2.43 0 0 1-2.14 1.31c-1.54 0-2.75-1.53-.14-3.48 3.91-2.93 2.54-7.72.67-8.01a1.54 1.54 0 0 0-.24-.02c-1.7 0-2.45 2.93-2.45 2.93s-2.2 5.52-5.97 9.3c-3.78 3.77-3.98 6.8-1.22 10.83 1.87 2.75 5.47 3.58 9.15 3.58 3.82 0 7.73-.9 9.93-1.46.1-.03 13.45-3.8 11.76-7-.29-.54-.75-.76-1.34-.76-2.38 0-6.71 3.54-8.57 3.54-.42 0-.71-.17-.83-.6-.8-2.85 12.05-4.05 10.97-8.17-.19-.73-.7-1.02-1.44-1.02-3.14 0-10.2 5.53-11.68 5.53-.1 0-.19-.03-.23-.1-.74-1.2-.34-2.04 4.88-5.2 5.23-3.16 8.9-5.06 6.8-7.33-.23-.26-.57-.38-.98-.38-3.18 0-10.67 6.82-10.67 6.82s-2.02 2.1-3.24 2.1a.74.74 0 0 1-.68-.38c-.87-1.46 8.05-8.22 8.55-11.01.34-1.9-.24-2.85-1.31-2.85Z"
/>
<path
fill="#FFD21E"
d="M56.33 76.69c-2.75-4.04-2.56-7.07 1.22-10.84 3.77-3.77 5.97-9.3 5.97-9.3s.82-3.2 2.7-2.9c1.86.3 3.23 5.08-.68 8.01-3.92 2.93.78 4.92 2.28 2.17 1.51-2.75 5.63-9.82 7.76-11.18 2.13-1.35 3.64-.6 3.13 2.2-.5 2.79-9.42 9.55-8.55 11 .86 1.47 3.92-1.71 3.92-1.71s9.58-8.71 11.66-6.44c2.08 2.27-1.58 4.17-6.8 7.33-5.23 3.16-5.63 4-4.9 5.2.75 1.2 12.28-8.53 13.36-4.4 1.08 4.11-11.76 5.3-10.97 8.15.8 2.85 9.05-5.38 10.74-2.18 1.69 3.21-11.65 6.98-11.76 7.01-4.31 1.12-15.26 3.49-19.08-2.12Z"
/>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" fill="none"><rect width="128" height="128" rx="24" fill="#1e293b"/><circle cx="64" cy="64" r="28" fill="none" stroke="#60a5fa" stroke-width="3"/><path d="M64 36v56M36 64h56" stroke="#60a5fa" stroke-width="2" opacity="0.3"/><circle cx="64" cy="64" r="8" fill="#3b82f6"/></svg>

After

Width:  |  Height:  |  Size: 338 B

View File

@@ -0,0 +1,18 @@
<svg width="234" height="42" viewBox="0 0 234 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 30H6V18H18V30Z" fill="#4B4646"/>
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="#B7B1B1"/>
<path d="M48 30H36V18H48V30Z" fill="#4B4646"/>
<path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="#B7B1B1"/>
<path d="M84 24V30H66V24H84Z" fill="#4B4646"/>
<path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="#B7B1B1"/>
<path d="M108 36H96V18H108V36Z" fill="#4B4646"/>
<path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="#B7B1B1"/>
<path d="M144 30H126V18H144V30Z" fill="#4B4646"/>
<path d="M144 12H126V30H144V36H120V6H144V12Z" fill="#F1ECEC"/>
<path d="M168 30H156V18H168V30Z" fill="#4B4646"/>
<path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="#F1ECEC"/>
<path d="M198 30H186V18H198V30Z" fill="#4B4646"/>
<path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="#F1ECEC"/>
<path d="M234 24V30H216V24H234Z" fill="#4B4646"/>
<path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="#F1ECEC"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,18 @@
<svg width="234" height="42" viewBox="0 0 234 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 30H6V18H18V30Z" fill="#4B4646"/>
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="#B7B1B1"/>
<path d="M48 30H36V18H48V30Z" fill="#4B4646"/>
<path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="#B7B1B1"/>
<path d="M84 24V30H66V24H84Z" fill="#4B4646"/>
<path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="#B7B1B1"/>
<path d="M108 36H96V18H108V36Z" fill="#4B4646"/>
<path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="#B7B1B1"/>
<path d="M144 30H126V18H144V30Z" fill="#4B4646"/>
<path d="M144 12H126V30H144V36H120V6H144V12Z" fill="#F1ECEC"/>
<path d="M168 30H156V18H168V30Z" fill="#4B4646"/>
<path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="#F1ECEC"/>
<path d="M198 30H186V18H198V30Z" fill="#4B4646"/>
<path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="#F1ECEC"/>
<path d="M234 24V30H216V24H234Z" fill="#4B4646"/>
<path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="#F1ECEC"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" fill="none"><rect width="128" height="128" rx="24" fill="#0c0a09"/><text x="64" y="74" font-family="sans-serif" font-size="36" font-weight="bold" fill="#fbbf24" text-anchor="middle">P</text><circle cx="64" cy="60" r="30" fill="none" stroke="#fbbf24" stroke-width="2" opacity="0.3"/></svg>

After

Width:  |  Height:  |  Size: 351 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" fill="none"><rect width="128" height="128" rx="24" fill="#1a1a2e"/><text x="64" y="70" font-family="sans-serif" font-size="40" font-weight="bold" fill="#f97316" text-anchor="middle">SD</text><text x="64" y="96" font-family="sans-serif" font-size="16" fill="#94a3b8" text-anchor="middle">WebUI</text></svg>

After

Width:  |  Height:  |  Size: 368 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" fill="none"><rect width="128" height="128" rx="24" fill="#1a1a2e"/><circle cx="64" cy="50" r="16" fill="none" stroke="#10b981" stroke-width="3"/><circle cx="44" cy="86" r="12" fill="none" stroke="#10b981" stroke-width="3"/><circle cx="84" cy="86" r="12" fill="none" stroke="#10b981" stroke-width="3"/><path d="M56 62l-8 16M72 62l8 16M52 86h24" stroke="#10b981" stroke-width="2"/></svg>

After

Width:  |  Height:  |  Size: 448 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" fill="none"><rect width="128" height="128" rx="24" fill="#1a1a2e"/><path d="M64 24L104 96H24L64 24z" fill="#4285F4" opacity="0.8"/><path d="M64 44L84 80H44L64 44z" fill="#34A853" opacity="0.9"/><circle cx="64" cy="68" r="8" fill="#FBBC04"/></svg>

After

Width:  |  Height:  |  Size: 309 B

1
public/providers/zai.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" fill="none"><rect width="128" height="128" rx="24" fill="#1e1b4b"/><text x="64" y="76" font-family="sans-serif" font-size="48" font-weight="bold" fill="#818cf8" text-anchor="middle">Z</text><text x="64" y="100" font-family="sans-serif" font-size="18" fill="#6366f1" text-anchor="middle">.AI</text></svg>

After

Width:  |  Height:  |  Size: 366 B

View File

@@ -0,0 +1,84 @@
#!/usr/bin/env node
import fs from "node:fs/promises";
import path from "node:path";
import { spawn } from "node:child_process";
/**
* This repository contains a legacy `app/` snapshot (packaging/runtime artifacts)
* alongside the active Next.js source in `src/app/`. Next.js route discovery scans
* both and fails the build on legacy files. We temporarily move the legacy folder
* out of the project root during `next build`, then restore it in all outcomes.
*/
const projectRoot = process.cwd();
const legacyAppDir = path.join(projectRoot, "app");
const backupDir = path.join(projectRoot, `.app-build-backup-${process.pid}-${Date.now()}`);
async function exists(targetPath) {
try {
await fs.access(targetPath);
return true;
} catch {
return false;
}
}
function runNextBuild() {
return new Promise((resolve) => {
const nextBin = path.join(projectRoot, "node_modules", "next", "dist", "bin", "next");
const child = spawn(process.execPath, [nextBin, "build"], {
cwd: projectRoot,
stdio: "inherit",
env: process.env,
});
const forward = (signal) => {
if (!child.killed) child.kill(signal);
};
process.on("SIGINT", forward);
process.on("SIGTERM", forward);
child.on("exit", (code, signal) => {
process.off("SIGINT", forward);
process.off("SIGTERM", forward);
if (signal) {
resolve({ code: 1, signal });
return;
}
resolve({ code: code ?? 1, signal: null });
});
});
}
async function main() {
let moved = false;
try {
if (await exists(legacyAppDir)) {
await fs.rename(legacyAppDir, backupDir);
moved = true;
}
const result = await runNextBuild();
process.exitCode = result.code;
} catch (error) {
console.error("[build-next-isolated] Build failed:", error);
process.exitCode = 1;
} finally {
if (moved) {
try {
await fs.rename(backupDir, legacyAppDir);
} catch (restoreError) {
console.error(
`[build-next-isolated] Failed to restore legacy app dir from ${backupDir}:`,
restoreError
);
process.exitCode = 1;
}
}
}
}
await main();

View File

@@ -9,15 +9,15 @@ import { bootstrapEnv } from "./bootstrap-env.mjs";
const mode = process.argv[2] === "start" ? "start" : "dev";
const runtimePorts = resolveRuntimePorts();
// Load .env / server.env first so PORT / DASHBOARD_PORT from files affect --port below.
const env = bootstrapEnv();
const runtimePorts = resolveRuntimePorts(env);
const { dashboardPort } = runtimePorts;
// Auto-generate secrets on first run, merge .env + process.env
const env = bootstrapEnv();
const args = ["./node_modules/next/dist/bin/next", mode, "--port", String(dashboardPort)];
// Default: use webpack (stable). Set OMNIROUTE_USE_TURBOPACK=1 to use Turbopack (faster dev).
if (mode === "dev" && process.env.OMNIROUTE_USE_TURBOPACK !== "1") {
// Default: use webpack (stable). Set OMNIROUTE_USE_TURBOPACK=1 in .env for Turbopack (faster dev).
// Must read merged `env` from bootstrap — .env is not applied to process.env in the launcher.
if (mode === "dev" && env.OMNIROUTE_USE_TURBOPACK !== "1") {
args.splice(2, 0, "--webpack");
}

View File

@@ -7,10 +7,8 @@ import {
} from "./runtime-env.mjs";
import { bootstrapEnv } from "./bootstrap-env.mjs";
const runtimePorts = resolveRuntimePorts();
// Auto-generate secrets on first run, merge .env + process.env
const env = bootstrapEnv();
const runtimePorts = resolveRuntimePorts(env);
spawnWithForwardedSignals("node", ["server.js"], {
stdio: "inherit",

View File

@@ -5,10 +5,14 @@ export function parsePort(value, fallback) {
return Number.isFinite(parsed) && parsed > 0 && parsed <= 65535 ? parsed : fallback;
}
export function resolveRuntimePorts() {
const basePort = parsePort(process.env.PORT || "20128", 20128);
const apiPort = parsePort(process.env.API_PORT || String(basePort), basePort);
const dashboardPort = parsePort(process.env.DASHBOARD_PORT || String(basePort), basePort);
/**
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [fromEnv]
* Defaults to process.env. Pass bootstrap `merged` so project `.env` PORT applies before spawn.
*/
export function resolveRuntimePorts(fromEnv = process.env) {
const basePort = parsePort(fromEnv.PORT || "20128", 20128);
const apiPort = parsePort(fromEnv.API_PORT || String(basePort), basePort);
const dashboardPort = parsePort(fromEnv.DASHBOARD_PORT || String(basePort), basePort);
return { basePort, apiPort, dashboardPort };
}

View File

@@ -23,6 +23,9 @@ export default function HomePageClient({ machineId }) {
const [selectedProvider, setSelectedProvider] = useState(null);
const [providerMetrics, setProviderMetrics] = useState({});
const [versionInfo, setVersionInfo] = useState<any>(null);
const [updating, setUpdating] = useState(false);
useEffect(() => {
if (typeof window !== "undefined") {
setBaseUrl(`${window.location.origin}/v1`);
@@ -31,10 +34,11 @@ export default function HomePageClient({ machineId }) {
const fetchData = useCallback(async () => {
try {
const [provRes, modelsRes, metricsRes] = await Promise.all([
const [provRes, modelsRes, metricsRes, versionRes] = await Promise.all([
fetch("/api/providers"),
fetch("/api/models"),
fetch("/api/provider-metrics"),
fetch("/api/system/version"),
]);
if (provRes.ok) {
const provData = await provRes.json();
@@ -48,6 +52,10 @@ export default function HomePageClient({ machineId }) {
const metricsData = await metricsRes.json();
setProviderMetrics(metricsData.metrics || {});
}
if (versionRes.ok) {
const versionData = await versionRes.json();
setVersionInfo(versionData);
}
} catch (e) {
console.log("Error fetching data:", e);
} finally {
@@ -123,6 +131,27 @@ export default function HomePageClient({ machineId }) {
},
];
const handleUpdate = async () => {
const notify = useNotificationStore.getState();
setUpdating(true);
try {
notify.info(t("updateStarted") || "Update process started...");
const res = await fetch("/api/system/version", { method: "POST" });
const data = await res.json();
if (res.ok && data.success) {
notify.success(
data.message || "Update initiated successfully. The system will restart shortly."
);
} else {
notify.error(data.error || "Failed to start update.");
setUpdating(false);
}
} catch {
notify.error("Network error while trying to update.");
setUpdating(false);
}
};
if (loading) {
return (
<div className="flex flex-col gap-8">
@@ -136,6 +165,30 @@ export default function HomePageClient({ machineId }) {
return (
<div className="flex flex-col gap-8">
{/* Update Notification Banner */}
{versionInfo?.updateAvailable && (
<div className="bg-primary/10 border border-primary/20 text-primary px-5 py-4 rounded-xl flex items-center justify-between min-h-[64px]">
<div className="flex items-center gap-4">
<span className="material-symbols-outlined text-[24px]">system_update_alt</span>
<div>
<p className="font-semibold text-sm">Update Available: v{versionInfo.latest}</p>
<p className="text-xs opacity-80 mt-0.5">
{t("updateAvailableDesc") ||
`You are currently using v${versionInfo.current}. Update to access the latest features and bug fixes.`}
</p>
</div>
</div>
<Button
size="sm"
onClick={handleUpdate}
disabled={updating}
className="shrink-0 ml-4 font-semibold"
>
{updating ? t("updating") || "Updating..." : t("updateNow") || "Update Now"}
</Button>
</div>
)}
{/* Quick Start */}
<Card>
<div className="flex flex-col gap-5">

View File

@@ -1,9 +1,11 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { Card, Button, Input } from "@/shared/components";
import { useTranslations } from "next-intl";
import { AI_PROVIDERS } from "@/shared/constants/config";
import { CLI_COMPAT_PROVIDER_IDS } from "@/shared/constants/cliCompatProviders";
interface AgentInfo {
id: string;
@@ -137,8 +139,9 @@ export default function AgentsPage() {
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex flex-col items-center justify-center min-h-[400px] gap-3">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent" />
<p className="text-sm text-text-muted">{t("scanning")}</p>
</div>
);
}
@@ -179,6 +182,51 @@ export default function AgentsPage() {
</div>
)}
{/* Setup Guide */}
<Card>
<div className="flex items-center justify-between gap-3 mb-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-500">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
support
</span>
</div>
<h3 className="text-lg font-semibold">{t("setupGuideTitle")}</h3>
</div>
<Link
href="/dashboard/cli-tools"
className="text-xs px-2.5 py-1.5 rounded-lg border border-border/60 hover:bg-surface/40 transition-colors"
>
{t("openCliTools")}
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div className="rounded-lg border border-border/50 bg-black/[0.02] dark:bg-white/[0.02] p-3">
<div className="flex items-center gap-2 mb-1.5">
<span className="material-symbols-outlined text-[16px] text-blue-500">radar</span>
<p className="text-sm font-medium">{t("setupGuideDetectCliTitle")}</p>
</div>
<p className="text-xs text-text-muted">{t("setupGuideDetectCliDesc")}</p>
</div>
<div className="rounded-lg border border-border/50 bg-black/[0.02] dark:bg-white/[0.02] p-3">
<div className="flex items-center gap-2 mb-1.5">
<span className="material-symbols-outlined text-[16px] text-amber-500">build</span>
<p className="text-sm font-medium">{t("setupGuideCustomAgentTitle")}</p>
</div>
<p className="text-xs text-text-muted">{t("setupGuideCustomAgentDesc")}</p>
</div>
<div className="rounded-lg border border-border/50 bg-black/[0.02] dark:bg-white/[0.02] p-3">
<div className="flex items-center gap-2 mb-1.5">
<span className="material-symbols-outlined text-[16px] text-emerald-500">
terminal
</span>
<p className="text-sm font-medium">{t("setupGuideCommandMissingTitle")}</p>
</div>
<p className="text-xs text-text-muted">{t("setupGuideCommandMissingDesc")}</p>
</div>
</div>
</Card>
{/* CLI Fingerprint Matching */}
<Card>
<div className="flex items-center gap-3 mb-4">
@@ -192,20 +240,7 @@ export default function AgentsPage() {
<div className="flex flex-col gap-4">
<p className="text-sm text-text-muted">{ts("cliFingerprintDesc")}</p>
<div className="flex flex-wrap gap-2">
{(
[
"codex",
"claude",
"github",
"antigravity",
"kiro",
"cursor",
"kimi-coding",
"kilocode",
"cline",
"qwen",
] as const
).map((providerId) => {
{CLI_COMPAT_PROVIDER_IDS.map((providerId) => {
const providerMeta = Object.values(AI_PROVIDERS).find(
(p: any) => p.id === providerId
) as any;
@@ -327,22 +362,18 @@ export default function AgentsPage() {
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-base font-semibold">OpenCode Integration</h3>
<h3 className="text-base font-semibold">{t("opencodeIntegration")}</h3>
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 font-medium">
opencode {agents.find((a) => a.id === "opencode")?.version} detected
{t("opencodeDetected", {
version: agents.find((a) => a.id === "opencode")?.version || "",
})}
</span>
</div>
<p className="text-sm text-text-muted mb-3">
Generate a ready-to-use{" "}
<code className="text-xs bg-black/[0.06] dark:bg-white/[0.08] px-1 py-0.5 rounded">
opencode.json
</code>{" "}
with your OmniRoute base URL and all available models drop it in your project root
and run{" "}
<code className="text-xs bg-black/[0.06] dark:bg-white/[0.08] px-1 py-0.5 rounded">
opencode
</code>
.
{t("opencodeDesc", {
configFile: "opencode.json",
command: "opencode",
})}
</p>
<Button
variant="secondary"
@@ -399,7 +430,9 @@ export default function AgentsPage() {
<span className="material-symbols-outlined text-[16px] mr-1">
{opencodeConfigDone ? "check" : "download"}
</span>
{opencodeConfigDone ? "Downloaded!" : "Download opencode.json"}
{opencodeConfigDone
? t("downloaded")
: t("downloadConfig", { file: "opencode.json" })}
</Button>
</div>
</div>

View File

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

View File

@@ -33,6 +33,16 @@ export default function CLIToolsPageClient({ machineId }) {
const [apiKeys, setApiKeys] = useState([]);
const [toolStatuses, setToolStatuses] = useState({});
const [statusesLoaded, setStatusesLoaded] = useState(false);
const translateOrFallback = useCallback(
(key, fallback, values = undefined) => {
try {
return t(key, values);
} catch {
return fallback;
}
},
[t]
);
useEffect(() => {
fetchConnections();
@@ -291,8 +301,42 @@ export default function CLIToolsPageClient({ machineId }) {
}
};
const getToolDocsHref = (toolId, tool) => {
if (typeof tool.docsUrl === "string" && tool.docsUrl.trim()) {
return tool.docsUrl.trim();
}
return `/docs?section=cli-tools&tool=${toolId}`;
};
const getToolUseCase = (toolId, tool) => {
const fallbackDescription = translateOrFallback(`toolDescriptions.${toolId}`, tool.description);
return translateOrFallback(`toolUseCases.${toolId}`, fallbackDescription);
};
return (
<div className="flex flex-col gap-6">
<Card>
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-primary/10 text-primary">
<span className="material-symbols-outlined text-[20px]">tips_and_updates</span>
</div>
<div className="flex-1 min-w-0">
<h2 className="text-sm font-semibold">{t("howItWorks")}</h2>
<div className="mt-2 grid grid-cols-1 md:grid-cols-3 gap-2 text-xs text-text-muted">
<div className="rounded-lg border border-border/50 bg-black/[0.02] dark:bg-white/[0.02] p-2.5">
{t("installationGuide")}
</div>
<div className="rounded-lg border border-border/50 bg-black/[0.02] dark:bg-white/[0.02] p-2.5">
{t("configureEndpoint")}
</div>
<div className="rounded-lg border border-border/50 bg-black/[0.02] dark:bg-white/[0.02] p-2.5">
{t("testConnection")}
</div>
</div>
</div>
</div>
</Card>
{!hasActiveProviders && (
<Card className="border-yellow-500/50 bg-yellow-500/5">
<div className="flex items-center gap-3">
@@ -308,7 +352,38 @@ export default function CLIToolsPageClient({ machineId }) {
)}
<div className="flex flex-col gap-4">
{Object.entries(CLI_TOOLS).map(([toolId, tool]) => renderToolCard(toolId, tool))}
{Object.entries(CLI_TOOLS).map(([toolId, tool]) => {
const docsHref = getToolDocsHref(toolId, tool);
const isExternalDocs = /^https?:\/\//i.test(docsHref);
return (
<div key={toolId} className="flex flex-col gap-2.5">
{renderToolCard(toolId, tool)}
<div className="rounded-lg border border-border/50 bg-black/[0.02] dark:bg-white/[0.02] p-3">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2.5">
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-wide text-text-muted">
{t("whenToUseLabel")}
</p>
<p className="text-xs text-text-muted mt-1 break-words">
{getToolUseCase(toolId, tool)}
</p>
</div>
<a
href={docsHref}
target={isExternalDocs ? "_blank" : undefined}
rel={isExternalDocs ? "noopener noreferrer" : undefined}
className="inline-flex items-center gap-1.5 text-xs text-primary hover:text-primary/80 transition-colors whitespace-nowrap"
>
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
menu_book
</span>
{t("openToolDocs")}
</a>
</div>
</div>
</div>
);
})}
</div>
</div>
);

View File

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

View File

@@ -13,29 +13,21 @@ import {
EmptyState,
} from "@/shared/components";
import Tooltip from "@/shared/components/Tooltip";
import ModelRoutingSection from "@/shared/components/ModelRoutingSection";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { useNotificationStore } from "@/store/notificationStore";
import { ROUTING_STRATEGIES } from "@/shared/constants/routingStrategies";
import { useTranslations } from "next-intl";
// Validate combo name: letters, numbers, -, _, /, .
const VALID_NAME_REGEX = /^[a-zA-Z0-9_/.-]+$/;
const STRATEGY_OPTIONS = [
{ value: "priority", labelKey: "priority", descKey: "priorityDesc", icon: "sort" },
{ value: "weighted", labelKey: "weighted", descKey: "weightedDesc", icon: "percent" },
{ value: "round-robin", labelKey: "roundRobin", descKey: "roundRobinDesc", icon: "autorenew" },
{ value: "random", labelKey: "random", descKey: "randomDesc", icon: "shuffle" },
{ value: "least-used", labelKey: "leastUsed", descKey: "leastUsedDesc", icon: "low_priority" },
{ value: "cost-optimized", labelKey: "costOpt", descKey: "costOptimizedDesc", icon: "savings" },
{
value: "fill-first",
labelKey: "fillFirst",
descKey: "fillFirstDesc",
icon: "stacked_bar_chart",
},
{ value: "p2c", labelKey: "p2c", descKey: "p2cDesc", icon: "compare_arrows" },
{ value: "strict-random", labelKey: "strictRandom", descKey: "strictRandomDesc", icon: "casino" },
];
const STRATEGY_OPTIONS = ROUTING_STRATEGIES.map((strategy) => ({
value: strategy.value,
labelKey: strategy.labelKey,
descKey: strategy.combosDescKey,
icon: strategy.icon,
}));
const STRATEGY_GUIDANCE_FALLBACK = {
priority: {
@@ -598,6 +590,9 @@ export default function CombosPage() {
</Card>
)}
{/* Model Routing Rules (#563) */}
<ModelRoutingSection combos={combos} />
{/* Combos List */}
{combos.length === 0 ? (
<EmptyState

View File

@@ -1,46 +1,842 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { Card } from "@/shared/components";
import { useTranslations } from "next-intl";
/* ─── Types ──────────────────────────────────────────── */
interface Endpoint {
method: string;
path: string;
tags: string[];
summary: string;
description: string;
security: boolean;
parameters: any[];
requestBody: boolean;
responses: string[];
}
interface CatalogData {
info: { title?: string; version?: string; description?: string };
servers: { url: string; description?: string }[];
tags: { name: string; description?: string }[];
endpoints: Endpoint[];
schemas: string[];
}
interface WebhookItem {
id: string;
url: string;
events: string[];
secret: string | null;
enabled: boolean;
description: string;
created_at: string;
last_triggered_at: string | null;
last_status: number | null;
failure_count: number;
}
interface TryItResult {
status: number;
statusText: string;
headers: Record<string, string>;
body: any;
latencyMs: number;
contentType: string;
}
const METHOD_COLORS: Record<string, string> = {
GET: "bg-emerald-500/15 text-emerald-500 border-emerald-500/30",
POST: "bg-blue-500/15 text-blue-500 border-blue-500/30",
PUT: "bg-amber-500/15 text-amber-500 border-amber-500/30",
PATCH: "bg-orange-500/15 text-orange-500 border-orange-500/30",
DELETE: "bg-red-500/15 text-red-500 border-red-500/30",
};
const WEBHOOK_EVENTS = [
"request.completed",
"request.failed",
"provider.error",
"provider.recovered",
"quota.exceeded",
"combo.switched",
];
/* ─── Main Component ─────────────────────────────────── */
export default function ApiEndpointsTab() {
const t = useTranslations("endpoints");
const [catalog, setCatalog] = useState<CatalogData | null>(null);
const [loading, setLoading] = useState(true);
const [section, setSection] = useState<"catalog" | "webhooks">("catalog");
const [search, setSearch] = useState("");
const [expandedEndpoint, setExpandedEndpoint] = useState<string | null>(null);
const [selectedTag, setSelectedTag] = useState<string | null>(null);
// Try It state
const [tryingEndpoint, setTryingEndpoint] = useState<string | null>(null);
const [tryBody, setTryBody] = useState("");
const [tryResult, setTryResult] = useState<TryItResult | null>(null);
const [trying, setTrying] = useState(false);
// Webhooks state
const [webhooks, setWebhooks] = useState<WebhookItem[]>([]);
const [webhooksLoading, setWebhooksLoading] = useState(false);
const [showAddWebhook, setShowAddWebhook] = useState(false);
const [whUrl, setWhUrl] = useState("");
const [whEvents, setWhEvents] = useState<string[]>(["*"]);
const [whDesc, setWhDesc] = useState("");
const [testingWebhookId, setTestingWebhookId] = useState<string | null>(null);
// Load catalog
const loadCatalog = async () => {
try {
const res = await fetch("/api/openapi/spec");
if (res.ok) {
const data = await res.json();
return data;
}
} catch {}
return null;
};
useEffect(() => {
let cancelled = false;
loadCatalog().then((data) => {
if (!cancelled) {
setCatalog(data);
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, []);
// Load webhooks
const fetchWebhooksData = async (): Promise<WebhookItem[]> => {
try {
const res = await fetch("/api/webhooks");
if (res.ok) {
const data = await res.json();
return data.webhooks || [];
}
} catch {}
return [];
};
const loadWebhooks = async () => {
setWebhooksLoading(true);
const data = await fetchWebhooksData();
setWebhooks(data);
setWebhooksLoading(false);
};
useEffect(() => {
if (section !== "webhooks") return;
let cancelled = false;
fetchWebhooksData().then((data) => {
if (!cancelled) {
setWebhooks(data);
setWebhooksLoading(false);
}
});
return () => {
cancelled = true;
};
}, [section]);
// Filter endpoints
const filteredEndpoints = useMemo(() => {
if (!catalog) return [];
return catalog.endpoints.filter((ep) => {
const matchesSearch =
!search ||
ep.path.toLowerCase().includes(search.toLowerCase()) ||
ep.summary.toLowerCase().includes(search.toLowerCase()) ||
ep.tags.some((t) => t.toLowerCase().includes(search.toLowerCase()));
const matchesTag = !selectedTag || ep.tags.includes(selectedTag);
return matchesSearch && matchesTag;
});
}, [catalog, search, selectedTag]);
// Group by tag
const groupedEndpoints = useMemo(() => {
const groups: Record<string, Endpoint[]> = {};
for (const ep of filteredEndpoints) {
const tag = ep.tags[0] || "Other";
if (!groups[tag]) groups[tag] = [];
groups[tag].push(ep);
}
return groups;
}, [filteredEndpoints]);
const allTags = useMemo(() => {
if (!catalog) return [];
return catalog.tags.map((t) => t.name);
}, [catalog]);
// Try It handler
const handleTryIt = async (ep: Endpoint) => {
const key = `${ep.method}:${ep.path}`;
if (tryingEndpoint === key) {
setTryingEndpoint(null);
setTryResult(null);
return;
}
setTryingEndpoint(key);
setTryResult(null);
setTryBody(ep.method === "GET" ? "" : "{\n \n}");
};
const executeTryIt = async (ep: Endpoint) => {
setTrying(true);
try {
const res = await fetch("/api/openapi/try", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
method: ep.method,
path: ep.path.replace("/api/", "/"),
body: tryBody ? JSON.parse(tryBody) : undefined,
}),
});
if (res.ok) setTryResult(await res.json());
} catch (err: any) {
setTryResult({
status: 0,
statusText: "Error",
headers: {},
body: { error: err.message },
latencyMs: 0,
contentType: "application/json",
});
}
setTrying(false);
};
// Webhook handlers
const addWebhook = async () => {
if (!whUrl.trim()) return;
try {
await fetch("/api/webhooks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: whUrl, events: whEvents, description: whDesc }),
});
setWhUrl("");
setWhEvents(["*"]);
setWhDesc("");
setShowAddWebhook(false);
await loadWebhooks();
} catch {}
};
const toggleWebhook = async (wh: WebhookItem) => {
try {
await fetch(`/api/webhooks/${wh.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: !wh.enabled }),
});
setWebhooks((prev) => prev.map((w) => (w.id === wh.id ? { ...w, enabled: !w.enabled } : w)));
} catch {}
};
const deleteWebhook = async (id: string) => {
if (!confirm("Delete this webhook?")) return;
try {
await fetch(`/api/webhooks/${id}`, { method: "DELETE" });
setWebhooks((prev) => prev.filter((w) => w.id !== id));
} catch {}
};
const testWebhook = async (id: string) => {
setTestingWebhookId(id);
try {
await fetch(`/api/webhooks/${id}/test`, { method: "POST" });
await loadWebhooks();
} catch {}
setTestingWebhookId(null);
};
if (loading) {
return (
<div className="p-6 max-w-6xl mx-auto">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-white/5 rounded-lg w-1/3" />
<div className="h-64 bg-white/5 rounded-xl" />
</div>
</div>
);
}
return (
<div className="p-6 max-w-5xl mx-auto space-y-6">
<Card className="p-8 text-center space-y-4">
<div className="flex items-center justify-center size-16 rounded-2xl bg-primary/10 text-primary mx-auto">
<span className="material-symbols-outlined text-[32px]">code</span>
</div>
<h2 className="text-xl font-semibold">{t("apiEndpointsTitle")}</h2>
<p className="text-sm text-text-muted max-w-md mx-auto">{t("apiEndpointsDescription")}</p>
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-amber-500/10 text-amber-500 text-sm font-medium">
<span className="material-symbols-outlined text-[18px]">construction</span>
{t("comingSoon")}
</div>
</Card>
<div className="p-6 max-w-6xl mx-auto space-y-5">
{/* Header with spec info */}
{catalog && (
<Card className="p-5">
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-xl bg-primary/10">
<span className="material-symbols-outlined text-primary text-[20px]">api</span>
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-base font-semibold">{catalog.info.title || "API"}</h2>
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary font-mono font-semibold">
{catalog.info.version}
</span>
</div>
<p className="text-xs text-text-muted mt-0.5">
{catalog.endpoints.length} endpoints across {allTags.length} categories
</p>
</div>
</div>
<div className="flex items-center gap-2">
<a
href="/docs/openapi.yaml"
download
className="flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium rounded-lg
bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
>
<span className="material-symbols-outlined text-[14px]">download</span>
YAML
</a>
<a
href="/api/openapi/spec"
target="_blank"
rel="noopener"
className="flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium rounded-lg
bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
>
<span className="material-symbols-outlined text-[14px]">open_in_new</span>
JSON
</a>
</div>
</div>
</Card>
)}
<Card className="p-5">
<h3 className="text-sm font-semibold mb-3">{t("plannedFeatures")}</h3>
<ul className="space-y-2 text-sm text-text-muted">
<li className="flex items-center gap-2">
<span className="material-symbols-outlined text-[16px] text-primary">check_circle</span>
{t("featureRestApi")}
</li>
<li className="flex items-center gap-2">
<span className="material-symbols-outlined text-[16px] text-primary">check_circle</span>
{t("featureWebhooks")}
</li>
<li className="flex items-center gap-2">
<span className="material-symbols-outlined text-[16px] text-primary">check_circle</span>
{t("featureSwagger")}
</li>
<li className="flex items-center gap-2">
<span className="material-symbols-outlined text-[16px] text-primary">check_circle</span>
{t("featureAuth")}
</li>
</ul>
</Card>
{/* Section tabs */}
<div className="flex gap-1 p-1 rounded-xl bg-black/5 dark:bg-white/[0.03] w-fit">
{[
{ id: "catalog" as const, label: "API Catalog", icon: "menu_book" },
{ id: "webhooks" as const, label: "Webhooks", icon: "webhook" },
].map((tab) => (
<button
key={tab.id}
onClick={() => setSection(tab.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg transition-all
${
section === tab.id
? "bg-white dark:bg-white/10 text-text-main shadow-sm"
: "text-text-muted hover:text-text-main"
}`}
>
<span className="material-symbols-outlined text-[14px]">{tab.icon}</span>
{tab.label}
</button>
))}
</div>
{/* ═══ API CATALOG ═══ */}
{section === "catalog" && catalog && (
<>
{/* Search & filter */}
<div className="flex items-center gap-2">
<div className="relative flex-1 max-w-md">
<span className="material-symbols-outlined text-[16px] text-text-muted absolute left-3 top-1/2 -translate-y-1/2">
search
</span>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search endpoints..."
className="w-full pl-9 pr-3 py-2 text-xs rounded-lg border border-black/10 dark:border-white/10
bg-white dark:bg-black/20 focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div className="flex gap-1 flex-wrap">
<button
onClick={() => setSelectedTag(null)}
className={`px-2 py-1 text-[10px] font-medium rounded-md transition-colors
${
!selectedTag
? "bg-primary/10 text-primary"
: "bg-black/5 dark:bg-white/5 text-text-muted hover:text-text-main"
}`}
>
All
</button>
{allTags.slice(0, 8).map((tag) => (
<button
key={tag}
onClick={() => setSelectedTag(selectedTag === tag ? null : tag)}
className={`px-2 py-1 text-[10px] font-medium rounded-md transition-colors
${
selectedTag === tag
? "bg-primary/10 text-primary"
: "bg-black/5 dark:bg-white/5 text-text-muted hover:text-text-main"
}`}
>
{tag}
</button>
))}
{allTags.length > 8 && (
<span className="px-2 py-1 text-[10px] text-text-muted">
+{allTags.length - 8} more
</span>
)}
</div>
</div>
{/* Endpoint groups */}
{Object.entries(groupedEndpoints).map(([tag, endpoints]) => (
<Card key={tag} className="overflow-hidden">
<div className="flex items-center gap-2 px-4 py-3 border-b border-black/5 dark:border-white/5">
<span className="material-symbols-outlined text-[14px] text-primary">folder</span>
<h3 className="text-xs font-semibold uppercase tracking-wider text-text-muted">
{tag}
</h3>
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-black/5 dark:bg-white/5 text-text-muted">
{endpoints.length}
</span>
<div className="flex-1 h-px bg-border/30" />
</div>
<div className="divide-y divide-black/[0.03] dark:divide-white/[0.03]">
{endpoints.map((ep) => {
const key = `${ep.method}:${ep.path}`;
const isExpanded = expandedEndpoint === key;
const isTrying = tryingEndpoint === key;
return (
<div key={key}>
<div
className="flex items-center gap-3 px-4 py-2.5 hover:bg-black/[0.02] dark:hover:bg-white/[0.02]
cursor-pointer transition-colors"
onClick={() => setExpandedEndpoint(isExpanded ? null : key)}
>
<span
className={`text-[10px] font-bold px-2 py-0.5 rounded border min-w-[42px] text-center font-mono
${METHOD_COLORS[ep.method] || "bg-gray-500/15 text-gray-500"}`}
>
{ep.method}
</span>
<code className="text-xs font-mono text-text-main flex-1 truncate">
{ep.path}
</code>
<span className="text-[11px] text-text-muted hidden sm:inline truncate max-w-[200px]">
{ep.summary}
</span>
{ep.security && (
<span
className="material-symbols-outlined text-[12px] text-amber-500"
title="Requires auth"
>
lock
</span>
)}
<span
className={`material-symbols-outlined text-[14px] text-text-muted transition-transform ${isExpanded ? "rotate-180" : ""}`}
>
expand_more
</span>
</div>
{/* Expanded detail */}
{isExpanded && (
<div className="px-4 pb-3 space-y-3 bg-black/[0.01] dark:bg-white/[0.01]">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs text-text-main font-medium">{ep.summary}</p>
{ep.description && ep.description !== ep.summary && (
<p className="text-[11px] text-text-muted mt-1">{ep.description}</p>
)}
<div className="flex items-center gap-3 mt-2 text-[10px] text-text-muted">
{ep.security && (
<span className="flex items-center gap-1">
<span className="material-symbols-outlined text-[12px] text-amber-500">
lock
</span>
Bearer Auth
</span>
)}
{ep.requestBody && (
<span className="flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">
description
</span>
Request Body
</span>
)}
<span className="flex items-center gap-1">
Responses: {ep.responses.join(", ")}
</span>
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
handleTryIt(ep);
}}
className={`flex items-center gap-1 px-2.5 py-1 text-[10px] font-semibold rounded-lg
transition-colors shrink-0
${
isTrying
? "bg-primary text-white"
: "bg-primary/10 text-primary hover:bg-primary/20"
}`}
>
<span className="material-symbols-outlined text-[12px]">
{isTrying ? "close" : "play_arrow"}
</span>
{isTrying ? "Close" : "Try It"}
</button>
</div>
{/* curl example */}
<div className="rounded-lg bg-black/5 dark:bg-black/30 p-3">
<p className="text-[9px] font-semibold text-text-muted uppercase tracking-wider mb-1">
Example
</p>
<code className="text-[11px] font-mono text-text-main break-all">
curl -X {ep.method} http://localhost:20128
{ep.path.replace("/api/", "/")}
{ep.security ? ' -H "Authorization: Bearer YOUR_KEY"' : ""}
{ep.requestBody
? " -H \"Content-Type: application/json\" -d '{...}'"
: ""}
</code>
</div>
{/* Try It panel */}
{isTrying && (
<div className="rounded-lg border border-primary/20 bg-primary/[0.02] p-3 space-y-3">
{ep.method !== "GET" && (
<div>
<label className="text-[9px] font-semibold text-text-muted uppercase tracking-wider">
Request Body (JSON)
</label>
<textarea
value={tryBody}
onChange={(e) => setTryBody(e.target.value)}
rows={4}
className="w-full mt-1 px-3 py-2 text-xs font-mono rounded-lg border border-black/10
dark:border-white/10 bg-white dark:bg-black/30 focus:outline-none
focus:ring-1 focus:ring-primary resize-none"
placeholder='{ "model": "gpt-4o", "messages": [...] }'
/>
</div>
)}
<button
onClick={() => executeTryIt(ep)}
disabled={trying}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-lg
bg-primary text-white hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
<span className="material-symbols-outlined text-[14px]">
{trying ? "hourglass_empty" : "send"}
</span>
{trying ? "Sending..." : "Send Request"}
</button>
{tryResult && (
<div className="rounded-lg bg-black/5 dark:bg-black/30 p-3 space-y-2">
<div className="flex items-center gap-3 text-xs">
<span
className={`px-2 py-0.5 rounded font-bold ${
tryResult.status >= 200 && tryResult.status < 300
? "bg-emerald-500/15 text-emerald-500"
: tryResult.status >= 400
? "bg-red-500/15 text-red-500"
: "bg-amber-500/15 text-amber-500"
}`}
>
{tryResult.status} {tryResult.statusText}
</span>
<span className="text-text-muted">{tryResult.latencyMs}ms</span>
</div>
<pre className="text-[11px] font-mono text-text-main overflow-auto max-h-[300px] whitespace-pre-wrap">
{typeof tryResult.body === "string"
? tryResult.body
: JSON.stringify(tryResult.body, null, 2)}
</pre>
</div>
)}
</div>
)}
</div>
)}
</div>
);
})}
</div>
</Card>
))}
{filteredEndpoints.length === 0 && (
<Card className="p-8 text-center">
<span className="material-symbols-outlined text-[32px] text-text-muted">
search_off
</span>
<p className="text-sm text-text-muted mt-2">No endpoints match your filter</p>
</Card>
)}
{/* Schemas section */}
{catalog.schemas.length > 0 && (
<Card className="p-4">
<div className="flex items-center gap-2 mb-3">
<span className="material-symbols-outlined text-[14px] text-primary">
data_object
</span>
<h3 className="text-xs font-semibold uppercase tracking-wider text-text-muted">
Data Schemas
</h3>
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-black/5 dark:bg-white/5 text-text-muted">
{catalog.schemas.length}
</span>
</div>
<div className="flex flex-wrap gap-1.5">
{catalog.schemas.map((schema) => (
<span
key={schema}
className="text-[10px] px-2 py-1 rounded-md bg-purple-500/10 text-purple-500 dark:text-purple-300 font-mono"
>
{schema}
</span>
))}
</div>
</Card>
)}
</>
)}
{/* ═══ WEBHOOKS ═══ */}
{section === "webhooks" && (
<>
<Card className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-primary text-[18px]">webhook</span>
<div>
<h3 className="text-sm font-semibold">Event Webhooks</h3>
<p className="text-[11px] text-text-muted">
Receive HTTP callbacks when events occur in OmniRoute
</p>
</div>
</div>
{!showAddWebhook && (
<button
onClick={() => setShowAddWebhook(true)}
className="flex items-center gap-1 px-2.5 py-1 text-xs font-medium rounded-lg
bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
>
<span className="material-symbols-outlined text-[14px]">add</span>
Add Webhook
</button>
)}
</div>
{/* Add webhook form */}
{showAddWebhook && (
<div className="mb-4 p-3 rounded-lg border border-primary/20 bg-primary/[0.03] space-y-2">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div>
<label className="text-[10px] font-medium text-text-muted uppercase tracking-wider">
Webhook URL
</label>
<input
value={whUrl}
onChange={(e) => setWhUrl(e.target.value)}
placeholder="https://example.com/webhook"
className="w-full mt-0.5 px-2.5 py-1.5 text-xs rounded-lg border border-black/10 dark:border-white/10
bg-white dark:bg-black/20 focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<label className="text-[10px] font-medium text-text-muted uppercase tracking-wider">
Description
</label>
<input
value={whDesc}
onChange={(e) => setWhDesc(e.target.value)}
placeholder="Production monitoring"
className="w-full mt-0.5 px-2.5 py-1.5 text-xs rounded-lg border border-black/10 dark:border-white/10
bg-white dark:bg-black/20 focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
</div>
<div>
<label className="text-[10px] font-medium text-text-muted uppercase tracking-wider">
Events
</label>
<div className="flex flex-wrap gap-1.5 mt-1">
<button
onClick={() => setWhEvents(["*"])}
className={`px-2 py-0.5 text-[10px] font-medium rounded transition-colors
${
whEvents.includes("*")
? "bg-primary/10 text-primary"
: "bg-black/5 dark:bg-white/5 text-text-muted"
}`}
>
All events
</button>
{WEBHOOK_EVENTS.map((ev) => (
<button
key={ev}
onClick={() => {
if (whEvents.includes("*")) {
setWhEvents([ev]);
} else if (whEvents.includes(ev)) {
setWhEvents(whEvents.filter((e) => e !== ev));
} else {
setWhEvents([...whEvents, ev]);
}
}}
className={`px-2 py-0.5 text-[10px] font-medium rounded transition-colors
${
whEvents.includes(ev) || whEvents.includes("*")
? "bg-primary/10 text-primary"
: "bg-black/5 dark:bg-white/5 text-text-muted"
}`}
>
{ev}
</button>
))}
</div>
</div>
<div className="flex gap-2 mt-2">
<button
onClick={addWebhook}
disabled={!whUrl.trim()}
className="px-3 py-1 text-xs font-medium rounded-lg bg-primary text-white
hover:bg-primary/90 disabled:opacity-40 transition-colors"
>
Save
</button>
<button
onClick={() => setShowAddWebhook(false)}
className="px-3 py-1 text-xs font-medium rounded-lg
bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
>
Cancel
</button>
</div>
</div>
)}
{/* Webhooks list */}
{webhooksLoading ? (
<div className="text-xs text-text-muted py-4 text-center">Loading...</div>
) : webhooks.length === 0 ? (
<div className="text-center py-6">
<span className="material-symbols-outlined text-[32px] text-text-muted">
webhook
</span>
<p className="text-xs text-text-muted mt-2">
No webhooks configured. Add one to receive event notifications.
</p>
</div>
) : (
<div className="space-y-2">
{webhooks.map((wh) => (
<div
key={wh.id}
className={`flex items-center justify-between px-3 py-2.5 rounded-lg border transition-colors
${
wh.enabled
? "border-black/10 dark:border-white/10 bg-white/50 dark:bg-white/[0.02]"
: "border-black/5 dark:border-white/5 opacity-50"
}`}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<code className="text-xs font-mono text-text-main truncate">{wh.url}</code>
{wh.failure_count > 0 && (
<span className="text-[9px] px-1 py-0.5 rounded bg-red-500/10 text-red-500">
{wh.failure_count} failures
</span>
)}
</div>
<div className="flex items-center gap-2 mt-0.5">
{wh.description && (
<span className="text-[10px] text-text-muted">{wh.description}</span>
)}
<span className="text-[9px] text-text-muted">
Events: {wh.events.join(", ")}
</span>
{wh.last_triggered_at && (
<span className="text-[9px] text-text-muted">
Last: {new Date(wh.last_triggered_at).toLocaleString()}
{wh.last_status ? ` (${wh.last_status})` : ""}
</span>
)}
</div>
</div>
<div className="flex items-center gap-1 shrink-0 ml-2">
<button
onClick={() => testWebhook(wh.id)}
disabled={testingWebhookId === wh.id}
className="p-1 rounded hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
title="Send test event"
>
<span
className={`material-symbols-outlined text-[14px] ${testingWebhookId === wh.id ? "animate-spin text-primary" : "text-text-muted"}`}
>
{testingWebhookId === wh.id ? "sync" : "send"}
</span>
</button>
<button
onClick={() => toggleWebhook(wh)}
className="p-1 rounded hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
title={wh.enabled ? "Disable" : "Enable"}
>
<span
className={`material-symbols-outlined text-[14px] ${wh.enabled ? "text-emerald-500" : "text-text-muted"}`}
>
{wh.enabled ? "toggle_on" : "toggle_off"}
</span>
</button>
<button
onClick={() => deleteWebhook(wh.id)}
className="p-1 rounded hover:bg-red-500/10 transition-colors"
title="Delete"
>
<span className="material-symbols-outlined text-[14px] text-red-500">
delete
</span>
</button>
</div>
</div>
))}
</div>
)}
</Card>
{/* Webhook signature info */}
<Card className="p-4">
<div className="flex items-center gap-2 mb-2">
<span className="material-symbols-outlined text-[14px] text-amber-500">vpn_key</span>
<h3 className="text-xs font-semibold">Webhook Signatures</h3>
</div>
<p className="text-[11px] text-text-muted mb-2">
Each webhook delivery includes an{" "}
<code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/5">
X-Webhook-Signature
</code>{" "}
header signed with HMAC-SHA256 using the webhook secret. Verify the signature to
ensure the payload is authentic.
</p>
<div className="rounded-lg bg-black/5 dark:bg-black/30 p-3">
<code className="text-[10px] font-mono text-text-main">
{`const crypto = require('crypto');\nconst sig = 'sha256=' + crypto.createHmac('sha256', secret).update(body).digest('hex');\nif (sig !== req.headers['x-webhook-signature']) throw new Error('Invalid signature');`}
</code>
</div>
</Card>
</>
)}
</div>
);
}

View File

@@ -8,10 +8,11 @@ import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers";
import { useTranslations } from "next-intl";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL || null;
const BUILD_TIME_CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL || null;
const CLOUD_ACTION_TIMEOUT_MS = 15000;
export default function APIPageClient({ machineId }) {
const [resolvedMachineId, setResolvedMachineId] = useState(machineId || "");
const t = useTranslations("endpoint");
const tc = useTranslations("common");
const [loading, setLoading] = useState(true);
@@ -29,7 +30,8 @@ export default function APIPageClient({ machineId }) {
const [syncStep, setSyncStep] = useState(""); // "syncing" | "verifying" | "disabling" | "done" | ""
const [modalSuccess, setModalSuccess] = useState(false); // show success state in modal before closing
const [selectedProvider, setSelectedProvider] = useState(null); // for provider models popup
const [cloudBaseUrl, setCloudBaseUrl] = useState(CLOUD_URL); // dynamic cloud URL from API response
const [cloudBaseUrl, setCloudBaseUrl] = useState(BUILD_TIME_CLOUD_URL); // dynamic cloud URL from API response
const [cloudConfigured, setCloudConfigured] = useState(Boolean(BUILD_TIME_CLOUD_URL));
const [viewTab, setViewTab] = useState("api");
const [mcpStatus, setMcpStatus] = useState<any>(null);
const [a2aStatus, setA2aStatus] = useState<any>(null);
@@ -136,6 +138,15 @@ export default function APIPageClient({ machineId }) {
if (res.ok) {
const data = await res.json();
setCloudEnabled(data.cloudEnabled || false);
if (typeof data.cloudConfigured === "boolean") {
setCloudConfigured(data.cloudConfigured);
}
if (data.cloudUrl) {
setCloudBaseUrl(data.cloudUrl);
}
if (data.machineId) {
setResolvedMachineId(data.machineId);
}
}
} catch (error) {
console.log("Error loading cloud settings:", error);
@@ -144,6 +155,13 @@ export default function APIPageClient({ machineId }) {
const handleCloudToggle = (checked) => {
if (checked) {
if (!cloudConfigured) {
setCloudStatus({
type: "warning",
message: "Cloud sync is not configured on this instance.",
});
return;
}
setShowCloudModal(true);
} else {
setShowDisableModal(true);
@@ -258,7 +276,12 @@ export default function APIPageClient({ machineId }) {
};
const [baseUrl, setBaseUrl] = useState("/v1");
const cloudEndpointNew = cloudBaseUrl ? `${cloudBaseUrl}/v1` : null;
const normalizedCloudBaseUrl = cloudBaseUrl
? resolvedMachineId && !cloudBaseUrl.endsWith(`/${resolvedMachineId}`)
? `${cloudBaseUrl}/${resolvedMachineId}`
: cloudBaseUrl
: null;
const cloudEndpointNew = normalizedCloudBaseUrl ? `${normalizedCloudBaseUrl}/v1` : null;
// Hydration fix: Only access window on client side
useEffect(() => {
@@ -290,12 +313,23 @@ export default function APIPageClient({ machineId }) {
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold">{t("title")}</h2>
<p className="text-sm text-text-muted">
{cloudEnabled ? t("usingCloudProxy") : t("usingLocalServer")}
</p>
{machineId && (
<p className="text-xs text-text-muted mt-1">
{t("machineId", { id: machineId.slice(0, 8) })}
<div className="mt-2">
<Button
size="sm"
variant={cloudEnabled ? "primary" : "secondary"}
icon={cloudEnabled ? "cloud_done" : "dns"}
onClick={() => handleCloudToggle(!cloudEnabled)}
disabled={cloudSyncing || (!cloudEnabled && !cloudConfigured)}
className={
cloudEnabled ? "" : "border-border/70! text-text-muted! hover:text-text!"
}
>
{cloudEnabled ? t("usingCloudProxy") : t("usingLocalServer")}
</Button>
</div>
{resolvedMachineId && (
<p className="text-xs text-text-muted mt-2">
{t("machineId", { id: resolvedMachineId.slice(0, 8) })}
</p>
)}
</div>
@@ -311,7 +345,7 @@ export default function APIPageClient({ machineId }) {
>
{t("disableCloud")}
</Button>
) : (
) : cloudConfigured ? (
<Button
variant="primary"
icon="cloud_upload"
@@ -321,6 +355,10 @@ export default function APIPageClient({ machineId }) {
>
{t("enableCloud")}
</Button>
) : (
<span className="text-xs px-2 py-1 rounded-full bg-surface text-text-muted border border-border/70">
Cloud not configured
</span>
)}
</div>
</div>
@@ -354,16 +392,17 @@ export default function APIPageClient({ machineId }) {
)}
{/* Endpoint URL */}
<div className="flex gap-2 mb-3">
<div className="flex flex-col sm:flex-row gap-2 mb-3">
<Input
value={currentEndpoint}
readOnly
className={`flex-1 font-mono text-sm ${cloudEnabled ? "animate-border-glow" : ""}`}
className={`flex-1 min-w-0 font-mono text-sm ${cloudEnabled ? "animate-border-glow" : ""}`}
/>
<Button
variant="secondary"
icon={copied === "endpoint_url" ? "check" : "content_copy"}
onClick={() => copy(currentEndpoint, "endpoint_url")}
className="shrink-0 self-start sm:self-auto"
>
{copied === "endpoint_url" ? tc("copied") : tc("copy")}
</Button>

View File

@@ -134,7 +134,7 @@ export default function HealthPage() {
);
}
const { system, providerHealth, rateLimitStatus, lockouts } = data;
const { system, providerHealth, providerSummary, rateLimitStatus, lockouts } = data;
const cbEntries = Object.entries(providerHealth || {});
const lockoutEntries = Object.entries(lockouts || {});
@@ -235,11 +235,37 @@ export default function HealthPage() {
</div>
<span className="text-sm text-text-muted">{t("providers")}</span>
</div>
<p className="text-xl font-semibold text-text-main">{cbEntries.length}</p>
<p className="text-xs text-text-muted mt-1">
{t("healthyCount", {
count: cbEntries.filter(([, v]: [string, any]) => v.state === "CLOSED").length,
<p className="text-xl font-semibold text-text-main">
{providerSummary?.configuredCount ?? cbEntries.length}
</p>
<p
className="text-[11px] text-text-muted mt-1 inline-flex items-center gap-1"
title={t("configuredProvidersHint")}
>
{t("configuredProvidersLabel")}
<span className="material-symbols-outlined text-[12px]" aria-hidden="true">
help
</span>
</p>
<p
className="text-xs text-text-muted inline-flex items-center gap-1"
title={t("activeProvidersHint")}
>
{t("activeProviders", { count: providerSummary?.activeCount ?? 0 })}
<span className="material-symbols-outlined text-[12px]" aria-hidden="true">
info
</span>
</p>
<p
className="text-xs text-text-muted inline-flex items-center gap-1"
title={t("monitoredProvidersHint")}
>
{t("monitoredProviders", {
count: providerSummary?.monitoredCount ?? cbEntries.length,
})}
<span className="material-symbols-outlined text-[12px]" aria-hidden="true">
info
</span>
</p>
</Card>
</div>

View File

@@ -358,6 +358,14 @@ function parseApiError(raw: any, statusCode: number): { message: string; isCrede
return { message: String(msg), isCredentials };
}
/** Format file size to human-readable string */
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
/** Render image result thumbnails */
function ImageResults({ data }: { data: any }) {
const images: Array<{ url?: string; b64_json?: string; revised_prompt?: string }> =
@@ -427,7 +435,9 @@ export default function MediaPageClient() {
const [speechFormat, setSpeechFormat] = useState("mp3");
// Transcription-specific
const MAX_TRANSCRIPTION_FILE_SIZE = 4 * 1024 * 1024 * 1024; // 4 GB
const [audioFile, setAudioFile] = useState<File | null>(null);
const [fileSizeError, setFileSizeError] = useState<string | null>(null);
// Fix #390: Track which local providers (sdwebui, comfyui) are actually configured
// so we can hide them when they haven't been set up in the providers page
@@ -743,18 +753,35 @@ export default function MediaPageClient() {
{/* Transcription: file upload */}
{activeTab === "transcription" ? (
<div>
<label className="block text-sm font-medium text-text-main mb-2">Audio File</label>
<label className="block text-sm font-medium text-text-main mb-2">Audio / Video File</label>
<input
type="file"
accept="audio/*,video/*"
onChange={(e) => setAudioFile(e.target.files?.[0] ?? null)}
onChange={(e) => {
const file = e.target.files?.[0] ?? null;
setFileSizeError(null);
if (file && file.size > MAX_TRANSCRIPTION_FILE_SIZE) {
setFileSizeError(`File too large (${formatFileSize(file.size)}). Maximum allowed: 4 GB.`);
setAudioFile(null);
e.target.value = "";
return;
}
setAudioFile(file);
}}
className="w-full px-3 py-2 rounded-lg bg-surface border border-black/10 dark:border-white/10 text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:bg-primary/10 file:text-primary file:text-sm"
/>
{audioFile && (
<p className="text-xs text-text-muted mt-1">
{audioFile.name} ({(audioFile.size / 1024).toFixed(0)} KB)
{fileSizeError && (
<p className="text-xs text-red-400 mt-1 flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">error</span>
{fileSizeError}
</p>
)}
{audioFile && !fileSizeError && (
<p className="text-xs text-text-muted mt-1">
{audioFile.name} ({formatFileSize(audioFile.size)})
</p>
)}
<p className="text-[10px] text-text-muted/60 mt-1">Supports audio and video files up to 4 GB</p>
</div>
) : (
/* Prompt / Text */

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