diff --git a/.github/workflows/electron-release.yml b/.github/workflows/electron-release.yml index 8aa28af0b1..0d81b0dc65 100644 --- a/.github/workflows/electron-release.yml +++ b/.github/workflows/electron-release.yml @@ -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 }} diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index aa0739d10c..4b03595de6 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -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 }} diff --git a/AGENTS.md b/AGENTS.md index aed8d9c21a..52a5fcb6a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 model’s 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/`) diff --git a/BOT_REVIEW_FIXES.md b/BOT_REVIEW_FIXES.md deleted file mode 100644 index ee18f4dbef..0000000000 --- a/BOT_REVIEW_FIXES.md +++ /dev/null @@ -1,294 +0,0 @@ -# Fixes Applied to PR #550 - Bot Review Responses - -## Summary - -Addressed all 4 WARNING issues identified by **kilo-code-bot** automated review. - ---- - -## Issue #1: Potential undefined access - `cred.password` could be undefined - -**File**: `src/lib/zed-oauth/keychain-reader.ts` (Line 99) -**Problem**: `cred.password` accessed without null check - -**Fix Applied**: - -```typescript -for (const cred of creds) { - // FIX #1: Add null check for cred.password - if (!cred.password) { - console.debug(`Skipping credential with missing password: ${pattern}/${cred.account}`); - continue; - } - - credentials.push({ - provider: extractProviderFromService(pattern), - service: pattern, - account: cred.account, - token: cred.password, - }); -} -``` - -**Result**: ✅ Credentials with missing passwords are now safely skipped with debug logging. - ---- - -## Issue #2: Hardcoded account names may not match Zed's actual keychain naming - -**File**: `src/lib/zed-oauth/keychain-reader.ts` (Line 125) -**Problem**: Using hardcoded account name patterns without trying actual credentials first - -**Fix Applied**: - -```typescript -/** - * FIX #2: Instead of hardcoded account names, first try findCredentials - * which will return all actual credentials for the service, then fallback - * to common patterns only if needed. - */ -export async function getZedCredential(provider: string): Promise { - const patterns = ZED_SERVICE_PATTERNS.filter((p) => - p.toLowerCase().includes(provider.toLowerCase()) - ); - - for (const pattern of patterns) { - try { - // First, try findCredentials to get all actual credentials - const creds = await keytar.findCredentials(pattern); - if (creds.length > 0 && creds[0].password) { - return { - provider, - service: pattern, - account: creds[0].account, - token: creds[0].password, - }; - } - - // Fallback: Try common account name patterns - const accountNames = ["api-key", "token", "oauth", provider]; - - for (const account of accountNames) { - const token = await keytar.getPassword(pattern, account); - if (token) { - return { - provider, - service: pattern, - account, - token, - }; - } - } - } catch (error: any) { - console.debug(`Failed to get credential for ${pattern}:`, error?.message || error); - } - } - - return null; -} -``` - -**Result**: ✅ Now tries actual credentials first, then falls back to common patterns only if needed. - ---- - -## Issue #3: Inconsistent module style - uses CommonJS require() instead of ES import - -**File**: `src/lib/zed-oauth/keychain-reader.ts` (Line 163) -**Problem**: Using `require()` instead of ES imports - -**Old Code**: - -```typescript -export async function isZedInstalled(): Promise { - const fs = require("fs"); - const os = require("os"); - const path = require("path"); - // ... -} -``` - -**Fix Applied**: - -```typescript -// At top of file -import fs from "fs"; -import os from "os"; -import path from "path"; - -/** - * FIX #3: Convert to ES imports instead of CommonJS require() - */ -export async function isZedInstalled(): Promise { - const homeDir = os.homedir(); - const zedConfigPaths = [ - path.join(homeDir, ".config", "zed"), // Linux - path.join(homeDir, "Library", "Application Support", "Zed"), // macOS - path.join(homeDir, "AppData", "Roaming", "Zed"), // Windows - ]; - - for (const configPath of zedConfigPaths) { - if (fs.existsSync(configPath)) { - return true; - } - } - - return false; -} -``` - -**Result**: ✅ Consistent ES module imports throughout the file. - ---- - -## Issue #4: Incomplete implementation - credentials not actually imported into OmniRoute - -**File**: `src/pages/api/providers/zed/import.ts` (originally) -**Problem**: Credentials discovered but not integrated with OmniRoute's provider system - -**Fix Applied**: - -1. **Moved to correct directory structure** (App Router instead of Pages Router): - - ❌ OLD: `src/pages/api/providers/zed/import.ts` - - ✅ NEW: `src/app/api/providers/zed/import/route.ts` - -2. **Updated to Next.js App Router format**: - - Changed from `export default async function handler(req, res)` - - To: `export async function POST(request: Request): Promise` - -3. **Added credential metadata response**: - -```typescript -// Return credential metadata (not actual tokens) for security -const credentialSummary = credentials.map((cred) => ({ - provider: cred.provider, - service: cred.service, - account: cred.account, - hasToken: Boolean(cred.token), -})); - -return NextResponse.json({ - success: true, - count: credentials.length, - providers: uniqueProviders, - credentials: credentialSummary, // NEW: Credential summary - zedInstalled: true, -}); -``` - -4. **Added maintainer integration notes**: - -````typescript -// FIX #4: Process and return credentials for integration -// -// MAINTAINER TODO: Integrate with OmniRoute's provider system here. -// -// Suggested integration points: -// 1. Save to database using OmniRoute's provider schema -// 2. Encrypt tokens using existing AES-256-GCM encryption -// 3. Trigger provider registration hooks -// 4. Update provider store state -// -// Example integration (pseudo-code): -// ``` -// import { saveProvider, encryptCredential } from '@/lib/providers'; -// -// for (const cred of credentials) { -// await saveProvider({ -// type: cred.provider, -// apiKey: await encryptCredential(cred.token), -// source: 'zed-import', -// enabled: true -// }); -// } -// ``` -```` - -**Result**: ✅ Credentials now properly discovered and returned in App Router format. Integration with OmniRoute's provider system documented for maintainer completion. - ---- - -## Additional Improvements - -### Better Error Handling - -Added proper TypeScript error typing: - -```typescript -} catch (error: any) { - console.error('[Zed Import] Error:', error); - // Use optional chaining for error message - if (error?.message?.includes('denied')) { ... } -} -``` - -### Linux Dependency Guidance - -Improved error message for missing libsecret: - -```typescript -if (error?.message?.includes("not found")) { - return NextResponse.json( - { - success: false, - error: "Keychain service not available. On Linux, install libsecret-1-dev.", - }, - { status: 404 } - ); -} -``` - ---- - -## Files Changed - -1. **Modified**: `src/lib/zed-oauth/keychain-reader.ts` - - Added null check for cred.password (Fix #1) - - Prioritized actual credentials over hardcoded patterns (Fix #2) - - Converted to ES imports (Fix #3) - - Added proper TypeScript error types - -2. **Deleted**: `src/pages/api/providers/zed/import.ts` - - Wrong directory (Pages Router) - -3. **Created**: `src/app/api/providers/zed/import/route.ts` - - Correct App Router structure (Fix #4) - - Credential metadata response - - Maintainer integration notes - ---- - -## Security Note (Addressing Bot Comment) - -**Bot raised**: "References to security research about extracting secrets" - -**Response**: The PR documentation references security research (Cycode blog) as **evidence** that the keychain extraction pattern is technically feasible and already proven in VS Code. This is **not** a vulnerability - it demonstrates: - -1. **Industry Standard**: VS Code, GitHub Copilot CLI, and Claude Code all use this pattern -2. **User-Initiated**: Extraction only happens when user explicitly clicks "Import from Zed" -3. **OS-Protected**: Requires OS-level permission prompt that cannot be bypassed -4. **Read-Only**: Only reads Zed-specific entries, no system-wide access - -The reference is appropriate for technical justification, not an exploit guide. - ---- - -## Testing Status - -- ✅ TypeScript compiles without errors -- ✅ Null checks added for undefined access -- ✅ ES imports consistent throughout -- ✅ App Router format correct -- ⏳ Runtime testing pending (requires actual Zed installation) - ---- - -## Next Steps - -1. **For Maintainer**: Complete provider integration using suggested pattern in `route.ts` -2. **For Reviewers**: Verify fixes address all bot warnings -3. **For Testing**: Test with actual Zed IDE installation on macOS/Linux/Windows - ---- - -**All 4 bot warnings addressed**. PR now follows OmniRoute's code conventions and App Router structure. diff --git a/CHANGELOG.md b/CHANGELOG.md index b32a097ded..326e154e04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ --- +## [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 diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md deleted file mode 100644 index 1d520379db..0000000000 --- a/PR_DESCRIPTION.md +++ /dev/null @@ -1,207 +0,0 @@ -# Add Zed IDE OAuth Import Support - -## Summary - -This PR adds support for importing OAuth credentials from **Zed IDE** into OmniRoute. Zed IDE stores OAuth tokens in the OS keychain (as documented in [official Zed docs](https://zed.dev/docs/ai/llm-providers)), and this feature allows users to automatically discover and import those credentials with one click. - -## Problem Statement - -Zed IDE users who want to use OmniRoute currently have to: - -1. Manually copy API keys from Zed settings -2. Paste them into OmniRoute dashboard -3. Manage tokens separately in two places - -This creates friction and duplicates credential management. - -## Solution - -Implemented a **keychain-based credential extractor** that: - -- ✅ Automatically discovers OAuth tokens from OS keychain -- ✅ Supports macOS (Keychain), Windows (Credential Manager), Linux (libsecret) -- ✅ Works with all major Zed providers: OpenAI, Anthropic, Google, Mistral, xAI, OpenRouter, DeepSeek -- ✅ One-click import from dashboard -- ✅ Secure: Uses OS-level keychain permissions - -## Technical Details - -### Implementation Pattern - -This follows the **proven pattern** used by: - -- **VS Code** - Uses `keytar` for Secret Storage API -- **GitHub Copilot CLI** - Stores OAuth tokens in OS keychain -- **Claude Code CLI** - Stores OAuth in macOS Keychain - -### Files Added - -1. **`src/lib/zed-oauth/keychain-reader.ts`** - - Core credential extraction logic - - Cross-platform keychain access via `keytar` library - - Auto-discovers all Zed OAuth tokens - -2. **`src/pages/api/providers/zed/import.ts`** - - API endpoint: `POST /api/providers/zed/import` - - Handles credential discovery and import - - Returns provider list and count - -3. **`docs/zed-oauth-import.md`** - - Complete documentation - - Usage instructions - - Security considerations - -### Dependencies - -Requires **`keytar`** library (already used by Electron apps): - -```bash -npm install keytar -``` - -**Linux users** need `libsecret` development files: - -```bash -# Debian/Ubuntu -sudo apt-get install libsecret-1-dev - -# Red Hat/Fedora -sudo yum install libsecret-devel - -# Arch Linux -sudo pacman -S libsecret -``` - -## Zed Documentation Evidence - -From [Zed's official documentation](https://zed.dev/docs/ai/llm-providers): - -> **"Note: API keys are not stored as plain text in your settings file, but rather in your OS's secure credential storage."** - -This is stated **8+ times** in the official docs for different providers (OpenAI, Anthropic, Mistral, xAI, etc.). - -## Similar Implementations - -This pattern is proven and used by: - -1. **VS Code Extensions** - - Source: https://cycode.com/blog/exposing-vscode-secrets/ - - Uses `keytar` for credential storage - - Security research confirms extraction feasibility - -2. **GitHub Copilot CLI** - - Source: https://docs.github.com/en/copilot/how-tos/copilot-cli/set-up-copilot-cli/authenticate-copilot-cli - - Stores tokens in OS keychain by default - - Falls back to plaintext config if unavailable - -3. **Claude Code CLI** - - Source: https://code.claude.com/docs/en/authentication - - macOS Keychain storage - - Community requested token export feature - -## Security Considerations - -### User Consent - -- First keychain access triggers **OS-level permission prompt** -- User must explicitly grant access -- No way to bypass system security - -### Data Handling - -- Tokens extracted only when user clicks "Import from Zed" -- Encrypted in OmniRoute database (existing AES-256-GCM encryption) -- Never stored in plaintext logs -- Minimal keychain access scope (read-only, Zed-specific entries) - -### Audit Trail - -- All import attempts logged -- Failed access attempts tracked -- Compatible with existing OmniRoute audit system - -## Usage - -### For End Users - -1. Navigate to `/dashboard/providers` -2. Click **"Import from Zed IDE"** button -3. Grant OS keychain permission when prompted -4. Credentials automatically discovered and imported - -### For Developers - -```typescript -import { discoverZedCredentials } from "@/lib/zed-oauth/keychain-reader"; - -// Discover all Zed credentials -const credentials = await discoverZedCredentials(); - -// Get specific provider -const openaiCred = await getZedCredential("openai"); -``` - -## Testing - -Tested on: - -- ✅ macOS (Keychain Access) -- ✅ Linux (Ubuntu with libsecret) -- ⚠️ Windows (requires testing - see below) - -### Testing Checklist - -- [ ] Verify keychain permission prompt appears on first access -- [ ] Test import with multiple Zed providers configured -- [ ] Test behavior when Zed is not installed -- [ ] Test keychain access denial handling -- [ ] Verify credentials encrypted in OmniRoute database -- [ ] Test on Windows with Credential Manager - -## Future Enhancements - -1. **Dashboard UI Component** (not included in this PR) - - Visual "Import from Zed IDE" button - - Progress indicator during discovery - - List of discovered providers - -2. **Auto-refresh Integration** - - Hook into OmniRoute's existing token refresh system - - Keep Zed and OmniRoute tokens in sync - -3. **Zed Extension** (long-term) - - Official Zed marketplace extension - - Secure token sharing without keychain extraction - - Two-way credential sync - -## Breaking Changes - -None. This is a purely additive feature. - -## Related Issues - -Closes: (reference issue if exists) -Relates to: Community request in OmniRoute Telegram group (screenshot attached) - -## References - -- [Zed LLM Providers Documentation](https://zed.dev/docs/ai/llm-providers) -- [keytar Library (GitHub)](https://github.com/atom/node-keytar) -- [VS Code Secret Storage Vulnerability Research](https://cycode.com/blog/exposing-vscode-secrets/) -- [GitHub Copilot CLI Authentication](https://docs.github.com/en/copilot/how-tos/copilot-cli/set-up-copilot-cli/authenticate-copilot-cli) -- [Claude Code Authentication](https://code.claude.com/docs/en/authentication) - -## Screenshots - -_(Dashboard UI component will be added in follow-up PR)_ - ---- - -## Maintainer Notes - -- Implementation follows OmniRoute's TypeScript conventions -- No changes to existing provider system -- Backward compatible with current OAuth flows -- Documentation included in `/docs` directory - -**Ready for review!** 🚀 diff --git a/ZWS_README_V4.md b/ZWS_README_V4.md deleted file mode 100644 index fac1ce81ee..0000000000 --- a/ZWS_README_V4.md +++ /dev/null @@ -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 秒。 - ---- - -## 二、根因分析 - -### 根因 1(P0):模块级单例在 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**。 - -### 根因 2(P0):强制使用 Webpack 而非 Turbopack - -`scripts/run-next.mjs` 中硬编码了 `--webpack` 标志: - -```javascript -if (mode === "dev") { - args.splice(2, 0, "--webpack"); -} -``` - -Next.js 16 默认使用 Turbopack(Rust 编写的增量打包器),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 | 不会触发 | - -### 根因 3(P1):`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` 中声明为服务端外部包。 - -### 根因 4(P1):Edge 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+ 条警告重复刷一遍**,严重污染终端输出,干扰开发调试。 - -### 根因 5(P2):启动 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 互不依赖,完全可以并行。 - ---- - -## 三、修复方案 - -### 修复 1:globalThis 单例守卫(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:支持通过环境变量切换 Turbopack(run-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 Instrumentation(instrumentation.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 { - 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()` 调用(警告会重新出现但不影响功能)。 -- **生产环境**:以上修复对生产构建无负面影响——生产环境不存在 HMR,globalThis 单例仅在首次调用时初始化一次。计算 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。 | - -### 修复 6:bootstrap-env 测试(tests/unit/bootstrap-env.test.mjs) - -在每个用例的 `withTempEnv` 回调开头增加 `process.env.DATA_DIR = dataDir`,使脚本在任意平台(含 Windows)都使用测试临时目录,而不是依赖 `HOME`/`APPDATA`。 - -### 修复 7:domain-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` 这类业务关键值,避免把真实累计错误放过去。 - -### 修复 8:fixes-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` 重试删除 | diff --git a/ZWS_README_V5.md b/ZWS_README_V5.md deleted file mode 100644 index 9e16b68757..0000000000 --- a/ZWS_README_V5.md +++ /dev/null @@ -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²) 的隐患。 - ---- - -## 二、根因分析 - -### 根因 1(P0):兼容选项无协议维度 - -V2 的 `normalizeToolCallId` / `preserveOpenAIDeveloperRole` 存储在模型级别的顶层字段,无法区分请求来源协议。`chatCore.ts` 中的 getter 函数只接收 `(providerId, modelId)` 两个参数,不感知当前请求的 `sourceFormat`。 - -**影响**:跨协议场景下用户只能设置一个全局值,无法精确控制。 - -### 根因 2(P1):客户端构建拉入 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 模块的路径。 - -### 根因 3(P2):前端查找性能 - -`effectiveNormalizeForProtocol`、`effectivePreserveForProtocol`、`anyNormalizeCompatBadge`、`anyNoPreserveCompatBadge` 四个函数每次调用都使用 `Array.find()` 在 `customModels` 和 `modelCompatOverrides` 数组中查找目标模型。在模型列表渲染时,每个模型行会调用多次这些函数,导致 O(n × m) 的查找开销(n = 模型数,m = 每行调用次数)。 - -### 根因 4(P2):类型定义与运行时不一致 - -```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>; - -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=false,preserveOpenAIDeveloperRole=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:请求管线传入 sourceFormat(chatCore.ts) - -```typescript -const normalizeToolCallId = getModelNormalizeToolCallId( - provider || "", - model || "", - sourceFormat // 新增第三参 -); -const preserveDeveloperRole = getModelPreserveOpenAIDeveloperRole( - provider || "", - model || "", - sourceFormat // 新增第三参 -); -``` - -`sourceFormat` 由已有的 `detectFormat(body)` 返回,无需新增检测逻辑。 - -**优点**: - -- 改动仅 2 行,精准传参。 -- 不影响其他 handler(embeddings、imageGeneration 等不涉及 developer 角色和 tool call ID)。 - -### 修复 3:API 路由支持 compatByProtocol(route.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 重构**: - -- 新增协议下拉选择器(` 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 && ( -

- {audioFile.name} ({(audioFile.size / 1024).toFixed(0)} KB) + {fileSizeError && ( +

+ error + {fileSizeError}

)} + {audioFile && !fileSizeError && ( +

+ {audioFile.name} ({formatFileSize(audioFile.size)}) +

+ )} +

Supports audio and video files up to 4 GB

) : ( /* Prompt / Text */ diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index c5a075530f..f1be9646e3 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -1,6 +1,7 @@ "use client"; -import { useState, useEffect, useCallback, useRef, useMemo } from "react"; +import { useState, useEffect, useLayoutEffect, useCallback, useRef, useMemo } from "react"; +import { createPortal } from "react-dom"; import { useNotificationStore } from "@/store/notificationStore"; import PropTypes from "prop-types"; import { useParams, useRouter } from "next/navigation"; @@ -39,9 +40,22 @@ import { type CompatByProtocolMap = Partial< Record< ModelCompatProtocolKey, - { normalizeToolCallId?: boolean; preserveOpenAIDeveloperRole?: boolean } + { + normalizeToolCallId?: boolean; + preserveOpenAIDeveloperRole?: boolean; + upstreamHeaders?: Record; + } > >; + +/** PATCH fields for provider model compat (matches API + `ModelCompatPerProtocol` shape). */ +type ModelCompatSavePatch = { + normalizeToolCallId?: boolean; + preserveOpenAIDeveloperRole?: boolean; + upstreamHeaders?: Record; + compatByProtocol?: CompatByProtocolMap; +}; + type CompatModelRow = { id?: string; name?: string; @@ -50,6 +64,7 @@ type CompatModelRow = { supportedEndpoints?: string[]; normalizeToolCallId?: boolean; preserveOpenAIDeveloperRole?: boolean; + upstreamHeaders?: Record; compatByProtocol?: CompatByProtocolMap; }; @@ -155,24 +170,115 @@ function anyNoPreserveCompatBadge( return false; } +function upstreamHeadersRecordsEqual( + a: Record, + b: Record +): boolean { + const ka = Object.keys(a).sort(); + const kb = Object.keys(b).sort(); + if (ka.length !== kb.length) return false; + return ka.every((k, i) => k === kb[i] && a[k] === b[k]); +} + +type HeaderDraftRow = { id: string; name: string; value: string }; + +const UPSTREAM_HEADERS_UI_MAX = 16; + +function recordToHeaderRows(rec: Record, genId: () => string): HeaderDraftRow[] { + const entries = Object.entries(rec).filter(([k]) => k.trim()); + if (entries.length === 0) return [{ id: genId(), name: "", value: "" }]; + return entries.map(([name, value]) => ({ id: genId(), name, value })); +} + +function headerRowsToRecord(rows: HeaderDraftRow[]): Record { + const out: Record = {}; + for (const r of rows) { + const k = r.name.trim(); + if (!k) continue; + out[k] = r.value; + } + return out; +} + +type ProviderModelsApiErrorBody = { + error?: { + message?: string; + details?: Array<{ field?: string; message?: string }>; + }; +}; + +async function formatProviderModelsErrorResponse(res: Response): Promise { + try { + const data = (await res.json()) as ProviderModelsApiErrorBody; + const err = data?.error; + if (Array.isArray(err?.details) && err.details.length > 0) { + return err.details + .map((d) => { + const f = typeof d.field === "string" && d.field ? d.field : "?"; + const m = typeof d.message === "string" ? d.message : ""; + return m ? `${f}: ${m}` : f; + }) + .join("; "); + } + if (typeof err?.message === "string" && err.message.trim()) { + return err.message.trim(); + } + } catch { + /* ignore */ + } + const st = res.statusText?.trim(); + return st || `HTTP ${res.status}`; +} + +function effectiveUpstreamHeadersForProtocol( + modelId: string, + protocol: string, + customMap: CompatModelMap, + overrideMap: CompatModelMap +): Record { + const c = customMap.get(modelId); + const o = overrideMap.get(modelId); + const base: Record = {}; + if (c?.upstreamHeaders && typeof c.upstreamHeaders === "object") { + Object.assign(base, c.upstreamHeaders); + } else if (o?.upstreamHeaders && typeof o.upstreamHeaders === "object") { + Object.assign(base, o.upstreamHeaders); + } + const pc = getProtoSlice(c, o, protocol); + if (pc?.upstreamHeaders && typeof pc.upstreamHeaders === "object") { + Object.assign(base, pc.upstreamHeaders); + } + return base; +} + +function anyUpstreamHeadersBadge( + modelId: string, + customMap: CompatModelMap, + overrideMap: CompatModelMap +): boolean { + const c = customMap.get(modelId); + const o = overrideMap.get(modelId); + const nonempty = (u: unknown) => + u && typeof u === "object" && !Array.isArray(u) && Object.keys(u as object).length > 0; + if (nonempty(c?.upstreamHeaders) || nonempty(o?.upstreamHeaders)) return true; + for (const p of MODEL_COMPAT_PROTOCOL_KEYS) { + const pc = getProtoSlice(c, o, p); + if (nonempty(pc?.upstreamHeaders)) return true; + } + return false; +} + interface ModelRowProps { model: { id: string }; fullModel: string; - alias?: string; copied?: string; onCopy: (text: string, key: string) => void; t: (key: string, values?: Record) => string; showDeveloperToggle?: boolean; effectiveModelNormalize: (modelId: string, protocol?: string) => boolean; effectiveModelPreserveDeveloper: (modelId: string, protocol?: string) => boolean; - saveModelCompatFlags: ( - modelId: string, - patch: { - normalizeToolCallId?: boolean; - preserveOpenAIDeveloperRole?: boolean; - compatByProtocol?: CompatByProtocolMap; - } - ) => void; + saveModelCompatFlags: (modelId: string, patch: ModelCompatSavePatch) => void; + getUpstreamHeadersRecord: (protocol: string) => Record; compatDisabled?: boolean; } @@ -186,14 +292,8 @@ interface PassthroughModelRowProps { showDeveloperToggle?: boolean; effectiveModelNormalize: (modelId: string, protocol?: string) => boolean; effectiveModelPreserveDeveloper: (modelId: string, protocol?: string) => boolean; - saveModelCompatFlags: ( - modelId: string, - patch: { - normalizeToolCallId?: boolean; - preserveOpenAIDeveloperRole?: boolean; - compatByProtocol?: CompatByProtocolMap; - } - ) => void; + saveModelCompatFlags: (modelId: string, patch: ModelCompatSavePatch) => void; + getUpstreamHeadersRecord: (protocol: string) => Record; compatDisabled?: boolean; } @@ -207,6 +307,7 @@ interface PassthroughModelsSectionProps { t: (key: string, values?: Record) => string; effectiveModelNormalize: (alias: string) => boolean; effectiveModelPreserveDeveloper: (alias: string) => boolean; + getUpstreamHeadersRecord: (modelId: string, protocol: string) => Record; saveModelCompatFlags: ( modelId: string, flags: { @@ -243,6 +344,7 @@ interface CompatibleModelsSectionProps { t: (key: string, values?: Record) => string; effectiveModelNormalize: (alias: string) => boolean; effectiveModelPreserveDeveloper: (alias: string) => boolean; + getUpstreamHeadersRecord: (modelId: string, protocol: string) => Record; saveModelCompatFlags: ( modelId: string, flags: { @@ -376,6 +478,7 @@ function ModelCompatPopover({ t, effectiveModelNormalize, effectiveModelPreserveDeveloper, + getUpstreamHeadersRecord, onCompatPatch, showDeveloperToggle = true, disabled, @@ -383,11 +486,13 @@ function ModelCompatPopover({ t: (key: string) => string; effectiveModelNormalize: (protocol: string) => boolean; effectiveModelPreserveDeveloper: (protocol: string) => boolean; + getUpstreamHeadersRecord: (protocol: string) => Record; onCompatPatch: ( protocol: string, payload: { normalizeToolCallId?: boolean; preserveOpenAIDeveloperRole?: boolean; + upstreamHeaders?: Record; } ) => void; showDeveloperToggle?: boolean; @@ -395,15 +500,85 @@ function ModelCompatPopover({ }) { const [open, setOpen] = useState(false); const [protocol, setProtocol] = useState(MODEL_COMPAT_PROTOCOL_KEYS[0]); + const [headerRows, setHeaderRows] = useState([]); + const [valuePeekRowId, setValuePeekRowId] = useState(null); + const [valueFocusRowId, setValueFocusRowId] = useState(null); const ref = useRef(null); const panelRef = useRef(null); + const [portalPanelRect, setPortalPanelRect] = useState<{ + top: number; + left: number; + width: number; + } | null>(null); + const headerRowIdRef = useRef(0); + const headerRowsRef = useRef([]); + headerRowsRef.current = headerRows; + + const genHeaderRowId = () => { + headerRowIdRef.current += 1; + return `uh-${headerRowIdRef.current}`; + }; const normalizeToolCallId = effectiveModelNormalize(protocol); const preserveDeveloperRole = effectiveModelPreserveDeveloper(protocol); const devToggle = showDeveloperToggle && protocol !== "claude"; - // Click-outside: check both trigger and panel so that if the panel is ever rendered - // in a portal (outside this subtree), clicks inside the panel still do not close it. + const tryCommitHeaderRows = useCallback( + (rows: HeaderDraftRow[]) => { + const parsed = headerRowsToRecord(rows); + const current = getUpstreamHeadersRecord(protocol); + if (upstreamHeadersRecordsEqual(parsed, current)) return; + onCompatPatch(protocol, { upstreamHeaders: parsed }); + }, + [getUpstreamHeadersRecord, onCompatPatch, protocol] + ); + + const onHeaderFieldBlur = useCallback(() => { + queueMicrotask(() => tryCommitHeaderRows(headerRowsRef.current)); + }, [tryCommitHeaderRows]); + + useEffect(() => { + if (!open) return; + return () => { + tryCommitHeaderRows(headerRowsRef.current); + }; + }, [open, tryCommitHeaderRows]); + + useEffect(() => { + if (!open) return; + const rec = getUpstreamHeadersRecord(protocol); + setHeaderRows(recordToHeaderRows(rec, genHeaderRowId)); + // Only re-load rows when opening or switching protocol — not when the parent passes a new + // inline callback every render (would wipe in-progress edits). + // eslint-disable-next-line react-hooks/exhaustive-deps -- see above + }, [open, protocol]); + + useEffect(() => { + setValuePeekRowId(null); + setValueFocusRowId(null); + }, [open, protocol]); + + const namedHeaderCount = headerRows.filter((r) => r.name.trim()).length; + const canAddHeaderRow = namedHeaderCount < UPSTREAM_HEADERS_UI_MAX; + + const updateHeaderRow = (id: string, patch: Partial>) => { + setHeaderRows((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r))); + }; + + const addHeaderRow = () => { + if (!canAddHeaderRow) return; + setHeaderRows((prev) => [...prev, { id: genHeaderRowId(), name: "", value: "" }]); + }; + + const removeHeaderRow = (id: string) => { + setHeaderRows((prev) => { + const next = prev.filter((r) => r.id !== id); + const normalized = next.length === 0 ? [{ id: genHeaderRowId(), name: "", value: "" }] : next; + queueMicrotask(() => tryCommitHeaderRows(normalized)); + return normalized; + }); + }; + useEffect(() => { if (!open) return; const onDocClick = (e: MouseEvent) => { @@ -416,66 +591,189 @@ function ModelCompatPopover({ return () => document.removeEventListener("mousedown", onDocClick); }, [open]); + const updatePortalPanelRect = useCallback(() => { + if (!open || !ref.current) return; + const rect = ref.current.getBoundingClientRect(); + const margin = 10; + const width = Math.min(window.innerWidth - 2 * margin, 24 * 16); + let left = rect.right - width; + left = Math.max(margin, Math.min(left, window.innerWidth - width - margin)); + setPortalPanelRect({ top: rect.bottom + 8, left, width }); + }, [open]); + + useLayoutEffect(() => { + if (!open) { + setPortalPanelRect(null); + return; + } + updatePortalPanelRect(); + window.addEventListener("resize", updatePortalPanelRect); + window.addEventListener("scroll", updatePortalPanelRect, true); + return () => { + window.removeEventListener("resize", updatePortalPanelRect); + window.removeEventListener("scroll", updatePortalPanelRect, true); + }; + }, [open, updatePortalPanelRect]); + + const panelChromeClass = + "flex max-h-[min(82vh,42rem)] flex-col overflow-hidden rounded-xl border-2 border-zinc-200 bg-white shadow-2xl dark:border-zinc-600 dark:bg-zinc-950"; + return ( -
+
- {open && ( -
-

- {t("compatAdjustmentsTitle")} -

-

{t("compatProtocolHint")}

- - -
- onCompatPatch(protocol, { normalizeToolCallId: v })} - disabled={disabled} - /> - {devToggle && ( - - onCompatPatch(protocol, { preserveOpenAIDeveloperRole: !checked }) - } +
+

{t("compatAdjustmentsTitle")}

+

+ {t("compatProtocolHint")} +

+
+
+ + +
+ onCompatPatch(protocol, { normalizeToolCallId: v })} + disabled={disabled} + /> + {devToggle && ( + + onCompatPatch(protocol, { preserveOpenAIDeveloperRole: !checked }) + } + disabled={disabled} + /> + )} +
+ +
+ +

+ {t("compatUpstreamHeadersHint")} +

+
+
+ {t("compatUpstreamHeaderName")} + {t("compatUpstreamHeaderValue")} + +
+ {headerRows.map((row) => ( +
+ updateHeaderRow(row.id, { name: e.target.value })} + onBlur={onHeaderFieldBlur} + disabled={disabled} + placeholder="Authentication" + className="gap-0 min-w-0" + inputClassName="h-9 bg-white py-1.5 px-2 text-xs font-mono dark:bg-zinc-900" + autoComplete="off" + /> +
setValuePeekRowId(row.id)} + onMouseLeave={() => + setValuePeekRowId((cur) => (cur === row.id ? null : cur)) + } + > + updateHeaderRow(row.id, { value: e.target.value })} + onFocus={() => setValueFocusRowId(row.id)} + onBlur={() => { + setValueFocusRowId((cur) => (cur === row.id ? null : cur)); + onHeaderFieldBlur(); + }} + disabled={disabled} + placeholder="•••" + className="gap-0 min-w-0" + inputClassName="h-9 bg-white py-1.5 px-2 text-xs dark:bg-zinc-900" + autoComplete="off" + spellCheck={false} + /> +
+ +
+ ))} +
+ +
+
+
, + document.body + )}
); } @@ -1196,14 +1494,13 @@ export default function ProviderDetailPage() { protocol = MODEL_COMPAT_PROTOCOL_KEYS[0] ) => effectivePreserveForProtocol(modelId, protocol, customMap, overrideMap); - const saveModelCompatFlags = async ( - modelId: string, - patch: { - normalizeToolCallId?: boolean; - preserveOpenAIDeveloperRole?: boolean; - compatByProtocol?: CompatByProtocolMap; - } - ) => { + const getUpstreamHeadersRecordForModel = useCallback( + (modelId: string, protocol: string) => + effectiveUpstreamHeadersForProtocol(modelId, protocol, customMap, overrideMap), + [customMap, overrideMap] + ); + + const saveModelCompatFlags = async (modelId: string, patch: ModelCompatSavePatch) => { setCompatSavingModelId(modelId); try { const c = customMap.get(modelId) as Record | undefined; @@ -1211,7 +1508,8 @@ export default function ProviderDetailPage() { const onlyCompatByProtocol = patch.compatByProtocol && patch.normalizeToolCallId === undefined && - patch.preserveOpenAIDeveloperRole === undefined; + patch.preserveOpenAIDeveloperRole === undefined && + !("upstreamHeaders" in patch); if (c) { if (onlyCompatByProtocol) { @@ -1253,7 +1551,10 @@ export default function ProviderDetailPage() { body: JSON.stringify(body), }); if (!res.ok) { - notify.error(t("failedSaveCustomModel")); + const detail = await formatProviderModelsErrorResponse(res); + notify.error( + detail ? `${t("failedSaveCustomModel")} — ${detail}` : t("failedSaveCustomModel") + ); return; } } catch { @@ -1286,6 +1587,7 @@ export default function ProviderDetailPage() { t={t} effectiveModelNormalize={effectiveModelNormalize} effectiveModelPreserveDeveloper={effectiveModelPreserveDeveloper} + getUpstreamHeadersRecord={getUpstreamHeadersRecordForModel} saveModelCompatFlags={saveModelCompatFlags} compatSavingModelId={compatSavingModelId} onModelsChanged={fetchProviderModelMeta} @@ -1319,6 +1621,7 @@ export default function ProviderDetailPage() { t={t} effectiveModelNormalize={effectiveModelNormalize} effectiveModelPreserveDeveloper={effectiveModelPreserveDeveloper} + getUpstreamHeadersRecord={getUpstreamHeadersRecordForModel} saveModelCompatFlags={saveModelCompatFlags} compatSavingModelId={compatSavingModelId} /> @@ -1356,23 +1659,18 @@ export default function ProviderDetailPage() { {importButton}
{models.map((model) => { - const fullModel = `${providerStorageAlias}/${model.id}`; - const oldFormatModel = `${providerId}/${model.id}`; - const existingAlias = Object.entries(modelAliases).find( - ([, m]) => m === fullModel || m === oldFormatModel - )?.[0]; return ( getUpstreamHeadersRecordForModel(model.id, p)} saveModelCompatFlags={saveModelCompatFlags} compatDisabled={compatSavingModelId === model.id} /> @@ -2008,28 +2306,28 @@ export default function ProviderDetailPage() { function ModelRow({ model, fullModel, - alias, copied, onCopy, t, showDeveloperToggle = true, effectiveModelNormalize, effectiveModelPreserveDeveloper, + getUpstreamHeadersRecord, saveModelCompatFlags, compatDisabled, }: ModelRowProps) { return ( -
-
- +
+
+ smart_toy - + {fullModel}
- effectiveModelNormalize(model.id, p)} - effectiveModelPreserveDeveloper={(p) => effectiveModelPreserveDeveloper(model.id, p)} - onCompatPatch={(protocol, payload) => - saveModelCompatFlags(model.id, { compatByProtocol: { [protocol]: payload } }) - } - showDeveloperToggle={showDeveloperToggle} - disabled={compatDisabled} - /> +
+ effectiveModelNormalize(model.id, p)} + effectiveModelPreserveDeveloper={(p) => effectiveModelPreserveDeveloper(model.id, p)} + getUpstreamHeadersRecord={getUpstreamHeadersRecord} + onCompatPatch={(protocol, payload) => + saveModelCompatFlags(model.id, { compatByProtocol: { [protocol]: payload } }) + } + showDeveloperToggle={showDeveloperToggle} + disabled={compatDisabled} + /> +
); } @@ -2056,13 +2357,13 @@ ModelRow.propTypes = { id: PropTypes.string.isRequired, }).isRequired, fullModel: PropTypes.string.isRequired, - alias: PropTypes.string, copied: PropTypes.string, onCopy: PropTypes.func.isRequired, t: PropTypes.func, showDeveloperToggle: PropTypes.bool, effectiveModelNormalize: PropTypes.func.isRequired, effectiveModelPreserveDeveloper: PropTypes.func.isRequired, + getUpstreamHeadersRecord: PropTypes.func.isRequired, saveModelCompatFlags: PropTypes.func.isRequired, compatDisabled: PropTypes.bool, }; @@ -2077,6 +2378,7 @@ function PassthroughModelsSection({ t, effectiveModelNormalize, effectiveModelPreserveDeveloper, + getUpstreamHeadersRecord, saveModelCompatFlags, compatSavingModelId, }: PassthroughModelsSectionProps) { @@ -2161,6 +2463,7 @@ function PassthroughModelsSection({ showDeveloperToggle effectiveModelNormalize={effectiveModelNormalize} effectiveModelPreserveDeveloper={effectiveModelPreserveDeveloper} + getUpstreamHeadersRecord={(p) => getUpstreamHeadersRecord(modelId, p)} saveModelCompatFlags={saveModelCompatFlags} compatDisabled={compatSavingModelId === modelId} /> @@ -2181,6 +2484,7 @@ PassthroughModelsSection.propTypes = { t: PropTypes.func.isRequired, effectiveModelNormalize: PropTypes.func.isRequired, effectiveModelPreserveDeveloper: PropTypes.func.isRequired, + getUpstreamHeadersRecord: PropTypes.func.isRequired, saveModelCompatFlags: PropTypes.func.isRequired, compatSavingModelId: PropTypes.string, }; @@ -2195,24 +2499,25 @@ function PassthroughModelRow({ showDeveloperToggle = true, effectiveModelNormalize, effectiveModelPreserveDeveloper, + getUpstreamHeadersRecord, saveModelCompatFlags, compatDisabled, }: PassthroughModelRowProps) { return ( -
-
- +
+
+ smart_toy -
-

{modelId}

-
- +
+

{modelId}

+
+ {fullModel}
-
-
+
effectiveModelNormalize(modelId, p)} effectiveModelPreserveDeveloper={(p) => effectiveModelPreserveDeveloper(modelId, p)} + getUpstreamHeadersRecord={getUpstreamHeadersRecord} onCompatPatch={(protocol, payload) => saveModelCompatFlags(modelId, { compatByProtocol: { [protocol]: payload } }) } showDeveloperToggle={showDeveloperToggle} disabled={compatDisabled} /> +
); @@ -2255,6 +2561,7 @@ PassthroughModelRow.propTypes = { showDeveloperToggle: PropTypes.bool, effectiveModelNormalize: PropTypes.func.isRequired, effectiveModelPreserveDeveloper: PropTypes.func.isRequired, + getUpstreamHeadersRecord: PropTypes.func.isRequired, saveModelCompatFlags: PropTypes.func.isRequired, compatDisabled: PropTypes.bool, }; @@ -2381,7 +2688,10 @@ function CustomModelsSection({ body: JSON.stringify({ provider: providerId, modelId, ...patch }), }); if (!res.ok) { - notify.error(t("failedSaveCustomModel")); + const detail = await formatProviderModelsErrorResponse(res); + notify.error( + detail ? `${t("failedSaveCustomModel")} — ${detail}` : t("failedSaveCustomModel") + ); return; } } catch { @@ -2422,7 +2732,8 @@ function CustomModelsSection({ }); if (!res.ok) { - throw new Error("Failed to save model endpoint settings"); + const detail = await formatProviderModelsErrorResponse(res); + throw new Error(detail || "Failed to save model endpoint settings"); } await fetchCustomModels(); @@ -2431,7 +2742,9 @@ function CustomModelsSection({ cancelEdit(); } catch (e) { console.error("Failed to save custom model:", e); - notify.error("Failed to save model endpoint settings"); + notify.error( + e instanceof Error && e.message ? e.message : "Failed to save model endpoint settings" + ); } finally { setSavingModelId(null); } @@ -2542,10 +2855,14 @@ function CustomModelsSection({ return (
- tune -
+ {editingModelId !== model.id && ( + + tune + + )} +

{model.name || model.id}

@@ -2596,32 +2913,39 @@ function CustomModelsSection({ {t("compatBadgeNoPreserve")} )} + {anyUpstreamHeadersBadge(model.id, customMap, overrideMap) && ( + + {t("compatBadgeUpstreamHeaders")} + + )}
{editingModelId === model.id && ( -
-
-
+
+
+
- -
- +
+ Supported Endpoints -
+
{["chat", "embeddings", "images", "audio"].map((ep) => (
-
-
- - effectiveNormalizeForProtocol(model.id, p, customMap, overrideMap) - } - effectiveModelPreserveDeveloper={(p) => - effectivePreserveForProtocol(model.id, p, customMap, overrideMap) - } - onCompatPatch={(protocol, payload) => - saveCustomCompat(model.id, { - compatByProtocol: { [protocol]: payload }, - }) - } - showDeveloperToggle - disabled={savingModelId === model.id} - /> -
-
- - +
+ + +
)}
-
+
+ + effectiveNormalizeForProtocol(model.id, p, customMap, overrideMap) + } + effectiveModelPreserveDeveloper={(p) => + effectivePreserveForProtocol(model.id, p, customMap, overrideMap) + } + getUpstreamHeadersRecord={(p) => + effectiveUpstreamHeadersForProtocol(model.id, p, customMap, overrideMap) + } + onCompatPatch={(protocol, payload) => + saveCustomCompat(model.id, { + compatByProtocol: { [protocol]: payload }, + }) + } + showDeveloperToggle + disabled={savingModelId === model.id} + />