diff --git a/README.md b/README.md index 34291fb..5e6c43e 100644 --- a/README.md +++ b/README.md @@ -349,6 +349,7 @@ docker run -d --name metapi \ > [!TIP] > 初始管理员令牌即启动时配置的 `AUTH_TOKEN`。 > 若在 Compose 外运行且未显式设置 `AUTH_TOKEN`,默认为 `change-me-admin-token`(仅用于本地调试)。 +> 桌面安装包首次启动也属于这类场景:如果你没有额外注入 `AUTH_TOKEN`,默认管理员令牌同样是 `change-me-admin-token`。 > 如果在「设置」面板中修改了管理员令牌,后续登录请使用新令牌。 Docker Compose、桌面安装包、反向代理、升级与数据库选项等详见 [部署指南](https://metapi.cita777.me/deployment)。 diff --git a/README_EN.md b/README_EN.md index e357345..7ba84bc 100644 --- a/README_EN.md +++ b/README_EN.md @@ -350,6 +350,7 @@ After starting, visit `http://localhost:4000` and log in with your `AUTH_TOKEN`! > [!TIP] > The initial admin token is the `AUTH_TOKEN` configured at startup. > If running outside Compose without explicitly setting `AUTH_TOKEN`, the default is `change-me-admin-token` (for local debugging only). +> The desktop installer falls into this category on first launch too: if you do not inject `AUTH_TOKEN`, the default admin token is also `change-me-admin-token`. > If you change the admin token in the Settings panel, use the new token for subsequent logins. For Docker Compose, desktop installers, reverse proxy, upgrades, and database options, see [Deployment Guide](docs/deployment.md). diff --git a/docs/deployment.md b/docs/deployment.md index e2418ff..9e11788 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -10,7 +10,7 @@ |------|----------|--------------|----------| | 云服务器 / NAS / 家用主机长期运行 | Docker / Docker Compose / Zeabur | 固定服务地址,例如 `http://your-host:4000` 或反向代理域名 | 你挂载的 `DATA_DIR` / 持久化卷 | | 免费云部署(24h 在线) | Render + TiDB + UptimeRobot | Render 分配的 `.onrender.com` 域名或自定义域名 | TiDB Serverless(外部 MySQL 数据库) | -| 个人电脑本地使用 | 桌面版安装包 | 桌面窗口;如需本机客户端直连,使用日志中打印的 `http://127.0.0.1:` | Electron `app.getPath('userData')/data` | +| 个人电脑本地使用 | 桌面版安装包 | 桌面窗口;默认本机客户端直连使用 `http://127.0.0.1:4000`,局域网客户端可使用当前机器 IP + `4000`;如有覆盖则按 `METAPI_DESKTOP_SERVER_PORT` 的实际端口访问 | Electron `app.getPath('userData')/data` | | 二次开发 / 调试 | 本地开发 | 前端 `http://localhost:5173`,后端默认 `http://localhost:4000` | 仓库内 `./data` 或自定义 `DATA_DIR` | > [!NOTE] @@ -191,6 +191,10 @@ docker run -d --name metapi \ - 托盘菜单支持重新打开窗口、重启后端、开机自启 - 支持基于 GitHub Releases 的应用内更新检查 +> [!IMPORTANT] +> 桌面版首次启动时,如果没有显式注入 `AUTH_TOKEN`,管理员登录令牌默认是 `change-me-admin-token`。 +> 这只适合本机初始调试使用,首次登录后应立即修改。 + > [!NOTE] > 服务器部署不再提供裸 Node.js Release 压缩包,统一推荐 Docker / Docker Compose。 @@ -211,7 +215,7 @@ docker run -d --name metapi \ ## 反向代理 -以下反向代理配置面向 Docker / 服务器模式。桌面版内置后端默认只绑定本机 `127.0.0.1`,通常不作为公网服务直接暴露。 +以下反向代理配置面向 Docker / 服务器模式。桌面版内置后端默认监听 `0.0.0.0:4000`,但通常仍作为单机桌面应用使用;如果要给局域网或公网客户端访问,请自行配置防火墙、反向代理和认证边界。若你显式设置了 `METAPI_DESKTOP_SERVER_PORT`,请把示例里的 `4000` 改成对应端口。 ### Nginx diff --git a/docs/getting-started.md b/docs/getting-started.md index 0347b11..8a34144 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -80,18 +80,22 @@ docker compose up -d | 项目 | 说明 | |------|------| | 管理界面 | 应用启动后会直接打开桌面窗口,不需要假设固定的 `http://localhost:4000` | -| 本地后端地址 | 桌面版把内置服务绑定到 `127.0.0.1`,默认会在 `4310..4399` 中挑选空闲端口;只有显式设置 `METAPI_DESKTOP_SERVER_PORT` 时才会固定 | +| 本地后端地址 | 桌面版内置服务默认监听 `0.0.0.0:4000`;桌面窗口和本机 curl 可继续使用 `http://127.0.0.1:4000`,局域网其他设备请使用当前机器的实际 IP + `4000`;如需改端口,可显式设置 `METAPI_DESKTOP_SERVER_PORT` | | 数据目录 | 保存在 `app.getPath('userData')/data`,不是仓库里的 `./data` | | 日志目录 | 保存在 `app.getPath('userData')/logs`;托盘菜单提供 `Open Logs Folder` | +> [!IMPORTANT] +> 桌面版首次启动时,如果你没有额外注入 `AUTH_TOKEN`,默认管理员令牌就是 `change-me-admin-token`。 +> 首次登录后建议立即到「设置」里改成你自己的强密码令牌。 + > [!TIP] > - Windows 下常见路径是 `%APPDATA%\Metapi\data` 和 `%APPDATA%\Metapi\logs`。 -> - 如果你要把本机其他客户端接到桌面版内置后端,先到日志里查当前端口,不要写死 `4000`。 +> - 如果没有额外覆盖端口,本机其他客户端可以直接连接 `http://127.0.0.1:4000`。 > [!WARNING] -> **端口冲突排障:** 如果桌面版启动后报端口被占用,可能是 `4310..4399` 范围内的端口全被其他应用占用了。 -> - 设置环境变量 `METAPI_DESKTOP_SERVER_PORT=<指定端口>` 固定到一个空闲端口 -> - 或关闭占用这些端口的应用后重启 Metapi Desktop +> **端口冲突排障:** 桌面版默认使用 `4000` 端口;如果该端口被其他应用占用: +> - 设置环境变量 `METAPI_DESKTOP_SERVER_PORT=<指定端口>` 改到一个空闲端口 +> - 或关闭占用 `4000` 的应用后重启 Metapi Desktop > [!NOTE] > 服务器部署统一推荐 Docker / Docker Compose,不再提供裸 Node.js 的 Release 压缩包。 @@ -196,7 +200,7 @@ npm run dev |----------|----------|----------------| | Docker / Docker Compose | `http://localhost:4000` | `http://localhost:4000` | | 本地开发 | `http://localhost:5173` | `http://localhost:4000` | -| 桌面版 | 直接使用桌面窗口 | 通常是4312,先从日志里的 `Proxy API:` 行确认当前 `http://127.0.0.1:` | +| 桌面版 | 直接使用桌面窗口 | 默认 `http://127.0.0.1:4000`;如果设置了 `METAPI_DESKTOP_SERVER_PORT`,则按日志里的实际端口访问,局域网其他设备改用当前机器 IP + 同一端口 | ### Docker / 本地开发:直接用 curl 验证 @@ -212,23 +216,25 @@ curl -sS http://localhost:4000/v1/chat/completions \ -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}' ``` -### 桌面版:先确认当前端口再验证 +### 桌面版:默认直接用 4000 验证 打开托盘菜单的 `Open Logs Folder`,在最新日志里查找类似下面的启动信息: ```text -Dashboard: http://127.0.0.1:4312 -Proxy API: http://127.0.0.1:4312/v1/chat/completions +Dashboard: http://127.0.0.1:4000 +Proxy API: http://127.0.0.1:4000/v1/chat/completions ``` -然后把日志里的实际端口替换进 curl: +如果你没有覆盖端口,可直接执行: ```bash -curl -sS http://127.0.0.1:4312/v1/models \ +curl -sS http://127.0.0.1:4000/v1/models \ -H "Authorization: Bearer your-proxy-sk-token" ``` -返回正常响应,说明代理链路已经可用。 +如果你显式设置了 `METAPI_DESKTOP_SERVER_PORT`,再把上面的 `4000` 替换成日志里的实际端口。返回正常响应,说明代理链路已经可用。 + +如果你要从同一局域网的其他设备访问桌面版,把上面的 `127.0.0.1` 替换成这台电脑的实际局域网 IP,并确认系统防火墙已放行对应端口。 ## 下一步 diff --git a/docs/operations.md b/docs/operations.md index 420f477..a3cc0f1 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -154,7 +154,7 @@ curl -sS http://localhost:4000/v1/chat/completions \ -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"ping"}]}' ``` -以上示例默认服务端部署监听 `localhost:4000`。Desktop 内置后端运行在 `127.0.0.1` 的动态端口,优先通过应用界面、托盘日志或健康检查日志排查,不要假定固定端口。 +以上示例默认服务端部署监听 `localhost:4000`。Desktop 内置后端默认监听 `0.0.0.0:4000`;本机排查通常可直接使用 `127.0.0.1:4000`,如果显式设置了 `METAPI_DESKTOP_SERVER_PORT`,则按日志里的实际端口访问;局域网排查改用当前机器的实际 IP。 ### 自动化监控建议 diff --git a/src/desktop/main.ts b/src/desktop/main.ts index f6d7ffe..df21f4a 100644 --- a/src/desktop/main.ts +++ b/src/desktop/main.ts @@ -9,7 +9,6 @@ import { } from 'electron'; import log from 'electron-log'; import electronUpdater from 'electron-updater'; -import getPort, { portNumbers } from 'get-port'; import { spawn, type ChildProcess } from 'node:child_process'; import { existsSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; @@ -18,6 +17,7 @@ import { createDesktopHealthUrl, createDesktopServerUrl, isFatalServerExit, + resolveDesktopServerPort, resolveDesktopServerWorkingDir, waitForServerReady, } from './runtime.js'; @@ -68,12 +68,6 @@ function resolveExternalServerUrl() { return (process.env.METAPI_DESKTOP_EXTERNAL_SERVER_URL || '').trim(); } -async function resolveServerPort() { - const forcedPort = Number.parseInt(process.env.METAPI_DESKTOP_SERVER_PORT || '', 10); - if (Number.isFinite(forcedPort) && forcedPort > 0) return forcedPort; - return getPort({ port: portNumbers(4310, 4399) }); -} - function showMainWindow() { if (!mainWindow) return; if (mainWindow.isMinimized()) mainWindow.restore(); @@ -185,7 +179,7 @@ async function waitForManagedServerReady(url: string) { async function startManagedBackend() { ensureDesktopDirs(); const serverEntryPath = getServerEntryPath(); - const port = await resolveServerPort(); + const port = resolveDesktopServerPort(process.env); serverUrl = createDesktopServerUrl(port); const env = buildDesktopServerEnv({ diff --git a/src/desktop/runtime.test.ts b/src/desktop/runtime.test.ts index f2bf957..1dbea80 100644 --- a/src/desktop/runtime.test.ts +++ b/src/desktop/runtime.test.ts @@ -3,12 +3,13 @@ import { buildDesktopServerEnv, createDesktopServerUrl, isFatalServerExit, + resolveDesktopServerPort, resolveDesktopServerWorkingDir, waitForServerReady, } from './runtime.js'; describe('desktop runtime helpers', () => { - it('builds desktop server env with loopback host and app directories', () => { + it('builds desktop server env with external listen host and app directories', () => { const env = buildDesktopServerEnv({ inheritedEnv: { AUTH_TOKEN: 'admin-token', @@ -19,7 +20,7 @@ describe('desktop runtime helpers', () => { port: 4312, }); - expect(env.HOST).toBe('127.0.0.1'); + expect(env.HOST).toBe('0.0.0.0'); expect(env.PORT).toBe('4312'); expect(env.DATA_DIR).toBe('/tmp/metapi-data'); expect(env.METAPI_LOG_DIR).toBe('/tmp/metapi-logs'); @@ -31,6 +32,16 @@ describe('desktop runtime helpers', () => { expect(createDesktopServerUrl(4312)).toBe('http://127.0.0.1:4312'); }); + it('defaults desktop backend port to 4000', () => { + expect(resolveDesktopServerPort({})).toBe(4000); + }); + + it('honors explicit desktop backend port override', () => { + expect(resolveDesktopServerPort({ + METAPI_DESKTOP_SERVER_PORT: '4312', + })).toBe(4312); + }); + it('uses resources path as backend cwd for packaged desktop builds', () => { expect(resolveDesktopServerWorkingDir({ appPath: 'C:/Users/test/AppData/Local/Programs/Metapi/resources/app.asar', diff --git a/src/desktop/runtime.ts b/src/desktop/runtime.ts index 71d0d2a..1ae4022 100644 --- a/src/desktop/runtime.ts +++ b/src/desktop/runtime.ts @@ -23,6 +23,7 @@ type DesktopServerWorkingDirInput = { isPackaged: boolean; }; +const DEFAULT_DESKTOP_SERVER_PORT = 4000; const DEFAULT_READY_TIMEOUT_MS = 30_000; const DEFAULT_READY_INTERVAL_MS = 250; @@ -31,9 +32,11 @@ function delay(ms: number) { } export function buildDesktopServerEnv(input: DesktopServerEnvInput): NodeJS.ProcessEnv { + const host = (input.inheritedEnv?.HOST || '0.0.0.0').trim() || '0.0.0.0'; + return { ...(input.inheritedEnv || {}), - HOST: '127.0.0.1', + HOST: host, PORT: String(input.port), DATA_DIR: input.userDataDir, METAPI_DESKTOP: '1', @@ -49,6 +52,12 @@ export function createDesktopHealthUrl(port: number): string { return `${createDesktopServerUrl(port)}/api/desktop/health`; } +export function resolveDesktopServerPort(env?: NodeJS.ProcessEnv): number { + const forcedPort = Number.parseInt(env?.METAPI_DESKTOP_SERVER_PORT || '', 10); + if (Number.isFinite(forcedPort) && forcedPort > 0) return forcedPort; + return DEFAULT_DESKTOP_SERVER_PORT; +} + export function resolveDesktopServerWorkingDir(input: DesktopServerWorkingDirInput): string { return input.isPackaged ? input.resourcesPath : input.appPath; } diff --git a/src/server/config.test.ts b/src/server/config.test.ts index 3bc80f6..20740a6 100644 --- a/src/server/config.test.ts +++ b/src/server/config.test.ts @@ -11,7 +11,7 @@ describe('buildConfig', () => { expect(config.dataDir).toBe('./data'); }); - it('keeps desktop deployments bound to loopback', () => { + it('aligns desktop deployments with server deployments for listen host', () => { const config = buildConfig({ HOST: '0.0.0.0', METAPI_DESKTOP: '1', @@ -19,7 +19,7 @@ describe('buildConfig', () => { DATA_DIR: '/tmp/metapi-data', }); - expect(config.listenHost).toBe('127.0.0.1'); + expect(config.listenHost).toBe('0.0.0.0'); expect(config.port).toBe(4312); expect(config.dataDir).toBe('/tmp/metapi-data'); }); diff --git a/src/server/config.ts b/src/server/config.ts index 3a45843..7322a42 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -32,11 +32,7 @@ function parseDbType(value: string | undefined): 'sqlite' | 'mysql' | 'postgres' } function parseListenHost(env: NodeJS.ProcessEnv): string { - const requestedHost = (env.HOST || '0.0.0.0').trim() || '0.0.0.0'; - if (parseBoolean(env.METAPI_DESKTOP, false)) { - return '127.0.0.1'; - } - return requestedHost; + return (env.HOST || '0.0.0.0').trim() || '0.0.0.0'; } export function buildConfig(env: NodeJS.ProcessEnv) { diff --git a/src/server/db/generated/mysql.upgrade.sql b/src/server/db/generated/mysql.upgrade.sql index 082deef..498dfc7 100644 --- a/src/server/db/generated/mysql.upgrade.sql +++ b/src/server/db/generated/mysql.upgrade.sql @@ -1,2 +1,4 @@ ALTER TABLE `downstream_api_keys` ADD COLUMN `group_name` TEXT; ALTER TABLE `downstream_api_keys` ADD COLUMN `tags` TEXT; +ALTER TABLE `proxy_logs` ADD COLUMN `downstream_api_key_id` INT; +CREATE INDEX `proxy_logs_downstream_api_key_created_at_idx` ON `proxy_logs` (`downstream_api_key_id`, `created_at`); diff --git a/src/server/db/generated/postgres.upgrade.sql b/src/server/db/generated/postgres.upgrade.sql index f6e05ac..2e50f3f 100644 --- a/src/server/db/generated/postgres.upgrade.sql +++ b/src/server/db/generated/postgres.upgrade.sql @@ -1,2 +1,4 @@ ALTER TABLE "downstream_api_keys" ADD COLUMN "group_name" TEXT; ALTER TABLE "downstream_api_keys" ADD COLUMN "tags" TEXT; +ALTER TABLE "proxy_logs" ADD COLUMN "downstream_api_key_id" INTEGER; +CREATE INDEX "proxy_logs_downstream_api_key_created_at_idx" ON "proxy_logs" ("downstream_api_key_id", "created_at"); diff --git a/src/server/db/index.ts b/src/server/db/index.ts index 71bbf9d..542d56e 100644 --- a/src/server/db/index.ts +++ b/src/server/db/index.ts @@ -8,7 +8,6 @@ import * as schema from './schema.js'; import { ensureSiteSchemaCompatibility, type SiteSchemaInspector } from './siteSchemaCompatibility.js'; import { ensureRouteGroupingSchemaCompatibility } from './routeGroupingSchemaCompatibility.js'; import { ensureProxyFileSchemaCompatibility } from './proxyFileSchemaCompatibility.js'; -import { ensureSharedIndexSchemaCompatibility } from './sharedIndexSchemaCompatibility.js'; import { executeLegacyCompat, executeLegacyCompatSync } from './legacySchemaCompat.js'; import { config } from '../config.js'; import { ensureRuntimeDatabaseReady } from '../runtimeDatabaseBootstrap.js'; @@ -447,12 +446,6 @@ export async function ensureProxyFileCompatibilityColumns(): Promise { await ensureProxyFileSchemaCompatibility(inspector); } -export async function ensureSharedIndexCompatibility(): Promise { - const inspector = createRuntimeSchemaInspector(); - if (!inspector) return; - await ensureSharedIndexSchemaCompatibility(inspector); -} - function ensureRouteGroupingSchema() { if (!tableExists('token_routes') || !tableExists('route_channels')) { return; @@ -557,6 +550,18 @@ function ensureProxyLogBillingDetailsSchema() { proxyLogBillingDetailsColumnAvailable = true; } +function ensureProxyLogDownstreamApiKeyIdSchema() { + if (!tableExists('proxy_logs')) { + return; + } + + if (!tableColumnExists('proxy_logs', 'downstream_api_key_id')) { + execSqliteLegacyCompat('ALTER TABLE proxy_logs ADD COLUMN downstream_api_key_id integer;'); + } + + proxyLogDownstreamApiKeyIdColumnAvailable = true; +} + function normalizeSchemaErrorMessage(error: unknown): string { if (typeof error === 'object' && error && 'message' in error) { return String((error as { message?: unknown }).message || ''); @@ -635,18 +640,6 @@ export async function ensureProxyLogBillingDetailsColumn(): Promise { } } -function ensureProxyLogDownstreamApiKeyIdSchema() { - if (!tableExists('proxy_logs')) { - return; - } - - if (!tableColumnExists('proxy_logs', 'downstream_api_key_id')) { - execSqliteLegacyCompat('ALTER TABLE proxy_logs ADD COLUMN downstream_api_key_id integer;'); - } - - proxyLogDownstreamApiKeyIdColumnAvailable = true; -} - export async function hasProxyLogDownstreamApiKeyIdColumn(): Promise { if (proxyLogDownstreamApiKeyIdColumnAvailable !== null) { return proxyLogDownstreamApiKeyIdColumnAvailable; diff --git a/src/server/db/runtimeSchemaBootstrap.test.ts b/src/server/db/runtimeSchemaBootstrap.test.ts index ec31d52..f47e790 100644 --- a/src/server/db/runtimeSchemaBootstrap.test.ts +++ b/src/server/db/runtimeSchemaBootstrap.test.ts @@ -2,6 +2,7 @@ import baselineContract from './generated/fixtures/2026-03-14-baseline.schemaCon import currentContract from './generated/schemaContract.json' with { type: 'json' }; import { classifyLegacyCompatMutation } from './legacySchemaCompat.js'; import { generateUpgradeSql } from './schemaArtifactGenerator.js'; +import type { SchemaContract, SchemaContractColumn } from './schemaContract.js'; import { describe, expect, it } from 'vitest'; import { __runtimeSchemaBootstrapTestUtils, @@ -17,6 +18,9 @@ function createStubClient(dialect: RuntimeSchemaDialect, executedSql: string[]): commit: async () => {}, rollback: async () => {}, execute: async (sqlText: string) => { + if (sqlText.trim().toLowerCase().startsWith('select')) { + return []; + } executedSql.push(sqlText); return []; }, @@ -33,6 +37,16 @@ function createStubClient(dialect: RuntimeSchemaDialect, executedSql: string[]): }; } +function makeColumn(overrides: Partial = {}): SchemaContractColumn { + return { + logicalType: 'text', + notNull: false, + defaultValue: null, + primaryKey: false, + ...overrides, + }; +} + describe('runtime schema bootstrap', () => { it.each(['mysql', 'postgres'] as const)('executes live-schema upgrade statements for %s', async (dialect) => { const executedSql: string[] = []; @@ -149,4 +163,72 @@ describe('runtime schema bootstrap', () => { expect(executedSql).toContain(targetSql); }); + + it('adds mysql text prefixes for new indexes when live datetime-like columns are still stored as text', async () => { + const executedSql: string[] = []; + const minimalContract: SchemaContract = { + tables: { + proxy_logs: { + columns: { + downstream_api_key_id: makeColumn({ logicalType: 'integer' }), + created_at: makeColumn({ logicalType: 'datetime', defaultValue: "datetime('now')" }), + }, + }, + }, + indexes: [ + { + name: 'proxy_logs_downstream_api_key_created_at_idx', + table: 'proxy_logs', + columns: ['downstream_api_key_id', 'created_at'], + unique: false, + }, + ], + uniques: [], + foreignKeys: [], + }; + + await ensureRuntimeDatabaseSchema({ + ...createStubClient('mysql', executedSql), + execute: async (sqlText: string) => { + if (sqlText.includes('FROM information_schema.columns')) { + return [[ + { + table_name: 'proxy_logs', + column_name: 'downstream_api_key_id', + data_type: 'int', + column_type: 'int', + }, + { + table_name: 'proxy_logs', + column_name: 'created_at', + data_type: 'text', + column_type: 'text', + }, + ]]; + } + + executedSql.push(sqlText); + return []; + }, + queryScalar: async (sqlText: string, params: unknown[] = []) => { + if (sqlText.includes('information_schema.tables')) { + return params[0] === 'proxy_logs' ? 1 : 0; + } + if (sqlText.includes('information_schema.columns')) { + return 1; + } + return 0; + }, + }, { + currentContract: minimalContract, + liveContract: { + ...minimalContract, + indexes: [], + }, + }); + + expect(executedSql).toContain( + 'CREATE INDEX `proxy_logs_downstream_api_key_created_at_idx` ON `proxy_logs` (`downstream_api_key_id`, `created_at`(191))', + ); + }); }); diff --git a/src/server/db/runtimeSchemaBootstrap.ts b/src/server/db/runtimeSchemaBootstrap.ts index 1838527..b664af5 100644 --- a/src/server/db/runtimeSchemaBootstrap.ts +++ b/src/server/db/runtimeSchemaBootstrap.ts @@ -7,7 +7,11 @@ import { ensureLegacySchemaCompatibility, type LegacySchemaCompatInspector, } from './legacySchemaCompat.js'; -import { generateBootstrapSql, generateUpgradeSql } from './schemaArtifactGenerator.js'; +import { + generateBootstrapSql, + generateUpgradeSql, + type MysqlIndexPrefixRequirementMap, +} from './schemaArtifactGenerator.js'; import { introspectLiveSchema } from './schemaIntrospection.js'; import { resolveGeneratedSchemaContractPath, type SchemaContract } from './schemaContract.js'; @@ -233,6 +237,92 @@ function buildCompatibleRuntimeBaseline( return baseline; } +function collectIndexedColumns(contract: SchemaContract): Map> { + const indexedColumns = new Map>(); + + for (const index of [...contract.indexes, ...contract.uniques]) { + let columns = indexedColumns.get(index.table); + if (!columns) { + columns = new Set(); + indexedColumns.set(index.table, columns); + } + + for (const columnName of index.columns) { + columns.add(columnName); + } + } + + return indexedColumns; +} + +function requiresMysqlIndexPrefixForColumnType(columnType: string): boolean { + const normalizedType = columnType.trim().toLowerCase(); + return normalizedType.includes('text') || normalizedType.includes('blob'); +} + +async function queryRuntimeRows( + client: RuntimeSchemaClient, + sqlText: string, + params: unknown[] = [], +): Promise>> { + const result = await client.execute(sqlText, params); + + if (!Array.isArray(result)) { + return []; + } + + const [first] = result; + if (Array.isArray(first)) { + return first as Array>; + } + + if (result.every((item) => typeof item === 'object' && item !== null && !Array.isArray(item))) { + return result as Array>; + } + + return []; +} + +async function resolveMySqlIndexPrefixRequirements( + client: RuntimeSchemaClient, + currentContract: SchemaContract, +): Promise { + const indexedColumns = collectIndexedColumns(currentContract); + if (indexedColumns.size === 0) { + return {}; + } + + const rows = await queryRuntimeRows(client, ` + SELECT + table_name AS table_name, + column_name AS column_name, + data_type AS data_type, + column_type AS column_type + FROM information_schema.columns + WHERE table_schema = DATABASE() + `); + + const requirements: MysqlIndexPrefixRequirementMap = {}; + for (const row of rows) { + const tableName = String(row.table_name || ''); + const columnName = String(row.column_name || ''); + const trackedColumns = indexedColumns.get(tableName); + if (!trackedColumns || !trackedColumns.has(columnName)) { + continue; + } + + const declaredType = String(row.column_type || row.data_type || ''); + if (!requiresMysqlIndexPrefixForColumnType(declaredType)) { + continue; + } + + requirements[tableName] ??= {}; + requirements[tableName][columnName] = true; + } + + return requirements; +} + async function createPostgresClient(connectionString: string, ssl: boolean): Promise { const clientOptions: pg.ClientConfig = { connectionString }; if (ssl) { @@ -346,9 +436,12 @@ function buildExternalUpgradeStatements( dialect: Exclude, currentContract: SchemaContract, liveContract: SchemaContract, + mysqlIndexPrefixRequirements?: MysqlIndexPrefixRequirementMap, ): string[] { const compatibleBaseline = buildCompatibleRuntimeBaseline(currentContract, liveContract); - return splitSqlStatements(generateUpgradeSql(dialect, currentContract, compatibleBaseline)); + return splitSqlStatements(generateUpgradeSql(dialect, currentContract, compatibleBaseline, { + mysqlIndexPrefixRequirements, + })); } export async function ensureRuntimeDatabaseSchema( @@ -356,13 +449,23 @@ export async function ensureRuntimeDatabaseSchema( options: EnsureRuntimeDatabaseSchemaOptions = {}, ): Promise { const currentContract = options.currentContract ?? readSchemaContract(); - const statements = client.dialect === 'sqlite' - ? splitSqlStatements(generateBootstrapSql('sqlite', currentContract)) - : buildExternalUpgradeStatements( + let statements: string[]; + + if (client.dialect === 'sqlite') { + statements = splitSqlStatements(generateBootstrapSql('sqlite', currentContract)); + } else { + const liveContract = await resolveLiveContract(client, options.liveContract); + const mysqlIndexPrefixRequirements = client.dialect === 'mysql' + ? await resolveMySqlIndexPrefixRequirements(client, currentContract) + : undefined; + + statements = buildExternalUpgradeStatements( client.dialect, currentContract, - await resolveLiveContract(client, options.liveContract), + liveContract, + mysqlIndexPrefixRequirements, ); + } for (const sqlText of statements) { await executeBootstrapStatement(client, sqlText); diff --git a/src/server/db/schemaArtifactGenerator.test.ts b/src/server/db/schemaArtifactGenerator.test.ts index 8d6378d..8738516 100644 --- a/src/server/db/schemaArtifactGenerator.test.ts +++ b/src/server/db/schemaArtifactGenerator.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -import { generateDialectArtifacts, type GeneratedDialectArtifacts } from './schemaArtifactGenerator.js'; +import { generateDialectArtifacts, generateUpgradeSql, type GeneratedDialectArtifacts } from './schemaArtifactGenerator.js'; import type { SchemaContract, SchemaContractColumn } from './schemaContract.js'; const dbDir = dirname(fileURLToPath(import.meta.url)); @@ -123,6 +123,45 @@ describe('schema artifact generator', () => { expect(artifacts.mysqlBootstrap).not.toContain('`read`(191)'); }); + it('allows mysql upgrade generation to force prefix lengths for live text-backed columns', () => { + const previousContract: SchemaContract = { + tables: { + proxy_logs: { + columns: { + downstream_api_key_id: makeColumn({ logicalType: 'integer' }), + created_at: makeColumn({ logicalType: 'datetime', defaultValue: "datetime('now')" }), + }, + }, + }, + indexes: [], + uniques: [], + foreignKeys: [], + }; + const currentContract: SchemaContract = { + ...previousContract, + indexes: [ + { + name: 'proxy_logs_downstream_api_key_created_at_idx', + table: 'proxy_logs', + columns: ['downstream_api_key_id', 'created_at'], + unique: false, + }, + ], + }; + + const mysqlUpgrade = generateUpgradeSql('mysql', currentContract, previousContract, { + mysqlIndexPrefixRequirements: { + proxy_logs: { + created_at: true, + }, + }, + }); + + expect(mysqlUpgrade).toContain( + 'CREATE INDEX `proxy_logs_downstream_api_key_created_at_idx` ON `proxy_logs` (`downstream_api_key_id`, `created_at`(191))', + ); + }); + it('rejects destructive diffs when generating additive upgrades', () => { const current = readSchemaContract(); const previous = structuredClone(current); diff --git a/src/server/db/schemaArtifactGenerator.ts b/src/server/db/schemaArtifactGenerator.ts index c29d16b..dd898e5 100644 --- a/src/server/db/schemaArtifactGenerator.ts +++ b/src/server/db/schemaArtifactGenerator.ts @@ -17,8 +17,13 @@ export interface GeneratedDialectArtifacts { postgresUpgrade: string; } +export type MysqlIndexPrefixRequirementMap = Record>; + type Dialect = 'mysql' | 'postgres'; type SqlDialect = 'sqlite' | Dialect; +type SqlGenerationOptions = { + mysqlIndexPrefixRequirements?: MysqlIndexPrefixRequirementMap; +}; function resolveDbDir(): string { return dirname(fileURLToPath(import.meta.url)); @@ -36,6 +41,21 @@ function escapeMysqlTextPrefix(columnType: LogicalColumnType): string { return columnType === 'text' ? '(191)' : ''; } +function resolveMysqlIndexPrefix( + tableName: string, + columnName: string, + contract: SchemaContract, + options?: SqlGenerationOptions, +): string { + const override = options?.mysqlIndexPrefixRequirements?.[tableName]?.[columnName]; + if (override !== undefined) { + return override ? '(191)' : ''; + } + + const column = contract.tables[tableName]?.columns[columnName]; + return column ? escapeMysqlTextPrefix(column.logicalType) : ''; +} + function mapColumnType(dialect: SqlDialect, columnName: string, column: SchemaContractColumn): string { if (dialect === 'sqlite') { switch (column.logicalType) { @@ -177,22 +197,34 @@ function buildCreateTableStatement( return `CREATE TABLE IF NOT EXISTS ${quoteIdentifier(dialect, tableName)} (${parts.join(', ')})`; } -function buildUniqueIndexStatement(dialect: SqlDialect, uniqueIndex: SchemaContractUnique, contract: SchemaContract): string { +function buildUniqueIndexStatement( + dialect: SqlDialect, + uniqueIndex: SchemaContractUnique, + contract: SchemaContract, + options?: SqlGenerationOptions, +): string { const columns = uniqueIndex.columns .map((columnName) => { - const column = contract.tables[uniqueIndex.table]?.columns[columnName]; - const suffix = dialect === 'mysql' && column ? escapeMysqlTextPrefix(column.logicalType) : ''; + const suffix = dialect === 'mysql' + ? resolveMysqlIndexPrefix(uniqueIndex.table, columnName, contract, options) + : ''; return `${quoteIdentifier(dialect, columnName)}${suffix}`; }) .join(', '); return `CREATE UNIQUE INDEX ${quoteIdentifier(dialect, uniqueIndex.name)} ON ${quoteIdentifier(dialect, uniqueIndex.table)} (${columns})`; } -function buildIndexStatement(dialect: SqlDialect, index: SchemaContractIndex, contract: SchemaContract): string { +function buildIndexStatement( + dialect: SqlDialect, + index: SchemaContractIndex, + contract: SchemaContract, + options?: SqlGenerationOptions, +): string { const columns = index.columns .map((columnName) => { - const column = contract.tables[index.table]?.columns[columnName]; - const suffix = dialect === 'mysql' && column ? escapeMysqlTextPrefix(column.logicalType) : ''; + const suffix = dialect === 'mysql' + ? resolveMysqlIndexPrefix(index.table, columnName, contract, options) + : ''; return `${quoteIdentifier(dialect, columnName)}${suffix}`; }) .join(', '); @@ -317,6 +349,7 @@ export function generateUpgradeSql( dialect: SqlDialect, currentContract: SchemaContract, previousContract?: SchemaContract | null, + options?: SqlGenerationOptions, ): string { if (!previousContract) { return `-- no previous schema contract available for ${dialect} additive upgrade generation\n`; @@ -357,7 +390,7 @@ export function generateUpgradeSql( .filter((unique) => !previousUniqueNames.has(unique.name)) .slice() .sort((left, right) => left.name.localeCompare(right.name, 'en')) - .map((unique) => buildUniqueIndexStatement(dialect, unique, currentContract)); + .map((unique) => buildUniqueIndexStatement(dialect, unique, currentContract, options)); const currentUniqueNames = new Set(currentContract.uniques.map((unique) => unique.name)); const previousIndexNames = new Set(previousContract.indexes.map((index) => index.name)); @@ -366,7 +399,7 @@ export function generateUpgradeSql( .filter((index) => !previousIndexNames.has(index.name)) .slice() .sort((left, right) => left.name.localeCompare(right.name, 'en')) - .map((index) => buildIndexStatement(dialect, index, currentContract)); + .map((index) => buildIndexStatement(dialect, index, currentContract, options)); const statements = [...addedTableStatements, ...addColumnStatements, ...uniqueStatements, ...indexStatements]; if (statements.length === 0) { diff --git a/src/server/db/schemaParity.test.ts b/src/server/db/schemaParity.test.ts index d3f18b4..d5f692c 100644 --- a/src/server/db/schemaParity.test.ts +++ b/src/server/db/schemaParity.test.ts @@ -3,6 +3,7 @@ import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import type { SchemaContract } from './schemaContract.js'; +import { SHARED_INDEX_COMPATIBILITY_SPECS } from './sharedIndexSchemaCompatibility.js'; const dbDir = dirname(fileURLToPath(import.meta.url)); const generatedDir = resolve(dbDir, 'generated'); @@ -63,4 +64,31 @@ describe('database schema parity', () => { expect(unknownTables).toEqual([]); expect(unknownIndexes).toEqual([]); }); + + it('does not duplicate contract-defined indexes inside shared index compatibility specs', () => { + const contract = JSON.parse(readFileSync(schemaContractPath, 'utf8')) as SchemaContract; + const contractIndexNames = new Set([ + ...contract.indexes.map((index) => index.name), + ...contract.uniques.map((unique) => unique.name), + ]); + + const duplicatedSpecs = SHARED_INDEX_COMPATIBILITY_SPECS + .map((spec) => spec.indexName) + .filter((indexName) => contractIndexNames.has(indexName)); + + expect(duplicatedSpecs).toEqual([]); + }); + + it('keeps proxy_logs downstream api key schema in the generated contract artifacts', () => { + const contract = JSON.parse(readFileSync(schemaContractPath, 'utf8')) as SchemaContract; + const mysqlBootstrap = readFileSync(resolve(generatedDir, 'mysql.bootstrap.sql'), 'utf8'); + const postgresBootstrap = readFileSync(resolve(generatedDir, 'postgres.bootstrap.sql'), 'utf8'); + + expect(contract.tables.proxy_logs?.columns.downstream_api_key_id?.logicalType).toBe('integer'); + expect(contract.indexes.some((index) => index.name === 'proxy_logs_downstream_api_key_created_at_idx')).toBe(true); + expect(mysqlBootstrap).toContain('`downstream_api_key_id`'); + expect(mysqlBootstrap).toContain('`proxy_logs_downstream_api_key_created_at_idx`'); + expect(postgresBootstrap).toContain('"downstream_api_key_id"'); + expect(postgresBootstrap).toContain('"proxy_logs_downstream_api_key_created_at_idx"'); + }); }); diff --git a/src/server/db/sharedIndexSchemaCompatibility.test.ts b/src/server/db/sharedIndexSchemaCompatibility.test.ts index c234e24..e7ceed5 100644 --- a/src/server/db/sharedIndexSchemaCompatibility.test.ts +++ b/src/server/db/sharedIndexSchemaCompatibility.test.ts @@ -25,33 +25,10 @@ function createInspector( describe('ensureSharedIndexSchemaCompatibility', () => { it.each([ - { - dialect: 'sqlite' as const, - expectedSql: [ - 'CREATE UNIQUE INDEX IF NOT EXISTS model_availability_account_model_unique ON model_availability (account_id, model_name);', - 'CREATE INDEX IF NOT EXISTS proxy_logs_status_created_at_idx ON proxy_logs (status, created_at);', - 'CREATE INDEX IF NOT EXISTS events_created_at_idx ON events (created_at);', - ], - }, - { - dialect: 'mysql' as const, - expectedSql: [ - 'CREATE UNIQUE INDEX `model_availability_account_model_unique` ON `model_availability` (`account_id`, `model_name`(191))', - 'CREATE INDEX `proxy_logs_status_created_at_idx` ON `proxy_logs` (`status`, `created_at`(191))', - 'CREATE INDEX `proxy_logs_downstream_api_key_created_at_idx` ON `proxy_logs` (`downstream_api_key_id`, `created_at`(191))', - 'CREATE INDEX `events_created_at_idx` ON `events` (`created_at`(191))', - ], - }, - { - dialect: 'postgres' as const, - expectedSql: [ - 'CREATE UNIQUE INDEX IF NOT EXISTS "model_availability_account_model_unique" ON "model_availability" ("account_id", "model_name")', - 'CREATE INDEX IF NOT EXISTS "proxy_logs_status_created_at_idx" ON "proxy_logs" ("status", "created_at")', - 'CREATE INDEX IF NOT EXISTS "proxy_logs_downstream_api_key_created_at_idx" ON "proxy_logs" ("downstream_api_key_id", "created_at")', - 'CREATE INDEX IF NOT EXISTS "events_created_at_idx" ON "events" ("created_at")', - ], - }, - ])('creates shared indexes for $dialect', async ({ dialect, expectedSql }) => { + 'sqlite', + 'mysql', + 'postgres', + ] as const)('is a no-op for %s now that contract-defined indexes come from runtime bootstrap', async (dialect) => { const { inspector, executedSql } = createInspector(dialect, { existingTables: [ 'sites', @@ -70,10 +47,7 @@ describe('ensureSharedIndexSchemaCompatibility', () => { }); await ensureSharedIndexSchemaCompatibility(inspector); - - for (const sqlText of expectedSql) { - expect(executedSql).toContain(sqlText); - } + expect(executedSql).toEqual([]); }); it('skips indexes for tables that do not exist', async () => { @@ -83,8 +57,6 @@ describe('ensureSharedIndexSchemaCompatibility', () => { await ensureSharedIndexSchemaCompatibility(inspector); - expect(executedSql).toEqual([ - 'CREATE INDEX IF NOT EXISTS "sites_status_idx" ON "sites" ("status")', - ]); + expect(executedSql).toEqual([]); }); }); diff --git a/src/server/db/sharedIndexSchemaCompatibility.ts b/src/server/db/sharedIndexSchemaCompatibility.ts index 04a5c8d..f7ddd45 100644 --- a/src/server/db/sharedIndexSchemaCompatibility.ts +++ b/src/server/db/sharedIndexSchemaCompatibility.ts @@ -12,341 +12,10 @@ type SharedIndexCompatibilitySpec = { createSql: Record; }; -export const SHARED_INDEX_COMPATIBILITY_SPECS: SharedIndexCompatibilitySpec[] = [ - { - table: 'sites', - indexName: 'sites_status_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS sites_status_idx ON sites (status);', - mysql: 'CREATE INDEX `sites_status_idx` ON `sites` (`status`)', - postgres: 'CREATE INDEX IF NOT EXISTS "sites_status_idx" ON "sites" ("status")', - }, - }, - { - table: 'accounts', - indexName: 'accounts_site_id_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS accounts_site_id_idx ON accounts (site_id);', - mysql: 'CREATE INDEX `accounts_site_id_idx` ON `accounts` (`site_id`)', - postgres: 'CREATE INDEX IF NOT EXISTS "accounts_site_id_idx" ON "accounts" ("site_id")', - }, - }, - { - table: 'accounts', - indexName: 'accounts_status_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS accounts_status_idx ON accounts (status);', - mysql: 'CREATE INDEX `accounts_status_idx` ON `accounts` (`status`)', - postgres: 'CREATE INDEX IF NOT EXISTS "accounts_status_idx" ON "accounts" ("status")', - }, - }, - { - table: 'accounts', - indexName: 'accounts_site_status_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS accounts_site_status_idx ON accounts (site_id, status);', - mysql: 'CREATE INDEX `accounts_site_status_idx` ON `accounts` (`site_id`, `status`)', - postgres: 'CREATE INDEX IF NOT EXISTS "accounts_site_status_idx" ON "accounts" ("site_id", "status")', - }, - }, - { - table: 'account_tokens', - indexName: 'account_tokens_account_id_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS account_tokens_account_id_idx ON account_tokens (account_id);', - mysql: 'CREATE INDEX `account_tokens_account_id_idx` ON `account_tokens` (`account_id`)', - postgres: 'CREATE INDEX IF NOT EXISTS "account_tokens_account_id_idx" ON "account_tokens" ("account_id")', - }, - }, - { - table: 'account_tokens', - indexName: 'account_tokens_account_enabled_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS account_tokens_account_enabled_idx ON account_tokens (account_id, enabled);', - mysql: 'CREATE INDEX `account_tokens_account_enabled_idx` ON `account_tokens` (`account_id`, `enabled`)', - postgres: 'CREATE INDEX IF NOT EXISTS "account_tokens_account_enabled_idx" ON "account_tokens" ("account_id", "enabled")', - }, - }, - { - table: 'account_tokens', - indexName: 'account_tokens_enabled_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS account_tokens_enabled_idx ON account_tokens (enabled);', - mysql: 'CREATE INDEX `account_tokens_enabled_idx` ON `account_tokens` (`enabled`)', - postgres: 'CREATE INDEX IF NOT EXISTS "account_tokens_enabled_idx" ON "account_tokens" ("enabled")', - }, - }, - { - table: 'checkin_logs', - indexName: 'checkin_logs_account_created_at_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS checkin_logs_account_created_at_idx ON checkin_logs (account_id, created_at);', - mysql: 'CREATE INDEX `checkin_logs_account_created_at_idx` ON `checkin_logs` (`account_id`, `created_at`(191))', - postgres: 'CREATE INDEX IF NOT EXISTS "checkin_logs_account_created_at_idx" ON "checkin_logs" ("account_id", "created_at")', - }, - }, - { - table: 'checkin_logs', - indexName: 'checkin_logs_created_at_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS checkin_logs_created_at_idx ON checkin_logs (created_at);', - mysql: 'CREATE INDEX `checkin_logs_created_at_idx` ON `checkin_logs` (`created_at`(191))', - postgres: 'CREATE INDEX IF NOT EXISTS "checkin_logs_created_at_idx" ON "checkin_logs" ("created_at")', - }, - }, - { - table: 'checkin_logs', - indexName: 'checkin_logs_status_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS checkin_logs_status_idx ON checkin_logs (status);', - mysql: 'CREATE INDEX `checkin_logs_status_idx` ON `checkin_logs` (`status`)', - postgres: 'CREATE INDEX IF NOT EXISTS "checkin_logs_status_idx" ON "checkin_logs" ("status")', - }, - }, - { - table: 'model_availability', - indexName: 'model_availability_account_model_unique', - createSql: { - sqlite: 'CREATE UNIQUE INDEX IF NOT EXISTS model_availability_account_model_unique ON model_availability (account_id, model_name);', - mysql: 'CREATE UNIQUE INDEX `model_availability_account_model_unique` ON `model_availability` (`account_id`, `model_name`(191))', - postgres: 'CREATE UNIQUE INDEX IF NOT EXISTS "model_availability_account_model_unique" ON "model_availability" ("account_id", "model_name")', - }, - }, - { - table: 'model_availability', - indexName: 'model_availability_account_available_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS model_availability_account_available_idx ON model_availability (account_id, available);', - mysql: 'CREATE INDEX `model_availability_account_available_idx` ON `model_availability` (`account_id`, `available`)', - postgres: 'CREATE INDEX IF NOT EXISTS "model_availability_account_available_idx" ON "model_availability" ("account_id", "available")', - }, - }, - { - table: 'model_availability', - indexName: 'model_availability_model_name_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS model_availability_model_name_idx ON model_availability (model_name);', - mysql: 'CREATE INDEX `model_availability_model_name_idx` ON `model_availability` (`model_name`(191))', - postgres: 'CREATE INDEX IF NOT EXISTS "model_availability_model_name_idx" ON "model_availability" ("model_name")', - }, - }, - { - table: 'token_model_availability', - indexName: 'token_model_availability_token_model_unique', - createSql: { - sqlite: 'CREATE UNIQUE INDEX IF NOT EXISTS token_model_availability_token_model_unique ON token_model_availability (token_id, model_name);', - mysql: 'CREATE UNIQUE INDEX `token_model_availability_token_model_unique` ON `token_model_availability` (`token_id`, `model_name`(191))', - postgres: 'CREATE UNIQUE INDEX IF NOT EXISTS "token_model_availability_token_model_unique" ON "token_model_availability" ("token_id", "model_name")', - }, - }, - { - table: 'token_model_availability', - indexName: 'token_model_availability_token_available_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS token_model_availability_token_available_idx ON token_model_availability (token_id, available);', - mysql: 'CREATE INDEX `token_model_availability_token_available_idx` ON `token_model_availability` (`token_id`, `available`)', - postgres: 'CREATE INDEX IF NOT EXISTS "token_model_availability_token_available_idx" ON "token_model_availability" ("token_id", "available")', - }, - }, - { - table: 'token_model_availability', - indexName: 'token_model_availability_model_name_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS token_model_availability_model_name_idx ON token_model_availability (model_name);', - mysql: 'CREATE INDEX `token_model_availability_model_name_idx` ON `token_model_availability` (`model_name`(191))', - postgres: 'CREATE INDEX IF NOT EXISTS "token_model_availability_model_name_idx" ON "token_model_availability" ("model_name")', - }, - }, - { - table: 'token_model_availability', - indexName: 'token_model_availability_available_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS token_model_availability_available_idx ON token_model_availability (available);', - mysql: 'CREATE INDEX `token_model_availability_available_idx` ON `token_model_availability` (`available`)', - postgres: 'CREATE INDEX IF NOT EXISTS "token_model_availability_available_idx" ON "token_model_availability" ("available")', - }, - }, - { - table: 'token_routes', - indexName: 'token_routes_model_pattern_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS token_routes_model_pattern_idx ON token_routes (model_pattern);', - mysql: 'CREATE INDEX `token_routes_model_pattern_idx` ON `token_routes` (`model_pattern`(191))', - postgres: 'CREATE INDEX IF NOT EXISTS "token_routes_model_pattern_idx" ON "token_routes" ("model_pattern")', - }, - }, - { - table: 'token_routes', - indexName: 'token_routes_enabled_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS token_routes_enabled_idx ON token_routes (enabled);', - mysql: 'CREATE INDEX `token_routes_enabled_idx` ON `token_routes` (`enabled`)', - postgres: 'CREATE INDEX IF NOT EXISTS "token_routes_enabled_idx" ON "token_routes" ("enabled")', - }, - }, - { - table: 'route_channels', - indexName: 'route_channels_route_id_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS route_channels_route_id_idx ON route_channels (route_id);', - mysql: 'CREATE INDEX `route_channels_route_id_idx` ON `route_channels` (`route_id`)', - postgres: 'CREATE INDEX IF NOT EXISTS "route_channels_route_id_idx" ON "route_channels" ("route_id")', - }, - }, - { - table: 'route_channels', - indexName: 'route_channels_account_id_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS route_channels_account_id_idx ON route_channels (account_id);', - mysql: 'CREATE INDEX `route_channels_account_id_idx` ON `route_channels` (`account_id`)', - postgres: 'CREATE INDEX IF NOT EXISTS "route_channels_account_id_idx" ON "route_channels" ("account_id")', - }, - }, - { - table: 'route_channels', - indexName: 'route_channels_token_id_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS route_channels_token_id_idx ON route_channels (token_id);', - mysql: 'CREATE INDEX `route_channels_token_id_idx` ON `route_channels` (`token_id`)', - postgres: 'CREATE INDEX IF NOT EXISTS "route_channels_token_id_idx" ON "route_channels" ("token_id")', - }, - }, - { - table: 'route_channels', - indexName: 'route_channels_route_enabled_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS route_channels_route_enabled_idx ON route_channels (route_id, enabled);', - mysql: 'CREATE INDEX `route_channels_route_enabled_idx` ON `route_channels` (`route_id`, `enabled`)', - postgres: 'CREATE INDEX IF NOT EXISTS "route_channels_route_enabled_idx" ON "route_channels" ("route_id", "enabled")', - }, - }, - { - table: 'route_channels', - indexName: 'route_channels_route_token_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS route_channels_route_token_idx ON route_channels (route_id, token_id);', - mysql: 'CREATE INDEX `route_channels_route_token_idx` ON `route_channels` (`route_id`, `token_id`)', - postgres: 'CREATE INDEX IF NOT EXISTS "route_channels_route_token_idx" ON "route_channels" ("route_id", "token_id")', - }, - }, - { - table: 'proxy_logs', - indexName: 'proxy_logs_created_at_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS proxy_logs_created_at_idx ON proxy_logs (created_at);', - mysql: 'CREATE INDEX `proxy_logs_created_at_idx` ON `proxy_logs` (`created_at`(191))', - postgres: 'CREATE INDEX IF NOT EXISTS "proxy_logs_created_at_idx" ON "proxy_logs" ("created_at")', - }, - }, - { - table: 'proxy_logs', - indexName: 'proxy_logs_account_created_at_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS proxy_logs_account_created_at_idx ON proxy_logs (account_id, created_at);', - mysql: 'CREATE INDEX `proxy_logs_account_created_at_idx` ON `proxy_logs` (`account_id`, `created_at`(191))', - postgres: 'CREATE INDEX IF NOT EXISTS "proxy_logs_account_created_at_idx" ON "proxy_logs" ("account_id", "created_at")', - }, - }, - { - table: 'proxy_logs', - indexName: 'proxy_logs_status_created_at_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS proxy_logs_status_created_at_idx ON proxy_logs (status, created_at);', - mysql: 'CREATE INDEX `proxy_logs_status_created_at_idx` ON `proxy_logs` (`status`, `created_at`(191))', - postgres: 'CREATE INDEX IF NOT EXISTS "proxy_logs_status_created_at_idx" ON "proxy_logs" ("status", "created_at")', - }, - }, - { - table: 'proxy_logs', - indexName: 'proxy_logs_model_actual_created_at_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS proxy_logs_model_actual_created_at_idx ON proxy_logs (model_actual, created_at);', - mysql: 'CREATE INDEX `proxy_logs_model_actual_created_at_idx` ON `proxy_logs` (`model_actual`(191), `created_at`(191))', - postgres: 'CREATE INDEX IF NOT EXISTS "proxy_logs_model_actual_created_at_idx" ON "proxy_logs" ("model_actual", "created_at")', - }, - }, - { - table: 'proxy_logs', - indexName: 'proxy_logs_downstream_api_key_created_at_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS proxy_logs_downstream_api_key_created_at_idx ON proxy_logs (downstream_api_key_id, created_at);', - mysql: 'CREATE INDEX `proxy_logs_downstream_api_key_created_at_idx` ON `proxy_logs` (`downstream_api_key_id`, `created_at`(191))', - postgres: 'CREATE INDEX IF NOT EXISTS "proxy_logs_downstream_api_key_created_at_idx" ON "proxy_logs" ("downstream_api_key_id", "created_at")', - }, - }, - { - table: 'proxy_video_tasks', - indexName: 'proxy_video_tasks_upstream_video_id_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS proxy_video_tasks_upstream_video_id_idx ON proxy_video_tasks (upstream_video_id);', - mysql: 'CREATE INDEX `proxy_video_tasks_upstream_video_id_idx` ON `proxy_video_tasks` (`upstream_video_id`(191))', - postgres: 'CREATE INDEX IF NOT EXISTS "proxy_video_tasks_upstream_video_id_idx" ON "proxy_video_tasks" ("upstream_video_id")', - }, - }, - { - table: 'proxy_video_tasks', - indexName: 'proxy_video_tasks_created_at_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS proxy_video_tasks_created_at_idx ON proxy_video_tasks (created_at);', - mysql: 'CREATE INDEX `proxy_video_tasks_created_at_idx` ON `proxy_video_tasks` (`created_at`(191))', - postgres: 'CREATE INDEX IF NOT EXISTS "proxy_video_tasks_created_at_idx" ON "proxy_video_tasks" ("created_at")', - }, - }, - { - table: 'downstream_api_keys', - indexName: 'downstream_api_keys_name_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS downstream_api_keys_name_idx ON downstream_api_keys (name);', - mysql: 'CREATE INDEX `downstream_api_keys_name_idx` ON `downstream_api_keys` (`name`(191))', - postgres: 'CREATE INDEX IF NOT EXISTS "downstream_api_keys_name_idx" ON "downstream_api_keys" ("name")', - }, - }, - { - table: 'downstream_api_keys', - indexName: 'downstream_api_keys_enabled_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS downstream_api_keys_enabled_idx ON downstream_api_keys (enabled);', - mysql: 'CREATE INDEX `downstream_api_keys_enabled_idx` ON `downstream_api_keys` (`enabled`)', - postgres: 'CREATE INDEX IF NOT EXISTS "downstream_api_keys_enabled_idx" ON "downstream_api_keys" ("enabled")', - }, - }, - { - table: 'downstream_api_keys', - indexName: 'downstream_api_keys_expires_at_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS downstream_api_keys_expires_at_idx ON downstream_api_keys (expires_at);', - mysql: 'CREATE INDEX `downstream_api_keys_expires_at_idx` ON `downstream_api_keys` (`expires_at`(191))', - postgres: 'CREATE INDEX IF NOT EXISTS "downstream_api_keys_expires_at_idx" ON "downstream_api_keys" ("expires_at")', - }, - }, - { - table: 'events', - indexName: 'events_read_created_at_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS events_read_created_at_idx ON events (read, created_at);', - mysql: 'CREATE INDEX `events_read_created_at_idx` ON `events` (`read`, `created_at`(191))', - postgres: 'CREATE INDEX IF NOT EXISTS "events_read_created_at_idx" ON "events" ("read", "created_at")', - }, - }, - { - table: 'events', - indexName: 'events_type_created_at_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS events_type_created_at_idx ON events (type, created_at);', - mysql: 'CREATE INDEX `events_type_created_at_idx` ON `events` (`type`, `created_at`(191))', - postgres: 'CREATE INDEX IF NOT EXISTS "events_type_created_at_idx" ON "events" ("type", "created_at")', - }, - }, - { - table: 'events', - indexName: 'events_created_at_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS events_created_at_idx ON events (created_at);', - mysql: 'CREATE INDEX `events_created_at_idx` ON `events` (`created_at`(191))', - postgres: 'CREATE INDEX IF NOT EXISTS "events_created_at_idx" ON "events" ("created_at")', - }, - }, -]; +// Contract-defined indexes are owned by generated schema artifacts and +// runtimeSchemaBootstrap. This legacy hook is intentionally empty so startup +// does not maintain a second static source of index SQL. +export const SHARED_INDEX_COMPATIBILITY_SPECS: SharedIndexCompatibilitySpec[] = []; function normalizeSchemaErrorMessage(error: unknown): string { if (typeof error === 'object' && error && 'message' in error) { diff --git a/src/server/index.ts b/src/server/index.ts index 0274891..b2179ec 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -36,7 +36,6 @@ import { ensureProxyLogDownstreamApiKeyIdColumn, ensureProxyLogBillingDetailsColumn, ensureRouteGroupingCompatibilityColumns, - ensureSharedIndexCompatibility, ensureSiteCompatibilityColumns, runtimeDbDialect, schema, @@ -267,7 +266,6 @@ try { await ensureRouteGroupingCompatibilityColumns(); await ensureProxyFileCompatibilityColumns(); await ensureProxyLogDownstreamApiKeyIdColumn(); - await ensureSharedIndexCompatibility(); const finalRows = await db.select().from(schema.settings).all(); const finalMap = toSettingsMap(finalRows); applyRuntimeSettings(finalMap); diff --git a/src/server/routes/api/accountTokens.coverage-refresh-failure.test.ts b/src/server/routes/api/accountTokens.coverage-refresh-failure.test.ts new file mode 100644 index 0000000..735adc1 --- /dev/null +++ b/src/server/routes/api/accountTokens.coverage-refresh-failure.test.ts @@ -0,0 +1,115 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { eq } from 'drizzle-orm'; + +const refreshModelsForAccountMock = vi.fn(); +const rebuildTokenRoutesFromAvailabilityMock = vi.fn(); + +vi.mock('../../services/modelService.js', () => ({ + refreshModelsForAccount: (...args: unknown[]) => refreshModelsForAccountMock(...args), + rebuildTokenRoutesFromAvailability: (...args: unknown[]) => rebuildTokenRoutesFromAvailabilityMock(...args), +})); + +type DbModule = typeof import('../../db/index.js'); + +describe('account token coverage refresh failure handling', () => { + let app: FastifyInstance; + let db: DbModule['db']; + let schema: DbModule['schema']; + let dataDir = ''; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-account-token-coverage-failure-')); + process.env.DATA_DIR = dataDir; + + await import('../../db/migrate.js'); + const dbModule = await import('../../db/index.js'); + const accountTokenRoutesModule = await import('./accountTokens.js'); + db = dbModule.db; + schema = dbModule.schema; + + app = Fastify(); + await app.register(accountTokenRoutesModule.accountTokensRoutes); + }); + + beforeEach(async () => { + refreshModelsForAccountMock.mockReset(); + rebuildTokenRoutesFromAvailabilityMock.mockReset(); + + await db.delete(schema.proxyLogs).run(); + await db.delete(schema.checkinLogs).run(); + await db.delete(schema.routeChannels).run(); + await db.delete(schema.tokenRoutes).run(); + await db.delete(schema.tokenModelAvailability).run(); + await db.delete(schema.modelAvailability).run(); + await db.delete(schema.accountTokens).run(); + await db.delete(schema.accounts).run(); + await db.delete(schema.sites).run(); + }); + + afterAll(async () => { + await app.close(); + delete process.env.DATA_DIR; + }); + + it('returns success after token persistence even when coverage rebuild fails', async () => { + const site = await db.insert(schema.sites).values({ + name: 'site-1', + url: 'https://site-1.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'user-1', + accessToken: 'acc-token-1', + status: 'active', + }).returning().get(); + + refreshModelsForAccountMock.mockResolvedValue({ + accountId: account.id, + refreshed: true, + status: 'success', + errorCode: null, + errorMessage: '', + modelCount: 1, + modelsPreview: ['gpt-4o-mini'], + tokenScanned: 1, + discoveredByCredential: false, + discoveredApiToken: false, + }); + rebuildTokenRoutesFromAvailabilityMock.mockRejectedValue(new Error('rebuild failed')); + + const response = await app.inject({ + method: 'POST', + url: '/api/account-tokens', + payload: { + accountId: account.id, + name: 'manual-token', + token: 'sk-manual-token', + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + success: true, + coverageRefresh: { + rebuild: { + success: false, + error: 'rebuild failed', + }, + }, + }); + + const tokens = await db.select() + .from(schema.accountTokens) + .where(eq(schema.accountTokens.accountId, account.id)) + .all(); + expect(tokens).toHaveLength(1); + expect(tokens[0]?.token).toBe('sk-manual-token'); + }); +}); diff --git a/src/server/routes/api/accountTokens.coverage-refresh.test.ts b/src/server/routes/api/accountTokens.coverage-refresh.test.ts new file mode 100644 index 0000000..f2e7b33 --- /dev/null +++ b/src/server/routes/api/accountTokens.coverage-refresh.test.ts @@ -0,0 +1,203 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { eq } from 'drizzle-orm'; + +const getApiTokensMock = vi.fn(); +const getApiTokenMock = vi.fn(); +const createApiTokenMock = vi.fn(); +const getUserGroupsMock = vi.fn(); +const deleteApiTokenMock = vi.fn(); +const getModelsMock = vi.fn(); + +vi.mock('../../services/platforms/index.js', () => ({ + getAdapter: () => ({ + getApiTokens: (...args: unknown[]) => getApiTokensMock(...args), + getApiToken: (...args: unknown[]) => getApiTokenMock(...args), + createApiToken: (...args: unknown[]) => createApiTokenMock(...args), + getUserGroups: (...args: unknown[]) => getUserGroupsMock(...args), + deleteApiToken: (...args: unknown[]) => deleteApiTokenMock(...args), + getModels: (...args: unknown[]) => getModelsMock(...args), + }), +})); + +type DbModule = typeof import('../../db/index.js'); + +describe('account token coverage refresh', () => { + let app: FastifyInstance; + let db: DbModule['db']; + let schema: DbModule['schema']; + let dataDir = ''; + let seedId = 0; + + const nextSeed = () => { + seedId += 1; + return seedId; + }; + + const seedAccount = async (modelName: string) => { + const id = nextSeed(); + const site = await db.insert(schema.sites).values({ + name: `site-${id}`, + url: `https://site-${id}.example.com`, + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: `user-${id}`, + accessToken: `acc-token-${id}`, + status: 'active', + }).returning().get(); + + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName, + available: true, + }).run(); + + return { site, account }; + }; + + const readTokenCandidates = async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/models/token-candidates', + }); + expect(response.statusCode).toBe(200); + return response.json() as { + models: Record>; + modelsWithoutToken: Record>; + }; + }; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-account-token-coverage-')); + process.env.DATA_DIR = dataDir; + + await import('../../db/migrate.js'); + const dbModule = await import('../../db/index.js'); + const accountTokenRoutesModule = await import('./accountTokens.js'); + const statsRoutesModule = await import('./stats.js'); + db = dbModule.db; + schema = dbModule.schema; + + app = Fastify(); + await app.register(accountTokenRoutesModule.accountTokensRoutes); + await app.register(statsRoutesModule.statsRoutes); + }); + + beforeEach(async () => { + getApiTokensMock.mockReset(); + getApiTokenMock.mockReset(); + createApiTokenMock.mockReset(); + getUserGroupsMock.mockReset(); + deleteApiTokenMock.mockReset(); + getModelsMock.mockReset(); + seedId = 0; + + await db.delete(schema.proxyLogs).run(); + await db.delete(schema.checkinLogs).run(); + await db.delete(schema.routeChannels).run(); + await db.delete(schema.tokenRoutes).run(); + await db.delete(schema.tokenModelAvailability).run(); + await db.delete(schema.modelAvailability).run(); + await db.delete(schema.accountTokens).run(); + await db.delete(schema.accounts).run(); + await db.delete(schema.sites).run(); + }); + + afterAll(async () => { + await app.close(); + delete process.env.DATA_DIR; + }); + + it('refreshes token coverage after manually adding an account token', async () => { + const modelName = 'gpt-4o-mini'; + const { account } = await seedAccount(modelName); + getModelsMock.mockImplementation(async (_url: string, credential: string) => { + if (credential === account.accessToken || credential === 'sk-manual-token') { + return [modelName]; + } + return []; + }); + + const response = await app.inject({ + method: 'POST', + url: '/api/account-tokens', + payload: { + accountId: account.id, + name: 'manual-token', + token: 'sk-manual-token', + }, + }); + + expect(response.statusCode).toBe(200); + + const token = await db.select() + .from(schema.accountTokens) + .where(eq(schema.accountTokens.accountId, account.id)) + .get(); + expect(token).toBeDefined(); + + const tokenAvailability = await db.select() + .from(schema.tokenModelAvailability) + .where(eq(schema.tokenModelAvailability.tokenId, token!.id)) + .all(); + expect(tokenAvailability.map((row) => row.modelName)).toContain(modelName); + + const candidates = await readTokenCandidates(); + expect(candidates.modelsWithoutToken[modelName]).toBeUndefined(); + expect(candidates.models[modelName]).toEqual([ + expect.objectContaining({ + accountId: account.id, + tokenId: token!.id, + }), + ]); + }); + + it('refreshes token coverage after syncing account tokens from upstream', async () => { + const modelName = 'claude-sonnet-4-5-20250929'; + const { account } = await seedAccount(modelName); + getApiTokensMock.mockResolvedValue([ + { name: 'default', key: 'sk-synced-token', enabled: true }, + ]); + getModelsMock.mockImplementation(async (_url: string, credential: string) => { + if (credential === account.accessToken || credential === 'sk-synced-token') { + return [modelName]; + } + return []; + }); + + const response = await app.inject({ + method: 'POST', + url: `/api/account-tokens/sync/${account.id}`, + }); + + expect(response.statusCode).toBe(200); + + const token = await db.select() + .from(schema.accountTokens) + .where(eq(schema.accountTokens.accountId, account.id)) + .get(); + expect(token).toBeDefined(); + + const tokenAvailability = await db.select() + .from(schema.tokenModelAvailability) + .where(eq(schema.tokenModelAvailability.tokenId, token!.id)) + .all(); + expect(tokenAvailability.map((row) => row.modelName)).toContain(modelName); + + const candidates = await readTokenCandidates(); + expect(candidates.modelsWithoutToken[modelName]).toBeUndefined(); + expect(candidates.models[modelName]).toEqual([ + expect.objectContaining({ + accountId: account.id, + tokenId: token!.id, + }), + ]); + }); +}); diff --git a/src/server/routes/api/accountTokens.ts b/src/server/routes/api/accountTokens.ts index 2ab0279..6a0407e 100644 --- a/src/server/routes/api/accountTokens.ts +++ b/src/server/routes/api/accountTokens.ts @@ -14,6 +14,11 @@ import { import { getAdapter } from '../../services/platforms/index.js'; import { getCredentialModeFromExtraConfig, resolvePlatformUserId } from '../../services/accountExtraConfig.js'; import { startBackgroundTask } from '../../services/backgroundTaskService.js'; +import { + rebuildTokenRoutesFromAvailability, + refreshModelsForAccount, + type ModelRefreshResult, +} from '../../services/modelService.js'; type AccountWithSiteRow = { accounts: typeof schema.accounts.$inferSelect; @@ -38,6 +43,25 @@ type SyncExecutionResult = { defaultTokenId?: number | null; }; +type CoverageRefreshFailureItem = { + accountId: number; + refreshed: false; + status: 'failed'; + errorCode: 'coverage_refresh_failed'; + errorMessage: string; + modelCount: 0; + modelsPreview: string[]; + reason: 'coverage_refresh_failed'; + tokenScanned: 0; + discoveredByCredential: false; + discoveredApiToken: false; +}; + +type CoverageRefreshItem = ModelRefreshResult | CoverageRefreshFailureItem; +type CoverageRefreshRebuildResult = + | { success: true; result: Awaited> } + | { success: false; error: string }; + const TOKEN_SYNC_TIMEOUT_MS = 15_000; const SYNC_ALL_BATCH_SIZE = 3; @@ -337,6 +361,12 @@ async function executeSyncAllAccountTokens() { results.push(...batchResults); } + const coverageRefresh = await refreshCoverageForAccounts( + results + .filter((item) => item.status === 'synced') + .map((item) => item.accountId), + ); + const summary = { total: results.length, synced: results.filter((item) => item.status === 'synced').length, @@ -346,7 +376,72 @@ async function executeSyncAllAccountTokens() { updated: results.reduce((acc, item) => acc + item.updated, 0), }; - return { summary, results }; + return { summary, results, coverageRefresh }; +} + +async function refreshCoverageForAccounts(accountIds: number[]) { + const uniqueAccountIds = Array.from(new Set( + accountIds.filter((id) => Number.isFinite(id) && id > 0), + )); + + if (uniqueAccountIds.length === 0) { + return { refresh: [], rebuild: null }; + } + + const refresh: CoverageRefreshItem[] = []; + for (let offset = 0; offset < uniqueAccountIds.length; offset += SYNC_ALL_BATCH_SIZE) { + const batch = uniqueAccountIds.slice(offset, offset + SYNC_ALL_BATCH_SIZE); + const settled = await Promise.allSettled( + batch.map(async (accountId) => refreshModelsForAccount(accountId)), + ); + settled.forEach((result, index) => { + if (result.status === 'fulfilled') { + refresh.push(result.value); + return; + } + + const accountId = batch[index] || 0; + const errorMessage = result.reason instanceof Error ? result.reason.message : String(result.reason || 'coverage refresh failed'); + refresh.push(buildCoverageRefreshFailureItem(accountId, errorMessage)); + console.warn(`[account-tokens] coverage refresh failed for account ${accountId}: ${errorMessage}`); + }); + } + + let rebuild: CoverageRefreshRebuildResult | null = null; + try { + rebuild = { + success: true, + result: await rebuildTokenRoutesFromAvailability(), + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error || 'route rebuild failed'); + rebuild = { + success: false, + error: errorMessage, + }; + console.warn(`[account-tokens] token route rebuild failed after coverage refresh: ${errorMessage}`); + } + + return { refresh, rebuild }; +} + +function buildCoverageRefreshFailureItem( + accountId: number, + errorMessage: string, +): CoverageRefreshFailureItem { + return { + accountId, + refreshed: false, + status: 'failed', + errorCode: 'coverage_refresh_failed', + errorMessage, + modelCount: 0, + modelsPreview: [], + reason: 'coverage_refresh_failed', + tokenScanned: 0, + discoveredByCredential: false, + discoveredApiToken: false, + }; } export async function accountTokensRoutes(app: FastifyInstance) { @@ -416,8 +511,8 @@ export async function accountTokensRoutes(app: FastifyInstance) { } else if (existing.every((token) => !token.isDefault) && (body.enabled ?? true)) { await setDefaultToken(created.id); } - - return { success: true, token: created }; + const coverageRefresh = await refreshCoverageForAccounts([body.accountId]); + return { success: true, token: created, coverageRefresh }; } const account = row.accounts; @@ -496,6 +591,7 @@ export async function accountTokensRoutes(app: FastifyInstance) { if (syncResult.status === 'skipped') { return reply.code(502).send({ success: false, message: syncResult.message || '站点未返回可用令牌' }); } + const coverageRefresh = await refreshCoverageForAccounts([account.id]); const preferred = await db.select().from(schema.accountTokens) .where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.isDefault, true))) @@ -509,6 +605,7 @@ export async function accountTokensRoutes(app: FastifyInstance) { success: true, createdViaUpstream: true, ...syncResult, + coverageRefresh, token, }; }); @@ -823,7 +920,8 @@ export async function accountTokensRoutes(app: FastifyInstance) { if (result.status === 'failed') { return reply.code(502).send({ success: false, message: result.message || '同步失败' }); } - return { success: true, ...result }; + const coverageRefresh = await refreshCoverageForAccounts([accountId]); + return { success: true, ...result, coverageRefresh }; }); app.post<{ Body?: { wait?: boolean } }>('/api/account-tokens/sync-all', async (request, reply) => { diff --git a/src/server/routes/proxy/gemini.test.ts b/src/server/routes/proxy/gemini.test.ts index b0abebe..fbe85af 100644 --- a/src/server/routes/proxy/gemini.test.ts +++ b/src/server/routes/proxy/gemini.test.ts @@ -6,10 +6,19 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites const fetchMock = vi.fn(); const selectChannelMock = vi.fn(); const selectNextChannelMock = vi.fn(); +const recordSuccessMock = vi.fn(); const recordFailureMock = vi.fn(); +const explainSelectionMock = vi.fn(); const invalidateTokenRouterCacheMock = vi.fn(); const authorizeDownstreamTokenMock = vi.fn(); const consumeManagedKeyRequestMock = vi.fn(); +const isModelAllowedByPolicyOrAllowedRoutesMock = vi.fn(); +const dbInsertValuesMock = vi.fn((_values?: unknown) => ({ + run: () => undefined, +})); +const dbInsertMock = vi.fn((_table?: unknown) => ({ + values: (values: unknown) => dbInsertValuesMock(values), +})); vi.mock('undici', () => ({ fetch: (...args: unknown[]) => fetchMock(...args), @@ -19,8 +28,9 @@ vi.mock('../../services/tokenRouter.js', () => ({ tokenRouter: { selectChannel: (...args: unknown[]) => selectChannelMock(...args), selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args), + recordSuccess: (...args: unknown[]) => recordSuccessMock(...args), recordFailure: (...args: unknown[]) => recordFailureMock(...args), - explainSelection: vi.fn(async () => ({ selectedChannelId: 11 })), + explainSelection: (...args: unknown[]) => explainSelectionMock(...args), }, invalidateTokenRouterCache: (...args: unknown[]) => invalidateTokenRouterCacheMock(...args), })); @@ -28,6 +38,16 @@ vi.mock('../../services/tokenRouter.js', () => ({ vi.mock('../../services/downstreamApiKeyService.js', () => ({ authorizeDownstreamToken: (...args: unknown[]) => authorizeDownstreamTokenMock(...args), consumeManagedKeyRequest: (...args: unknown[]) => consumeManagedKeyRequestMock(...args), + isModelAllowedByPolicyOrAllowedRoutes: (...args: unknown[]) => isModelAllowedByPolicyOrAllowedRoutesMock(...args), +})); + +vi.mock('../../db/index.js', () => ({ + db: { + insert: (arg: unknown) => dbInsertMock(arg), + }, + schema: { + proxyLogs: {}, + }, })); function parseSsePayloads(body: string): Array> { @@ -82,9 +102,14 @@ describe('gemini native proxy routes', () => { fetchMock.mockReset(); selectChannelMock.mockReset(); selectNextChannelMock.mockReset(); + recordSuccessMock.mockReset(); recordFailureMock.mockReset(); + explainSelectionMock.mockReset(); authorizeDownstreamTokenMock.mockReset(); consumeManagedKeyRequestMock.mockReset(); + isModelAllowedByPolicyOrAllowedRoutesMock.mockReset(); + dbInsertMock.mockClear(); + dbInsertValuesMock.mockClear(); authorizeDownstreamTokenMock.mockResolvedValue({ ok: true, @@ -102,7 +127,10 @@ describe('gemini native proxy routes', () => { actualModel: 'gemini-2.5-flash', }); selectNextChannelMock.mockReturnValue(null); + recordSuccessMock.mockResolvedValue(undefined); recordFailureMock.mockResolvedValue(undefined); + explainSelectionMock.mockResolvedValue({ selectedChannelId: 11 }); + isModelAllowedByPolicyOrAllowedRoutesMock.mockResolvedValue(true); }); afterAll(async () => { @@ -241,6 +269,108 @@ describe('gemini native proxy routes', () => { }); }); + it('filters Gemini native model list by downstream managed-key policy', async () => { + authorizeDownstreamTokenMock.mockResolvedValue({ + ok: true, + source: 'managed', + token: 'sk-managed-gemini', + key: { id: 91 }, + policy: { supportedModels: ['gemini-2.5-flash'], allowedRouteIds: [], siteWeightMultipliers: {} }, + }); + isModelAllowedByPolicyOrAllowedRoutesMock.mockImplementation(async (modelName: string) => modelName === 'gemini-2.5-flash'); + explainSelectionMock.mockImplementation(async (modelName: string) => ( + modelName === 'gemini-2.5-flash' + ? { selectedChannelId: 11 } + : { selectedChannelId: undefined } + )); + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + models: [ + { name: 'models/gemini-2.5-flash', displayName: 'Gemini 2.5 Flash' }, + { name: 'models/gemini-2.0-flash', displayName: 'Gemini 2.0 Flash' }, + ], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'GET', + url: '/v1beta/models', + headers: { + authorization: 'Bearer sk-managed-gemini', + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + models: [ + { + name: 'models/gemini-2.5-flash', + displayName: 'Gemini 2.5 Flash', + }, + ], + }); + expect(isModelAllowedByPolicyOrAllowedRoutesMock).toHaveBeenCalledWith('gemini-2.5-flash', { supportedModels: ['gemini-2.5-flash'], allowedRouteIds: [], siteWeightMultipliers: {} }); + expect(isModelAllowedByPolicyOrAllowedRoutesMock).toHaveBeenCalledWith('gemini-2.0-flash', { supportedModels: ['gemini-2.5-flash'], allowedRouteIds: [], siteWeightMultipliers: {} }); + }); + + it('writes a proxy log row for successful native generateContent requests', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + candidates: [ + { + content: { + parts: [{ text: 'hello from gemini' }], + role: 'model', + }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 10, + candidatesTokenCount: 5, + totalTokenCount: 15, + }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1beta/models/gemini-2.5-flash:generateContent', + headers: { + 'x-goog-api-key': 'sk-managed-gemini', + }, + payload: { + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + expect(recordSuccessMock).toHaveBeenCalledWith(11, expect.any(Number), 0); + expect(dbInsertMock).toHaveBeenCalledTimes(1); + expect(dbInsertValuesMock).toHaveBeenCalledWith(expect.objectContaining({ + routeId: 22, + channelId: 11, + accountId: 33, + modelRequested: 'gemini-2.5-flash', + modelActual: 'gemini-2.5-flash', + status: 'success', + httpStatus: 200, + retryCount: 0, + promptTokens: 10, + completionTokens: 5, + totalTokens: 15, + errorMessage: '[downstream:/v1beta/models/gemini-2.5-flash:generateContent] [upstream:/v1beta/models/gemini-2.5-flash:generateContent]', + createdAt: expect.any(String), + })); + }); + it('forwards explicit gemini version paths through transformer-owned parsing helpers', async () => { fetchMock.mockResolvedValue(new Response(JSON.stringify({ candidates: [ @@ -842,4 +972,67 @@ describe('gemini native proxy routes', () => { expect(recordFailureMock).toHaveBeenCalledWith(11); expect(response.body).toContain('hello from fallback sse'); }); + + it('writes failed and successful proxy log rows for Gemini-native stream retries', async () => { + selectNextChannelMock.mockReturnValue({ + channel: { id: 12, routeId: 22 }, + site: { id: 45, name: 'gemini-site-2', url: 'https://generativelanguage.googleapis.com', platform: 'gemini' }, + account: { id: 34, username: 'demo-user-2' }, + tokenName: 'fallback', + tokenValue: 'gemini-key-2', + actualModel: 'gemini-2.5-flash', + }); + + const encoder = new TextEncoder(); + const upstreamBody = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: {"responseId":"resp-fallback","candidates":[{"content":{"role":"model","parts":[{"text":"hello from fallback sse"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":11,"candidatesTokenCount":6,"totalTokenCount":17}}\r\n\r\n')); + controller.close(); + }, + }); + + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ + error: { message: 'upstream unavailable' }, + }), { + status: 500, + headers: { 'content-type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(upstreamBody, { + status: 200, + headers: { 'content-type': 'text/event-stream; charset=utf-8' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse', + headers: { + 'x-goog-api-key': 'sk-managed-gemini', + }, + payload: { + contents: [{ role: 'user', parts: [{ text: 'hello' }] }], + }, + }); + + expect(response.statusCode).toBe(200); + expect(dbInsertMock).toHaveBeenCalledTimes(2); + expect(dbInsertValuesMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ + channelId: 11, + status: 'failed', + httpStatus: 500, + retryCount: 0, + errorMessage: '[downstream:/v1beta/models/gemini-2.5-flash:streamGenerateContent] [upstream:/v1beta/models/gemini-2.5-flash:streamGenerateContent] {\"error\":{\"message\":\"upstream unavailable\"}}', + })); + expect(dbInsertValuesMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ + channelId: 12, + status: 'success', + httpStatus: 200, + retryCount: 1, + promptTokens: 11, + completionTokens: 6, + totalTokens: 17, + errorMessage: '[downstream:/v1beta/models/gemini-2.5-flash:streamGenerateContent] [upstream:/v1beta/models/gemini-2.5-flash:streamGenerateContent]', + })); + expect(recordSuccessMock).toHaveBeenCalledWith(12, expect.any(Number), 0); + }); }); diff --git a/src/server/routes/proxy/gemini.ts b/src/server/routes/proxy/gemini.ts index 7a80d84..d9f724c 100644 --- a/src/server/routes/proxy/gemini.ts +++ b/src/server/routes/proxy/gemini.ts @@ -1,8 +1,13 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { TextDecoder } from 'node:util'; import { fetch } from 'undici'; +import { db, schema } from '../../db/index.js'; +import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; +import { parseProxyUsage } from '../../services/proxyUsageParser.js'; +import { isModelAllowedByPolicyOrAllowedRoutes } from '../../services/downstreamApiKeyService.js'; import { tokenRouter } from '../../services/tokenRouter.js'; import { getDownstreamRoutingPolicy } from './downstreamPolicy.js'; +import { composeProxyLogMessage } from './logPathMeta.js'; import { geminiGenerateContentTransformer, } from '../../transformers/gemini/generate-content/index.js'; @@ -14,6 +19,11 @@ const GEMINI_MODEL_PROBES = [ 'gemini-1.5-flash', 'gemini-pro', ]; +const EMPTY_PROXY_USAGE = { + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, +}; async function selectGeminiChannel(request: FastifyRequest) { const policy = getDownstreamRoutingPolicy(request); @@ -33,6 +43,106 @@ async function selectNextGeminiProbeChannel(request: FastifyRequest, excludeChan return null; } +function resolveDownstreamPath(request: FastifyRequest): string { + const rawUrl = request.raw.url || request.url || ''; + const withoutQuery = rawUrl.split('?')[0] || rawUrl; + return withoutQuery || '/v1beta/models'; +} + +function resolveUpstreamPath(apiVersion: string, modelActionPath: string): string { + const normalizedVersion = apiVersion.replace(/^\/+/, ''); + const normalizedAction = modelActionPath.replace(/^\/+/, ''); + return `/${normalizedVersion}/${normalizedAction}`; +} + +function hasDownstreamModelRestrictions(policy: { supportedModels?: unknown; allowedRouteIds?: unknown }): boolean { + const supportedModels = Array.isArray(policy.supportedModels) ? policy.supportedModels : []; + const allowedRouteIds = Array.isArray(policy.allowedRouteIds) ? policy.allowedRouteIds : []; + return supportedModels.length > 0 || allowedRouteIds.length > 0; +} + +function extractGeminiListedModelName(item: unknown): string { + if (!item || typeof item !== 'object') return ''; + const rawName = typeof (item as { name?: unknown }).name === 'string' + ? (item as { name: string }).name.trim() + : ''; + if (!rawName) return ''; + return rawName.startsWith('models/') ? rawName.slice('models/'.length) : rawName; +} + +async function filterGeminiListedModelsForPolicy( + payload: unknown, + request: FastifyRequest, +): Promise { + if (!payload || typeof payload !== 'object' || !Array.isArray((payload as { models?: unknown[] }).models)) { + return payload; + } + + const policy = getDownstreamRoutingPolicy(request); + if (!hasDownstreamModelRestrictions(policy)) { + return payload; + } + + const filteredModels: unknown[] = []; + for (const item of (payload as { models: unknown[] }).models) { + const modelName = extractGeminiListedModelName(item); + if (!modelName) continue; + if (!await isModelAllowedByPolicyOrAllowedRoutes(modelName, policy)) continue; + const decision = await tokenRouter.explainSelection?.(modelName, [], policy); + if (decision && typeof decision.selectedChannelId !== 'number') continue; + filteredModels.push(item); + } + + return { + ...(payload as Record), + models: filteredModels, + }; +} + +async function logProxy( + selected: any, + modelRequested: string, + status: string, + httpStatus: number, + latencyMs: number, + errorMessage: string | null, + retryCount: number, + downstreamPath: string, + upstreamPath: string | null, + promptTokens = 0, + completionTokens = 0, + totalTokens = 0, +) { + try { + const createdAt = formatUtcSqlDateTime(new Date()); + const normalizedErrorMessage = composeProxyLogMessage({ + downstreamPath, + upstreamPath, + errorMessage, + }); + await db.insert(schema.proxyLogs).values({ + routeId: selected.channel.routeId, + channelId: selected.channel.id, + accountId: selected.account.id, + modelRequested, + modelActual: selected.actualModel || modelRequested, + status, + httpStatus, + latencyMs, + promptTokens, + completionTokens, + totalTokens, + estimatedCost: 0, + billingDetails: null, + errorMessage: normalizedErrorMessage, + retryCount, + createdAt, + }).run(); + } catch (error) { + console.warn('[proxy/gemini] failed to write proxy log', error); + } +} + export async function geminiProxyRoute(app: FastifyInstance) { const listModels = async (request: FastifyRequest, reply: FastifyReply) => { const apiVersion = geminiGenerateContentTransformer.resolveProxyApiVersion( @@ -72,7 +182,9 @@ export async function geminiProxyRoute(app: FastifyInstance) { } try { - return reply.code(upstream.status).send(JSON.parse(text)); + const parsed = JSON.parse(text); + const filtered = await filterGeminiListedModelsForPolicy(parsed, request); + return reply.code(upstream.status).send(filtered); } catch { return reply.code(upstream.status).type(upstream.headers.get('content-type') || 'application/json').send(text); } @@ -107,6 +219,7 @@ export async function geminiProxyRoute(app: FastifyInstance) { } const policy = getDownstreamRoutingPolicy(request); + const downstreamPath = resolveDownstreamPath(request); const excludeChannelIds: number[] = []; let retryCount = 0; let lastStatus = 503; @@ -132,7 +245,9 @@ export async function geminiProxyRoute(app: FastifyInstance) { /^models\/[^:]+/, `models/${selected.actualModel || requestedModel}`, ); + const upstreamPath = resolveUpstreamPath(apiVersion, actualModelAction); const query = new URLSearchParams(request.query as Record).toString(); + const startTime = Date.now(); try { const upstream = await fetch( geminiGenerateContentTransformer.resolveActionUrl( @@ -156,6 +271,17 @@ export async function geminiProxyRoute(app: FastifyInstance) { lastContentType = contentType; lastText = await upstream.text(); await tokenRouter.recordFailure?.(selected.channel.id); + await logProxy( + selected, + requestedModel, + 'failed', + lastStatus, + Date.now() - startTime, + lastText, + retryCount, + downstreamPath, + upstreamPath, + ); if (retryCount < MAX_RETRIES) { retryCount += 1; continue; @@ -174,6 +300,19 @@ export async function geminiProxyRoute(app: FastifyInstance) { reply.raw.setHeader('Content-Type', contentType || 'text/event-stream'); const reader = upstream.body?.getReader(); if (!reader) { + const latency = Date.now() - startTime; + await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0); + await logProxy( + selected, + requestedModel, + 'success', + upstream.status, + latency, + null, + retryCount, + downstreamPath, + upstreamPath, + ); reply.raw.end(); return; } @@ -209,21 +348,70 @@ export async function geminiProxyRoute(app: FastifyInstance) { reader.releaseLock(); reply.raw.end(); } + const parsedUsage = parseProxyUsage(aggregateState); + const latency = Date.now() - startTime; + await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0); + await logProxy( + selected, + requestedModel, + 'success', + upstream.status, + latency, + null, + retryCount, + downstreamPath, + upstreamPath, + parsedUsage.promptTokens, + parsedUsage.completionTokens, + parsedUsage.totalTokens, + ); return; } const text = await upstream.text(); + const aggregateState = geminiGenerateContentTransformer.stream.createAggregateState(); + let parsedUsage = EMPTY_PROXY_USAGE; try { const parsed = JSON.parse(text); - const aggregateState = geminiGenerateContentTransformer.stream.createAggregateState(); + const responsePayload = geminiGenerateContentTransformer.stream.serializeUpstreamJsonPayload( + aggregateState, + parsed, + isStreamAction, + ); + parsedUsage = parseProxyUsage(aggregateState); + const latency = Date.now() - startTime; + await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0); + await logProxy( + selected, + requestedModel, + 'success', + upstream.status, + latency, + null, + retryCount, + downstreamPath, + upstreamPath, + parsedUsage.promptTokens, + parsedUsage.completionTokens, + parsedUsage.totalTokens, + ); return reply.code(upstream.status).send( - geminiGenerateContentTransformer.stream.serializeUpstreamJsonPayload( - aggregateState, - parsed, - isStreamAction, - ), + responsePayload, ); } catch { + const latency = Date.now() - startTime; + await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0); + await logProxy( + selected, + requestedModel, + 'success', + upstream.status, + latency, + null, + retryCount, + downstreamPath, + upstreamPath, + ); return reply.code(upstream.status).type(contentType || 'application/json').send(text); } } catch (error) { @@ -236,6 +424,17 @@ export async function geminiProxyRoute(app: FastifyInstance) { }, }); await tokenRouter.recordFailure?.(selected.channel.id); + await logProxy( + selected, + requestedModel, + 'failed', + 0, + Date.now() - startTime, + error instanceof Error ? error.message : 'Gemini upstream request failed', + retryCount, + downstreamPath, + upstreamPath, + ); if (retryCount < MAX_RETRIES) { retryCount += 1; continue; diff --git a/src/server/services/modelService.ts b/src/server/services/modelService.ts index b97ad79..17cd49c 100644 --- a/src/server/services/modelService.ts +++ b/src/server/services/modelService.ts @@ -12,6 +12,61 @@ const MODEL_DISCOVERY_TIMEOUT_MS = 12_000; const MODEL_REFRESH_BATCH_SIZE = 3; type ModelRefreshErrorCode = 'timeout' | 'unauthorized' | 'empty_models' | 'unknown'; +type ModelRefreshSkipCode = 'site_disabled' | 'adapter_or_status'; + +export type ModelRefreshAccountNotFoundResult = { + accountId: number; + refreshed: false; + status: 'failed'; + errorCode: 'account_not_found'; + errorMessage: '账号不存在'; + modelCount: 0; + modelsPreview: string[]; + reason: 'account_not_found'; +}; + +export type ModelRefreshSkippedResult = { + accountId: number; + refreshed: false; + status: 'skipped'; + errorCode: ModelRefreshSkipCode; + errorMessage: string; + modelCount: 0; + modelsPreview: string[]; + reason: ModelRefreshSkipCode; +}; + +export type ModelRefreshFailureResult = { + accountId: number; + refreshed: true; + status: 'failed'; + errorCode: ModelRefreshErrorCode; + errorMessage: string; + modelCount: 0; + modelsPreview: string[]; + tokenScanned: number; + discoveredByCredential: boolean; + discoveredApiToken: boolean; +}; + +export type ModelRefreshSuccessResult = { + accountId: number; + refreshed: true; + status: 'success'; + errorCode: null; + errorMessage: ''; + modelCount: number; + modelsPreview: string[]; + tokenScanned: number; + discoveredByCredential: boolean; + discoveredApiToken: boolean; +}; + +export type ModelRefreshResult = + | ModelRefreshAccountNotFoundResult + | ModelRefreshSkippedResult + | ModelRefreshFailureResult + | ModelRefreshSuccessResult; function classifyModelDiscoveryError(message: string): ModelRefreshErrorCode { const lowered = message.toLowerCase(); @@ -64,23 +119,88 @@ async function withTimeout(fn: () => Promise, timeoutMs: number, timeoutMe } } -export async function refreshModelsForAccount(accountId: number) { +function buildAccountNotFoundRefreshResult(accountId: number): ModelRefreshAccountNotFoundResult { + return { + accountId, + refreshed: false, + status: 'failed', + errorCode: 'account_not_found', + errorMessage: '账号不存在', + modelCount: 0, + modelsPreview: [], + reason: 'account_not_found', + }; +} + +function buildSkippedRefreshResult( + accountId: number, + code: ModelRefreshSkipCode, + errorMessage: string, +): ModelRefreshSkippedResult { + return { + accountId, + refreshed: false, + status: 'skipped', + errorCode: code, + errorMessage, + modelCount: 0, + modelsPreview: [], + reason: code, + }; +} + +function buildFailedRefreshResult(input: { + accountId: number; + errorCode: ModelRefreshErrorCode; + errorMessage: string; + tokenScanned: number; + discoveredByCredential: boolean; + discoveredApiToken: boolean; +}): ModelRefreshFailureResult { + return { + accountId: input.accountId, + refreshed: true, + status: 'failed', + errorCode: input.errorCode, + errorMessage: input.errorMessage, + modelCount: 0, + modelsPreview: [], + tokenScanned: input.tokenScanned, + discoveredByCredential: input.discoveredByCredential, + discoveredApiToken: input.discoveredApiToken, + }; +} + +function buildSuccessfulRefreshResult(input: { + accountId: number; + modelCount: number; + modelsPreview: string[]; + tokenScanned: number; + discoveredByCredential: boolean; + discoveredApiToken: boolean; +}): ModelRefreshSuccessResult { + return { + accountId: input.accountId, + refreshed: true, + status: 'success', + errorCode: null, + errorMessage: '', + modelCount: input.modelCount, + modelsPreview: input.modelsPreview, + tokenScanned: input.tokenScanned, + discoveredByCredential: input.discoveredByCredential, + discoveredApiToken: input.discoveredApiToken, + }; +} + +export async function refreshModelsForAccount(accountId: number): Promise { const row = await db.select().from(schema.accounts) .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) .where(eq(schema.accounts.id, accountId)) .get(); if (!row) { - return { - accountId, - refreshed: false, - status: 'failed', - errorCode: 'account_not_found', - errorMessage: '账号不存在', - modelCount: 0, - modelsPreview: [], - reason: 'account_not_found', - }; + return buildAccountNotFoundRefreshResult(accountId); } const account = row.accounts; @@ -103,29 +223,11 @@ export async function refreshModelsForAccount(accountId: number) { } if (isSiteDisabled(site.status)) { - return { - accountId, - refreshed: false, - status: 'skipped', - errorCode: 'site_disabled', - errorMessage: '站点已禁用', - modelCount: 0, - modelsPreview: [], - reason: 'site_disabled', - }; + return buildSkippedRefreshResult(accountId, 'site_disabled', '站点已禁用'); } if (!adapter || account.status !== 'active') { - return { - accountId, - refreshed: false, - status: 'skipped', - errorCode: 'adapter_or_status', - errorMessage: '平台不可用或账号未激活', - modelCount: 0, - modelsPreview: [], - reason: 'adapter_or_status', - }; + return buildSkippedRefreshResult(accountId, 'adapter_or_status', '平台不可用或账号未激活'); } const platformUserId = resolvePlatformUserId(account.extraConfig, account.username); @@ -266,18 +368,14 @@ export async function refreshModelsForAccount(accountId: number) { source: 'model-discovery', checkedAt: new Date().toISOString(), }); - return { + return buildFailedRefreshResult({ accountId, - refreshed: true, - status: 'failed', errorCode, errorMessage, - modelCount: 0, - modelsPreview: [], tokenScanned: scannedTokenCount, discoveredByCredential, discoveredApiToken: !!discoveredApiToken, - }; + }); } const checkedAt = new Date().toISOString(); @@ -299,26 +397,22 @@ export async function refreshModelsForAccount(accountId: number) { }); const modelsPreview = Array.from(accountModels).slice(0, 10); - return { + return buildSuccessfulRefreshResult({ accountId, - refreshed: true, - status: 'success', - errorCode: null, - errorMessage: '', modelCount: accountModels.size, modelsPreview, tokenScanned: scannedTokenCount, discoveredByCredential, discoveredApiToken: !!discoveredApiToken, - }; + }); } -async function refreshModelsForAllActiveAccounts() { +async function refreshModelsForAllActiveAccounts(): Promise { const accounts = await db.select({ id: schema.accounts.id }).from(schema.accounts) .where(eq(schema.accounts.status, 'active')) .all(); - const results: any[] = []; + const results: ModelRefreshResult[] = []; for (let offset = 0; offset < accounts.length; offset += MODEL_REFRESH_BATCH_SIZE) { const batch = accounts.slice(offset, offset + MODEL_REFRESH_BATCH_SIZE); const batchResults = await Promise.all(batch.map(async (account) => refreshModelsForAccount(account.id))); diff --git a/src/server/services/tokenRouter.patterns.test.ts b/src/server/services/tokenRouter.patterns.test.ts index e5f1e2c..8f753fa 100644 --- a/src/server/services/tokenRouter.patterns.test.ts +++ b/src/server/services/tokenRouter.patterns.test.ts @@ -154,4 +154,31 @@ describe('TokenRouter patterns and model mapping', () => { expect(decision.actualModel).toBe('claude-opus-4-5'); expect(exposedModels).toContain('claude-opus-4-6'); }); + + it('prefers an exact route over a colliding group display-name alias', async () => { + await createRouteWithSingleChannel( + 're:^claude-(opus|sonnet)-4-5$', + undefined, + { + displayName: 'claude-opus-4-6', + sourceModel: 'claude-opus-4-5', + }, + ); + const exact = await createRouteWithSingleChannel( + 'claude-opus-4-6', + undefined, + { + sourceModel: 'claude-opus-4-6', + }, + ); + const router = new TokenRouter(); + + const selected = await router.selectChannel('claude-opus-4-6'); + const decision = await router.explainSelection('claude-opus-4-6'); + + expect(selected).toBeTruthy(); + expect(selected?.channel.id).toBe(exact.channel.id); + expect(selected?.actualModel).toBe('claude-opus-4-6'); + expect(decision.actualModel).toBe('claude-opus-4-6'); + }); }); diff --git a/src/server/services/tokenRouter.ts b/src/server/services/tokenRouter.ts index 454e02d..5f05b8f 100644 --- a/src/server/services/tokenRouter.ts +++ b/src/server/services/tokenRouter.ts @@ -257,9 +257,9 @@ export function parseRegexModelPattern(pattern: string): RegExp | null { } } -export function matchesModelPattern(model: string, pattern: string): boolean { - const normalizedPattern = (pattern || '').trim(); - if (!normalizedPattern) return false; +export function matchesModelPattern(model: string, pattern: string): boolean { + const normalizedPattern = (pattern || '').trim(); + if (!normalizedPattern) return false; if (normalizedPattern === model) return true; @@ -268,8 +268,15 @@ export function matchesModelPattern(model: string, pattern: string): boolean { return !!re && re.test(model); } - return minimatch(model, normalizedPattern); -} + return minimatch(model, normalizedPattern); +} + +function isExactRouteModelPattern(pattern: string): boolean { + const normalizedPattern = (pattern || '').trim(); + if (!normalizedPattern) return false; + if (isRegexModelPattern(normalizedPattern)) return false; + return !/[\*\?\[]/.test(normalizedPattern); +} function normalizeRouteDisplayName(displayName: string | null | undefined): string { return (displayName || '').trim(); @@ -993,12 +1000,14 @@ export class TokenRouter { routes = routes.filter((route) => allowSet.has(route.id)); } - // Find matching route by model pattern or display alias. - const matchedRoute = routes.find((r) => { - return matchesRouteRequestModel(model, r); - }); - - if (!matchedRoute) return null; + const matchedRoute = routes.find((route) => ( + isExactRouteModelPattern(route.modelPattern) + && (route.modelPattern || '').trim() === model + )) + || routes.find((route) => isRouteDisplayNameMatch(model, route.displayName)) + || routes.find((route) => matchesModelPattern(model, route.modelPattern)); + + if (!matchedRoute) return null; return await this.loadRouteMatch(matchedRoute); } diff --git a/src/web/pages/ModelTester.tsx b/src/web/pages/ModelTester.tsx index 04048fd..b8a9ffe 100644 --- a/src/web/pages/ModelTester.tsx +++ b/src/web/pages/ModelTester.tsx @@ -11,6 +11,7 @@ import { buildApiPayload, buildEmbeddingsRequestEnvelope, buildFileUploadRequestEnvelope, + buildGeminiNativeConversationProxyEnvelope, buildImagesEditRequestEnvelope, buildImagesGenerationsRequestEnvelope, buildRawProxyRequestEnvelope, @@ -1027,30 +1028,6 @@ export default function ModelTester() { }; }, [buildConversationMessagesWithSystem, inputs.max_tokens, inputs.model, inputs.stream, inputs.temperature, inputs.top_p, parameterEnabled.max_tokens, parameterEnabled.temperature, parameterEnabled.top_p]); - const buildGeminiBodyFromMessages = useCallback((baseMessages: ChatMessage[]) => { - const effectiveMessages = buildConversationMessagesWithSystem(baseMessages); - const systemContents = effectiveMessages - .filter((message) => message.role === 'system' || message.role === 'developer') - .map((message) => message.content.trim()) - .filter(Boolean); - const contents = effectiveMessages - .filter((message) => message.role !== 'system' && message.role !== 'developer') - .map((message) => ({ - role: message.role === 'assistant' ? 'model' : 'user', - parts: [{ text: message.content }], - })); - - return { - ...(systemContents.length > 0 ? { systemInstruction: { parts: [{ text: systemContents.join('\n\n') }] } } : {}), - contents, - generationConfig: { - ...(parameterEnabled.temperature ? { temperature: inputs.temperature } : {}), - ...(parameterEnabled.top_p ? { topP: inputs.top_p } : {}), - ...(parameterEnabled.max_tokens ? { maxOutputTokens: inputs.max_tokens } : {}), - }, - }; - }, [buildConversationMessagesWithSystem, inputs.max_tokens, inputs.temperature, inputs.top_p, parameterEnabled.max_tokens, parameterEnabled.temperature, parameterEnabled.top_p]); - const buildConversationProxyEnvelope = useCallback((baseMessages: ChatMessage[]): ProxyTestEnvelope => { const normalizedMessages = buildConversationMessagesWithSystem(baseMessages); @@ -1099,15 +1076,7 @@ export default function ModelTester() { } if (inputs.protocol === 'gemini') { - return { - method: 'POST', - path: `/gemini/v1beta/models/${encodeURIComponent(inputs.model)}:generateContent${inputs.stream ? '?alt=sse' : ''}`, - requestKind: 'json', - stream: inputs.stream, - jobMode: false, - rawMode: false, - jsonBody: buildGeminiBodyFromMessages(baseMessages), - }; + return buildGeminiNativeConversationProxyEnvelope(baseMessages, inputs, parameterEnabled); } const openAiEnvelope = buildApiPayload(normalizedMessages, { ...inputs, protocol: 'openai' }, parameterEnabled); @@ -1124,7 +1093,7 @@ export default function ModelTester() { rawMode: false, jsonBody: openAiPayload, }; - }, [buildApiPayload, buildClaudeBodyFromMessages, buildConversationMessagesWithSystem, buildGeminiBodyFromMessages, buildResponsesBodyFromMessages, customRequestBody, customRequestMode, inputs, parameterEnabled]); + }, [buildApiPayload, buildClaudeBodyFromMessages, buildConversationMessagesWithSystem, buildResponsesBodyFromMessages, customRequestBody, customRequestMode, inputs, parameterEnabled]); const buildModeProxyEnvelope = useCallback((): ProxyTestEnvelope | null => { if (inputs.mode === 'embeddings') { diff --git a/src/web/pages/TokenRoutes.tsx b/src/web/pages/TokenRoutes.tsx index 706c39f..184cd06 100644 --- a/src/web/pages/TokenRoutes.tsx +++ b/src/web/pages/TokenRoutes.tsx @@ -32,6 +32,7 @@ import type { RouteDecision, RouteIconOption, MissingTokenRouteSiteActionItem, + MissingTokenGroupRouteSiteActionItem, GroupRouteItem, } from './token-routes/types.js'; import { @@ -59,6 +60,7 @@ const EMPTY_ROUTE_CANDIDATE_VIEW: RouteCandidateView = { tokenOptionsByAccountId: {}, }; const EMPTY_MISSING_ITEMS: MissingTokenRouteSiteActionItem[] = []; +const EMPTY_MISSING_GROUP_ITEMS: MissingTokenGroupRouteSiteActionItem[] = []; const ROUTE_ICON_OPTIONS: RouteIconOption[] = [ { value: '', label: '自动品牌图标', description: '按模型匹配规则自动识别品牌', iconText: '✦' }, ]; @@ -68,6 +70,7 @@ export default function TokenRoutes() { const [routeSummaries, setRouteSummaries] = useState([]); const [modelCandidates, setModelCandidates] = useState({}); const [missingTokenModelsByName, setMissingTokenModelsByName] = useState({}); + const [missingTokenGroupModelsByName, setMissingTokenGroupModelsByName] = useState({}); const [endpointTypesByModel, setEndpointTypesByModel] = useState>({}); const [search, setSearch] = useState(''); @@ -189,6 +192,9 @@ export default function TokenRoutes() { setMissingTokenModelsByName( normalizeMissingTokenModels((candidateRows?.modelsWithoutToken || {}) as MissingTokenModelsByName), ); + setMissingTokenGroupModelsByName( + normalizeMissingTokenModels((candidateRows?.modelsMissingTokenGroups || {}) as MissingTokenModelsByName), + ); setEndpointTypesByModel(candidateRows?.endpointTypesByModel || {}); const decisionPlaceholder: Record = {}; for (const route of summaries) { @@ -586,7 +592,12 @@ export default function TokenRoutes() { if (search.trim()) { const q = search.trim().toLowerCase(); - list = list.filter((route) => route.modelPattern.toLowerCase().includes(q)); + list = list.filter((route) => { + const modelPattern = route.modelPattern.toLowerCase(); + const displayName = (route.displayName || '').toLowerCase(); + const title = resolveRouteTitle(route).toLowerCase(); + return modelPattern.includes(q) || displayName.includes(q) || title.includes(q); + }); } return list; @@ -636,6 +647,10 @@ export default function TokenRoutes() { () => buildRouteMissingTokenIndex(routePatterns, missingTokenModelsByName, matchesModelPattern), [routePatterns, missingTokenModelsByName], ); + const routeMissingTokenGroupIndex = useMemo( + () => buildRouteMissingTokenIndex(routePatterns, missingTokenGroupModelsByName, matchesModelPattern), + [routePatterns, missingTokenGroupModelsByName], + ); const getRouteCandidateView = (routeId: number): RouteCandidateView => { return routeModelCandidateIndex[routeId] || EMPTY_ROUTE_CANDIDATE_VIEW; @@ -782,6 +797,56 @@ export default function TokenRoutes() { return result; }, [routeMissingTokenIndex]); + const missingTokenGroupItemsByRouteId = useMemo(() => { + const result: Record = {}; + for (const routeId of Object.keys(routeMissingTokenGroupIndex).map(Number)) { + const missingGroupHints = routeMissingTokenGroupIndex[routeId] || []; + const siteMap = new Map(); + for (const hint of missingGroupHints) { + for (const account of hint.accounts) { + if (!Number.isFinite(account.accountId) || account.accountId <= 0) continue; + const siteName = (account.siteName || '').trim() || `site-${account.siteId || 'unknown'}`; + const key = `${account.siteId || 0}::${siteName.toLowerCase()}`; + const accountLabel = account.username || `account-${account.accountId}`; + const missingGroups = Array.isArray(account.missingGroups) ? account.missingGroups : []; + const requiredGroups = Array.isArray(account.requiredGroups) ? account.requiredGroups : []; + const availableGroups = Array.isArray(account.availableGroups) ? account.availableGroups : []; + const existing = siteMap.get(key); + if (!existing) { + siteMap.set(key, { + key, + siteName, + accountId: account.accountId, + accountLabel, + missingGroups: [...missingGroups], + requiredGroups: [...requiredGroups], + availableGroups: [...availableGroups], + ...(account.groupCoverageUncertain === true ? { groupCoverageUncertain: true } : {}), + }); + continue; + } + if (account.accountId < existing.accountId) { + existing.accountId = account.accountId; + existing.accountLabel = accountLabel; + } + existing.missingGroups = Array.from(new Set([...existing.missingGroups, ...missingGroups])) + .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); + existing.requiredGroups = Array.from(new Set([...existing.requiredGroups, ...requiredGroups])) + .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); + existing.availableGroups = Array.from(new Set([...existing.availableGroups, ...availableGroups])) + .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); + if (account.groupCoverageUncertain === true) { + existing.groupCoverageUncertain = true; + } + } + } + result[routeId] = Array.from(siteMap.values()).sort((a, b) => ( + a.siteName.localeCompare(b.siteName, undefined, { sensitivity: 'base' }) + )); + } + return result; + }, [routeMissingTokenGroupIndex]); + // Stable callbacks for RouteCard memo (use refs to avoid dependency on closure variables) const toggleExpandRef = useRef(toggleExpand); toggleExpandRef.current = toggleExpand; @@ -1075,6 +1140,7 @@ export default function TokenRoutes() { onDeleteChannel={stableDeleteChannel} onChannelDragEnd={stableChannelDragEnd} missingTokenSiteItems={missingTokenSiteItemsByRouteId[route.id] || EMPTY_MISSING_ITEMS} + missingTokenGroupItems={missingTokenGroupItemsByRouteId[route.id] || EMPTY_MISSING_GROUP_ITEMS} onCreateTokenForMissing={stableCreateTokenForMissing} onAddChannel={stableAddChannel} expandedSourceGroupMap={expandedSourceGroupMap} diff --git a/src/web/pages/helpers/modelTesterSession.test.ts b/src/web/pages/helpers/modelTesterSession.test.ts index 3cb7c29..64fb9d2 100644 --- a/src/web/pages/helpers/modelTesterSession.test.ts +++ b/src/web/pages/helpers/modelTesterSession.test.ts @@ -9,6 +9,7 @@ import { buildApiPayload, buildEmbeddingsRequestEnvelope, buildFileUploadRequestEnvelope, + buildGeminiNativeConversationProxyEnvelope, buildRawProxyRequestEnvelope, buildSearchRequestEnvelope, collectModelTesterModelNames, @@ -321,6 +322,39 @@ describe('modelTesterSession', () => { }); }); + it('builds gemini native proxy envelope without loading assistant placeholders', () => { + const payload = buildGeminiNativeConversationProxyEnvelope( + [ + { id: 'u1', role: 'user', content: 'hello', createAt: 1 }, + { id: 'a1', role: 'assistant', content: '', createAt: 2, status: MESSAGE_STATUS.LOADING }, + { id: 'a2', role: 'assistant', content: 'partial', createAt: 3, status: MESSAGE_STATUS.INCOMPLETE }, + { id: 'a3', role: 'assistant', content: 'done', createAt: 4, status: MESSAGE_STATUS.COMPLETE }, + ], + { + ...DEFAULT_INPUTS, + model: 'gemini-2.5-pro', + protocol: 'gemini', + systemPrompt: 'system text', + stream: true, + }, + { + ...DEFAULT_PARAMETER_ENABLED, + max_tokens: true, + }, + ); + + expect(payload.path).toBe('/gemini/v1beta/models/gemini-2.5-pro:generateContent?alt=sse'); + expect(payload.jobMode).toBe(false); + expect(payload.jsonBody).toEqual({ + systemInstruction: { parts: [{ text: 'system text' }] }, + contents: [ + { role: 'user', parts: [{ text: 'hello' }] }, + { role: 'model', parts: [{ text: 'done' }] }, + ], + generationConfig: { temperature: 0.7, maxOutputTokens: 4096 }, + }); + }); + it('serializes conversation file parts into openai and responses payloads', () => { const message = { id: 'u1', diff --git a/src/web/pages/helpers/modelTesterSession.ts b/src/web/pages/helpers/modelTesterSession.ts index 63a0caf..ec86e73 100644 --- a/src/web/pages/helpers/modelTesterSession.ts +++ b/src/web/pages/helpers/modelTesterSession.ts @@ -949,6 +949,19 @@ export const buildConversationRequestEnvelope = ( jsonBody: buildConversationJsonBody(messages, inputs, parameterEnabled), }); +export const buildGeminiNativeConversationProxyEnvelope = ( + messages: ChatMessage[], + inputs: ModelTesterInputs, + parameterEnabled: ParameterEnabled, +): TesterProxyEnvelope => { + const envelope = buildConversationRequestEnvelope(messages, { ...inputs, protocol: 'gemini' }, parameterEnabled); + return { + ...envelope, + path: `/gemini${envelope.path}${inputs.stream ? '?alt=sse' : ''}`, + jobMode: false, + }; +}; + export const buildEmbeddingsRequestEnvelope = ( inputText: string, inputs: ModelTesterInputs, diff --git a/src/web/pages/helpers/routeListVisibility.ts b/src/web/pages/helpers/routeListVisibility.ts index a0ad8cd..a4d65ad 100644 --- a/src/web/pages/helpers/routeListVisibility.ts +++ b/src/web/pages/helpers/routeListVisibility.ts @@ -16,6 +16,12 @@ export function buildVisibleRouteList( isExactModelPattern: (pattern: string) => boolean, matchesModelPattern: (model: string, pattern: string) => boolean, ): T[] { + const exactModelNames = new Set( + routes + .filter((route) => isExactModelPattern(route.modelPattern)) + .map((route) => (route.modelPattern || '').trim()) + .filter(Boolean), + ); const coveringGroups = routes.filter((route) => ( route.enabled && !isExactModelPattern(route.modelPattern) @@ -33,6 +39,7 @@ export function buildVisibleRouteList( return !coveringGroups.some((groupRoute) => ( groupRoute.id !== route.id + && !exactModelNames.has((groupRoute.displayName || '').trim()) && matchesModelPattern(exactModel, groupRoute.modelPattern) )); }); diff --git a/src/web/pages/helpers/routeMissingTokenHints.ts b/src/web/pages/helpers/routeMissingTokenHints.ts index eb90d05..39a98b0 100644 --- a/src/web/pages/helpers/routeMissingTokenHints.ts +++ b/src/web/pages/helpers/routeMissingTokenHints.ts @@ -3,6 +3,10 @@ export type MissingTokenModelAccount = { username: string | null; siteId: number; siteName: string; + missingGroups?: string[]; + requiredGroups?: string[]; + availableGroups?: string[]; + groupCoverageUncertain?: boolean; }; export type MissingTokenModelsByName = Record; @@ -29,11 +33,21 @@ export function normalizeMissingTokenModels( if (!account || !Number.isFinite(account.accountId)) continue; const accountName = (account.username || '').trim(); const siteName = String(account.siteName || '').trim(); + const normalizeLabels = (labels: unknown): string[] => Array.isArray(labels) + ? Array.from(new Set(labels + .map((label) => String(label || '').trim()) + .filter(Boolean))) + .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })) + : []; accountMap.set(account.accountId, { accountId: account.accountId, username: accountName || null, siteId: account.siteId, siteName, + ...(normalizeLabels(account.missingGroups).length > 0 ? { missingGroups: normalizeLabels(account.missingGroups) } : {}), + ...(normalizeLabels(account.requiredGroups).length > 0 ? { requiredGroups: normalizeLabels(account.requiredGroups) } : {}), + ...(normalizeLabels(account.availableGroups).length > 0 ? { availableGroups: normalizeLabels(account.availableGroups) } : {}), + ...(account.groupCoverageUncertain === true ? { groupCoverageUncertain: true } : {}), }); } if (accountMap.size > 0) { diff --git a/src/web/pages/token-routes/RouteCard.tsx b/src/web/pages/token-routes/RouteCard.tsx index f448886..cc241de 100644 --- a/src/web/pages/token-routes/RouteCard.tsx +++ b/src/web/pages/token-routes/RouteCard.tsx @@ -23,6 +23,7 @@ import type { RouteDecision, RouteDecisionCandidate, MissingTokenRouteSiteActionItem, + MissingTokenGroupRouteSiteActionItem, RouteRoutingStrategy, } from './types.js'; import type { RouteCandidateView, RouteTokenOption } from '../helpers/routeModelCandidatesIndex.js'; @@ -61,6 +62,7 @@ type RouteCardProps = { onChannelDragEnd: (routeId: number, event: DragEndEvent) => void; // Missing token hints missingTokenSiteItems: MissingTokenRouteSiteActionItem[]; + missingTokenGroupItems: MissingTokenGroupRouteSiteActionItem[]; onCreateTokenForMissing: (accountId: number, modelName: string) => void; // Add channel onAddChannel: (routeId: number) => void; @@ -104,6 +106,7 @@ function RouteCardInner({ onDeleteChannel, onChannelDragEnd, missingTokenSiteItems, + missingTokenGroupItems, onCreateTokenForMissing, onAddChannel, expandedSourceGroupMap, @@ -298,22 +301,43 @@ function RouteCardInner({ {/* Missing token hints + Add channel button */} -
- {missingTokenSiteItems.length > 0 ? ( -
- {tr('待注册站点')}: - {missingTokenSiteItems.map((item) => ( - - ))} +
+ {(missingTokenSiteItems.length > 0 || missingTokenGroupItems.length > 0) ? ( +
+ {missingTokenSiteItems.length > 0 && ( +
+ {tr('待注册站点')}: + {missingTokenSiteItems.map((item) => ( + + ))} +
+ )} + {missingTokenGroupItems.length > 0 && ( +
+ {tr('缺少分组')}: + {missingTokenGroupItems.map((item) => ( + + ))} +
+ )}
) :
}