feat: Implement compatibility strategies for responses and chat endpoints

- Added `routeCompatibility.ts` to handle response compatibility for different endpoints.
- Introduced `chatEndpointStrategy.ts` to manage chat endpoint recovery and downgrading logic.
- Created tests for chat formats and input file handling in `chatFormatsCore.test.ts` and `inputFile.test.ts`.
- Developed `endpointCompatibility.ts` to define compatibility checks and header building for various endpoints.
- Implemented protocol lifecycle management in `protocolLifecycle.ts` and corresponding tests in `protocolModel.test.ts`.
- Enhanced tooltip functionality with a new `TooltipLayer` component and associated tests.
- Refactored existing components to improve tooltip handling and ensure proper rendering in the application.
This commit is contained in:
cita
2026-03-09 22:48:10 +08:00
parent 15d4b207cc
commit 0f8176cd04
74 changed files with 4645 additions and 1565 deletions
+13 -35
View File
@@ -5,7 +5,7 @@ export default withMermaid(
defineConfig({ defineConfig({
lang: 'zh-CN', lang: 'zh-CN',
title: 'Metapi 文档', title: 'Metapi 文档',
description: '面向普通用户的 Metapi 使用文档', description: 'Metapi 使用文档、FAQ 与维护协作指南',
cleanUrls: true, cleanUrls: true,
lastUpdated: true, lastUpdated: true,
ignoreDeadLinks: true, ignoreDeadLinks: true,
@@ -14,51 +14,29 @@ export default withMermaid(
logo: '/logos/logo-icon-512.png', logo: '/logos/logo-icon-512.png',
nav: [ nav: [
{ text: '首页', link: '/' }, { text: '首页', link: '/' },
{ text: '快速开始', link: '/getting-started' }, { text: '快速上手', link: '/getting-started' },
{ text: '上游接入', link: '/upstream-integration' }, { text: '上游接入', link: '/upstream-integration' },
{ text: '下游接入', link: '/client-integration' }, { text: 'FAQ', link: '/faq' },
{ text: 'FAQ / 排错', link: '/faq' }, { text: '文档维护', link: '/README' },
{ text: '项目主页', link: 'https://github.com/cita-777/metapi' }, { text: '项目主页', link: 'https://github.com/cita-777/metapi' },
], ],
sidebar: [ sidebar: [
{ {
text: '开始使用', text: '开始',
items: [ items: [
{ text: '文档首页', link: '/' }, { text: '文档首页', link: '/' },
{ text: '快速开始', link: '/getting-started' }, { text: '快速上手', link: '/getting-started' },
{ text: '部署指南', link: '/deployment' },
], ],
}, },
{ {
text: '上游接入', text: '使用与运维',
items: [ items: [
{ text: '上游接入总览', link: '/upstream-integration' }, { text: '上游接入', link: '/upstream-integration' },
{ text: 'New API', link: '/upstream/new-api' }, { text: '配置说明', link: '/configuration' },
{ text: 'Sub2API', link: '/upstream/sub2api' }, { text: '客户端接入', link: '/client-integration' },
{ text: 'AnyRouter', link: '/upstream/anyrouter' }, { text: '运维手册', link: '/operations' },
{ text: 'One API', link: '/upstream/one-api' }, { text: '常见问题 FAQ', link: '/faq' },
{ text: 'OneHub', link: '/upstream/onehub' },
{ text: 'DoneHub', link: '/upstream/donehub' },
{ text: 'Veloera', link: '/upstream/veloera' },
],
},
{
text: '下游接入',
items: [
{ text: '下游接入', link: '/client-integration' },
],
},
{
text: 'FAQ 与排错',
items: [
{ text: 'FAQ / 排错', link: '/faq' },
],
},
{
text: '高级与自托管',
items: [
{ text: '自托管部署与反向代理', link: '/deployment' },
{ text: '运行配置与部署级配置', link: '/configuration' },
{ text: '运维与维护', link: '/operations' },
], ],
}, },
{ {
+1
View File
@@ -28,6 +28,7 @@ npm run docs:build
|------|--------|------------| |------|--------|------------|
| 对外第一印象、产品定位、核心入口 | [文档首页](/) | 需要调整公开落地页信息架构、首页 CTA 或首屏导航时 | | 对外第一印象、产品定位、核心入口 | [文档首页](/) | 需要调整公开落地页信息架构、首页 CTA 或首屏导航时 |
| 新用户部署与首条请求 | [快速上手](./getting-started.md) | 新安装流程、默认端口、首次调用步骤变化时 | | 新用户部署与首条请求 | [快速上手](./getting-started.md) | 新安装流程、默认端口、首次调用步骤变化时 |
| 上游平台选择与接法 | [上游接入](./upstream-integration.md) | 平台支持范围、默认连接分段、自动识别规则变化时 |
| 生产部署与回滚 | [部署指南](./deployment.md) | Docker Compose、反向代理、升级回滚策略变更时 | | 生产部署与回滚 | [部署指南](./deployment.md) | Docker Compose、反向代理、升级回滚策略变更时 |
| 环境变量、参数和配置项 | [配置说明](./configuration.md) | 新增配置、默认值变化、兼容行为变化时 | | 环境变量、参数和配置项 | [配置说明](./configuration.md) | 新增配置、默认值变化、兼容行为变化时 |
| 客户端与工具接入 | [客户端接入](./client-integration.md) | Open WebUI、Cherry Studio、Cursor 等接入方式变化时 | | 客户端与工具接入 | [客户端接入](./client-integration.md) | Open WebUI、Cherry Studio、Cursor 等接入方式变化时 |
+4 -1
View File
@@ -118,9 +118,11 @@ npm run dev
进入 **站点管理**,添加你使用的上游中转站: 进入 **站点管理**,添加你使用的上游中转站:
- 填写站点名称(自己想怎么取就怎么取)和 URL - 填写站点名称(自己想怎么取就怎么取)和 URL
- 选择平台类型(New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API),通常可自动检测 - 选择平台类型(`new-api` / `one-api` / `one-hub` / `done-hub` / `veloera` / `anyrouter` / `sub2api` / `openai` / `claude` / `gemini` / `cliproxyapi`),通常可自动检测
- 填写站点的管理员 API Key(可选,部分功能需要) - 填写站点的管理员 API Key(可选,部分功能需要)
如果你不确定该选哪个平台,先看 [上游接入](./upstream-integration.md)。
![站点管理](./screenshots/site-management.png) ![站点管理](./screenshots/site-management.png)
### 步骤 2:添加账号 ### 步骤 2:添加账号
@@ -206,6 +208,7 @@ curl -sS http://127.0.0.1:4312/v1/models \
## 下一步 ## 下一步
- [上游接入](./upstream-integration.md) — 当前代码支持哪些上游、默认该走哪个连接分段
- [部署指南](./deployment.md) — 反向代理、HTTPS、升级策略 - [部署指南](./deployment.md) — 反向代理、HTTPS、升级策略
- [配置说明](./configuration.md) — 详细环境变量与路由参数 - [配置说明](./configuration.md) — 详细环境变量与路由参数
- [客户端接入](./client-integration.md) — 对接 Open WebUI、Cherry Studio 等 - [客户端接入](./client-integration.md) — 对接 Open WebUI、Cherry Studio 等
+7 -6
View File
@@ -12,23 +12,23 @@ hero:
- theme: brand - theme: brand
text: 10 分钟快速上手 text: 10 分钟快速上手
link: /getting-started link: /getting-started
- theme: alt
text: 上游接入
link: /upstream-integration
- theme: alt - theme: alt
text: 常见问题 FAQ text: 常见问题 FAQ
link: /faq link: /faq
- theme: alt
text: 文档维护与贡献
link: /README
features: features:
- title: 快速上手 - title: 快速上手
details: 从部署到第一条请求,按步骤完成最小可用环境搭建。 details: 从部署到第一条请求,按步骤完成最小可用环境搭建。
link: /getting-started link: /getting-started
- title: 上游接入
details: 按当前代码支持的平台类型,快速判断该选什么平台、先走哪个连接分段。
link: /upstream-integration
- title: 问题排查 - title: 问题排查
details: 汇总高频报错、根因定位和标准修复路径,降低重复沟通成本。 details: 汇总高频报错、根因定位和标准修复路径,降低重复沟通成本。
link: /faq link: /faq
- title: 运维与配置
details: 覆盖部署、配置、监控、备份、升级回滚等生产场景。
link: /operations
--- ---
## 项目架构 ## 项目架构
@@ -289,6 +289,7 @@ onBeforeUnmount(() => {
## 从这里开始 ## 从这里开始
- 初次部署或首次接入:从 [快速上手](/getting-started) 开始,先跑通最小可用链路。 - 初次部署或首次接入:从 [快速上手](/getting-started) 开始,先跑通最小可用链路。
- 不确定上游平台该怎么选:先看 [上游接入](/upstream-integration),再决定走 `账号管理` 还是 `API Key管理`。
- 准备上线或升级回滚:查看 [部署指南](/deployment) 与 [运维手册](/operations)。 - 准备上线或升级回滚:查看 [部署指南](/deployment) 与 [运维手册](/operations)。
- 需要补齐环境变量或路由参数:直接查 [配置说明](/configuration)。 - 需要补齐环境变量或路由参数:直接查 [配置说明](/configuration)。
- 正在处理客户端或第三方工具接入:优先看 [客户端接入](/client-integration)。 - 正在处理客户端或第三方工具接入:优先看 [客户端接入](/client-integration)。
View File
+6
View File
@@ -9,6 +9,7 @@ import { ensureSiteSchemaCompatibility, type SiteSchemaInspector } from './siteS
import { ensureRouteGroupingSchemaCompatibility } from './routeGroupingSchemaCompatibility.js'; import { ensureRouteGroupingSchemaCompatibility } from './routeGroupingSchemaCompatibility.js';
import { ensureProxyFileSchemaCompatibility } from './proxyFileSchemaCompatibility.js'; import { ensureProxyFileSchemaCompatibility } from './proxyFileSchemaCompatibility.js';
import { config } from '../config.js'; import { config } from '../config.js';
import { ensureRuntimeDatabaseReady } from '../runtimeDatabaseBootstrap.js';
import { mkdirSync } from 'fs'; import { mkdirSync } from 'fs';
import { dirname, resolve } from 'path'; import { dirname, resolve } from 'path';
@@ -938,6 +939,11 @@ export async function switchRuntimeDatabase(nextDialect: RuntimeDbDialect, nextD
try { try {
activeDb = initDb(); activeDb = initDb();
await ensureRuntimeDatabaseReady({
dialect: nextDialect,
connectionString: nextDbUrl,
ssl: config.dbSsl,
});
} catch (error) { } catch (error) {
await closeDbConnections(); await closeDbConnections();
runtimeDbDialect = previousDialect; runtimeDbDialect = previousDialect;
+253
View File
@@ -0,0 +1,253 @@
import Database from 'better-sqlite3';
import mysql from 'mysql2/promise';
import pg from 'pg';
import { mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { ensureSiteSchemaCompatibility, type SiteSchemaInspector } from './siteSchemaCompatibility.js';
import { ensureRouteGroupingSchemaCompatibility } from './routeGroupingSchemaCompatibility.js';
import { ensureProxyFileSchemaCompatibility } from './proxyFileSchemaCompatibility.js';
export type RuntimeSchemaDialect = 'sqlite' | 'mysql' | 'postgres';
export interface RuntimeSchemaClient {
dialect: RuntimeSchemaDialect;
begin(): Promise<void>;
commit(): Promise<void>;
rollback(): Promise<void>;
execute(sqlText: string, params?: unknown[]): Promise<unknown>;
queryScalar(sqlText: string, params?: unknown[]): Promise<number>;
close(): Promise<void>;
}
export interface RuntimeSchemaConnectionInput {
dialect: RuntimeSchemaDialect;
connectionString: string;
ssl?: boolean;
}
function validateIdentifier(identifier: string): string {
if (!/^[a-z_][a-z0-9_]*$/i.test(identifier)) {
throw new Error(`Invalid SQL identifier: ${identifier}`);
}
return identifier;
}
function createSiteSchemaInspector(client: RuntimeSchemaClient): SiteSchemaInspector {
if (client.dialect === 'sqlite') {
return {
dialect: 'sqlite',
tableExists: async (table) => {
const normalizedTable = validateIdentifier(table);
return (await client.queryScalar(
`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = '${normalizedTable}'`,
)) > 0;
},
columnExists: async (table, column) => {
const normalizedTable = validateIdentifier(table);
const normalizedColumn = validateIdentifier(column);
return (await client.queryScalar(
`SELECT COUNT(*) FROM pragma_table_info('${normalizedTable}') WHERE name = '${normalizedColumn}'`,
)) > 0;
},
execute: async (sqlText) => {
await client.execute(sqlText);
},
};
}
if (client.dialect === 'mysql') {
return {
dialect: 'mysql',
tableExists: async (table) => {
return (await client.queryScalar(
'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ?',
[table],
)) > 0;
},
columnExists: async (table, column) => {
return (await client.queryScalar(
'SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?',
[table, column],
)) > 0;
},
execute: async (sqlText) => {
await client.execute(sqlText);
},
};
}
return {
dialect: 'postgres',
tableExists: async (table) => {
return (await client.queryScalar(
'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = $1',
[table],
)) > 0;
},
columnExists: async (table, column) => {
return (await client.queryScalar(
'SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 AND column_name = $2',
[table, column],
)) > 0;
},
execute: async (sqlText) => {
await client.execute(sqlText);
},
};
}
async function createPostgresClient(connectionString: string, ssl: boolean): Promise<RuntimeSchemaClient> {
const clientOptions: pg.ClientConfig = { connectionString };
if (ssl) {
clientOptions.ssl = { rejectUnauthorized: false };
}
const client = new pg.Client(clientOptions);
await client.connect();
return {
dialect: 'postgres',
begin: async () => { await client.query('BEGIN'); },
commit: async () => { await client.query('COMMIT'); },
rollback: async () => { await client.query('ROLLBACK'); },
execute: async (sqlText, params = []) => client.query(sqlText, params),
queryScalar: async (sqlText, params = []) => {
const result = await client.query(sqlText, params);
const row = result.rows[0] as Record<string, unknown> | undefined;
if (!row) return 0;
return Number(Object.values(row)[0]) || 0;
},
close: async () => { await client.end(); },
};
}
async function createMySqlClient(connectionString: string, ssl: boolean): Promise<RuntimeSchemaClient> {
const connectionOptions: mysql.ConnectionOptions = { uri: connectionString };
if (ssl) {
connectionOptions.ssl = { rejectUnauthorized: false };
}
const connection = await mysql.createConnection(connectionOptions);
return {
dialect: 'mysql',
begin: async () => { await connection.beginTransaction(); },
commit: async () => { await connection.commit(); },
rollback: async () => { await connection.rollback(); },
execute: async (sqlText, params = []) => connection.execute(sqlText, params as any[]),
queryScalar: async (sqlText, params = []) => {
const [rows] = await connection.query(sqlText, params as any[]);
if (!Array.isArray(rows) || rows.length === 0) return 0;
const row = rows[0] as Record<string, unknown>;
return Number(Object.values(row)[0]) || 0;
},
close: async () => { await connection.end(); },
};
}
async function createSqliteClient(connectionString: string): Promise<RuntimeSchemaClient> {
const filePath = connectionString === ':memory:' ? ':memory:' : resolve(connectionString);
if (filePath !== ':memory:') {
mkdirSync(dirname(filePath), { recursive: true });
}
const sqlite = new Database(filePath);
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('foreign_keys = ON');
return {
dialect: 'sqlite',
begin: async () => { sqlite.exec('BEGIN'); },
commit: async () => { sqlite.exec('COMMIT'); },
rollback: async () => { sqlite.exec('ROLLBACK'); },
execute: async (sqlText, params = []) => {
const lowered = sqlText.trim().toLowerCase();
const statement = sqlite.prepare(sqlText);
if (lowered.startsWith('select')) return statement.all(...params);
return statement.run(...params);
},
queryScalar: async (sqlText, params = []) => {
const row = sqlite.prepare(sqlText).get(...params) as Record<string, unknown> | undefined;
if (!row) return 0;
return Number(Object.values(row)[0]) || 0;
},
close: async () => { sqlite.close(); },
};
}
export async function createRuntimeSchemaClient(input: RuntimeSchemaConnectionInput): Promise<RuntimeSchemaClient> {
if (input.dialect === 'postgres') {
return createPostgresClient(input.connectionString, !!input.ssl);
}
if (input.dialect === 'mysql') {
return createMySqlClient(input.connectionString, !!input.ssl);
}
return createSqliteClient(input.connectionString);
}
export async function ensureRuntimeDatabaseSchema(client: RuntimeSchemaClient): Promise<void> {
const statements = client.dialect === 'postgres'
? [
`CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "external_checkin_url" TEXT, "platform" TEXT NOT NULL, "proxy_url" TEXT, "use_system_proxy" BOOLEAN DEFAULT FALSE, "status" TEXT DEFAULT 'active', "is_pinned" BOOLEAN DEFAULT FALSE, "sort_order" INTEGER DEFAULT 0, "global_weight" DOUBLE PRECISION DEFAULT 1, "api_key" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "site_id" INTEGER NOT NULL REFERENCES "sites"("id") ON DELETE CASCADE, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" DOUBLE PRECISION DEFAULT 0, "balance_used" DOUBLE PRECISION DEFAULT 0, "quota" DOUBLE PRECISION DEFAULT 0, "unit_cost" DOUBLE PRECISION, "value_score" DOUBLE PRECISION DEFAULT 0, "status" TEXT DEFAULT 'active', "is_pinned" BOOLEAN DEFAULT FALSE, "sort_order" INTEGER DEFAULT 0, "checkin_enabled" BOOLEAN DEFAULT TRUE, "last_checkin_at" TEXT, "last_balance_refresh" TEXT, "extra_config" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "token_group" TEXT, "source" TEXT DEFAULT 'manual', "enabled" BOOLEAN DEFAULT TRUE, "is_default" BOOLEAN DEFAULT FALSE, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "token_id" INTEGER NOT NULL REFERENCES "account_tokens"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "decision_snapshot" TEXT, "decision_refreshed_at" TEXT, "enabled" BOOLEAN DEFAULT TRUE, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" BOOLEAN DEFAULT TRUE, "manual_override" BOOLEAN DEFAULT FALSE, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" DOUBLE PRECISION DEFAULT 0, "last_used_at" TEXT, "last_fail_at" TEXT, "cooldown_until" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" DOUBLE PRECISION, "billing_details" TEXT, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "public_id" TEXT NOT NULL UNIQUE, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" TEXT, "upstream_response_meta" TEXT, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "public_id" TEXT NOT NULL UNIQUE, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT, "updated_at" TEXT, "deleted_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "name" TEXT NOT NULL, "key" TEXT NOT NULL UNIQUE, "description" TEXT, "enabled" BOOLEAN DEFAULT TRUE, "expires_at" TEXT, "max_cost" DOUBLE PRECISION, "used_cost" DOUBLE PRECISION DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" TEXT, "allowed_route_ids" TEXT, "site_weight_multipliers" TEXT, "last_used_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT DEFAULT 'info', "read" BOOLEAN DEFAULT FALSE, "related_id" INTEGER, "related_type" TEXT, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "settings" ("key" TEXT PRIMARY KEY, "value" TEXT)`,
]
: client.dialect === 'mysql'
? [
`CREATE TABLE IF NOT EXISTS \`sites\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`name\` TEXT NOT NULL, \`url\` TEXT NOT NULL, \`external_checkin_url\` TEXT NULL, \`platform\` VARCHAR(64) NOT NULL, \`proxy_url\` TEXT NULL, \`use_system_proxy\` BOOLEAN DEFAULT FALSE, \`status\` VARCHAR(32) DEFAULT 'active', \`is_pinned\` BOOLEAN DEFAULT FALSE, \`sort_order\` INT DEFAULT 0, \`global_weight\` DOUBLE DEFAULT 1, \`api_key\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`accounts\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`site_id\` INT NOT NULL, \`username\` TEXT NULL, \`access_token\` TEXT NOT NULL, \`api_token\` TEXT NULL, \`balance\` DOUBLE DEFAULT 0, \`balance_used\` DOUBLE DEFAULT 0, \`quota\` DOUBLE DEFAULT 0, \`unit_cost\` DOUBLE NULL, \`value_score\` DOUBLE DEFAULT 0, \`status\` VARCHAR(32) DEFAULT 'active', \`is_pinned\` BOOLEAN DEFAULT FALSE, \`sort_order\` INT DEFAULT 0, \`checkin_enabled\` BOOLEAN DEFAULT TRUE, \`last_checkin_at\` TEXT NULL, \`last_balance_refresh\` TEXT NULL, \`extra_config\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, CONSTRAINT \`accounts_site_fk\` FOREIGN KEY (\`site_id\`) REFERENCES \`sites\`(\`id\`) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS \`account_tokens\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`name\` TEXT NOT NULL, \`token\` TEXT NOT NULL, \`token_group\` TEXT NULL, \`source\` VARCHAR(32) DEFAULT 'manual', \`enabled\` BOOLEAN DEFAULT TRUE, \`is_default\` BOOLEAN DEFAULT FALSE, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, CONSTRAINT \`account_tokens_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS \`checkin_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`status\` VARCHAR(32) NOT NULL, \`message\` TEXT NULL, \`reward\` TEXT NULL, \`created_at\` TEXT NULL, CONSTRAINT \`checkin_logs_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS \`model_availability\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`model_name\` TEXT NOT NULL, \`available\` BOOLEAN NULL, \`latency_ms\` INT NULL, \`checked_at\` TEXT NULL, CONSTRAINT \`model_availability_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS \`token_model_availability\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`token_id\` INT NOT NULL, \`model_name\` TEXT NOT NULL, \`available\` BOOLEAN NULL, \`latency_ms\` INT NULL, \`checked_at\` TEXT NULL, CONSTRAINT \`token_model_availability_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS \`token_routes\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`model_pattern\` TEXT NOT NULL, \`display_name\` TEXT NULL, \`display_icon\` TEXT NULL, \`model_mapping\` TEXT NULL, \`decision_snapshot\` TEXT NULL, \`decision_refreshed_at\` TEXT NULL, \`enabled\` BOOLEAN DEFAULT TRUE, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`route_channels\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NOT NULL, \`account_id\` INT NOT NULL, \`token_id\` INT NULL, \`source_model\` TEXT NULL, \`priority\` INT DEFAULT 0, \`weight\` INT DEFAULT 10, \`enabled\` BOOLEAN DEFAULT TRUE, \`manual_override\` BOOLEAN DEFAULT FALSE, \`success_count\` INT DEFAULT 0, \`fail_count\` INT DEFAULT 0, \`total_latency_ms\` INT DEFAULT 0, \`total_cost\` DOUBLE DEFAULT 0, \`last_used_at\` TEXT NULL, \`last_fail_at\` TEXT NULL, \`cooldown_until\` TEXT NULL, CONSTRAINT \`route_channels_route_fk\` FOREIGN KEY (\`route_id\`) REFERENCES \`token_routes\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE SET NULL)`,
`CREATE TABLE IF NOT EXISTS \`proxy_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NULL, \`channel_id\` INT NULL, \`account_id\` INT NULL, \`model_requested\` TEXT NULL, \`model_actual\` TEXT NULL, \`status\` VARCHAR(32) NULL, \`http_status\` INT NULL, \`latency_ms\` INT NULL, \`prompt_tokens\` INT NULL, \`completion_tokens\` INT NULL, \`total_tokens\` INT NULL, \`estimated_cost\` DOUBLE NULL, \`billing_details\` TEXT NULL, \`error_message\` TEXT NULL, \`retry_count\` INT DEFAULT 0, \`created_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`proxy_video_tasks\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`public_id\` VARCHAR(191) NOT NULL UNIQUE, \`upstream_video_id\` TEXT NOT NULL, \`site_url\` TEXT NOT NULL, \`token_value\` TEXT NOT NULL, \`requested_model\` TEXT NULL, \`actual_model\` TEXT NULL, \`channel_id\` INT NULL, \`account_id\` INT NULL, \`status_snapshot\` TEXT NULL, \`upstream_response_meta\` TEXT NULL, \`last_upstream_status\` INT NULL, \`last_polled_at\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`proxy_files\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`public_id\` VARCHAR(191) NOT NULL UNIQUE, \`owner_type\` VARCHAR(64) NOT NULL, \`owner_id\` VARCHAR(191) NOT NULL, \`filename\` TEXT NOT NULL, \`mime_type\` VARCHAR(191) NOT NULL, \`purpose\` TEXT NULL, \`byte_size\` INT NOT NULL, \`sha256\` VARCHAR(191) NOT NULL, \`content_base64\` LONGTEXT NOT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, \`deleted_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`downstream_api_keys\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`name\` TEXT NOT NULL, \`key\` VARCHAR(191) NOT NULL UNIQUE, \`description\` TEXT NULL, \`enabled\` BOOLEAN DEFAULT TRUE, \`expires_at\` TEXT NULL, \`max_cost\` DOUBLE NULL, \`used_cost\` DOUBLE DEFAULT 0, \`max_requests\` INT NULL, \`used_requests\` INT DEFAULT 0, \`supported_models\` TEXT NULL, \`allowed_route_ids\` TEXT NULL, \`site_weight_multipliers\` TEXT NULL, \`last_used_at\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`events\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`type\` VARCHAR(32) NOT NULL, \`title\` TEXT NOT NULL, \`message\` TEXT NULL, \`level\` VARCHAR(16) DEFAULT 'info', \`read\` BOOLEAN DEFAULT FALSE, \`related_id\` INT NULL, \`related_type\` VARCHAR(32) NULL, \`created_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`settings\` (\`key\` VARCHAR(191) PRIMARY KEY, \`value\` TEXT NULL)`,
]
: [
`CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "external_checkin_url" TEXT, "platform" TEXT NOT NULL, "proxy_url" TEXT, "use_system_proxy" INTEGER DEFAULT 0, "status" TEXT DEFAULT 'active', "is_pinned" INTEGER DEFAULT 0, "sort_order" INTEGER DEFAULT 0, "global_weight" REAL DEFAULT 1, "api_key" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "site_id" INTEGER NOT NULL REFERENCES "sites"("id") ON DELETE CASCADE, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" REAL DEFAULT 0, "balance_used" REAL DEFAULT 0, "quota" REAL DEFAULT 0, "unit_cost" REAL, "value_score" REAL DEFAULT 0, "status" TEXT DEFAULT 'active', "is_pinned" INTEGER DEFAULT 0, "sort_order" INTEGER DEFAULT 0, "checkin_enabled" INTEGER DEFAULT 1, "last_checkin_at" TEXT, "last_balance_refresh" TEXT, "extra_config" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "token_group" TEXT, "source" TEXT DEFAULT 'manual', "enabled" INTEGER DEFAULT 1, "is_default" INTEGER DEFAULT 0, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" INTEGER, "latency_ms" INTEGER, "checked_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "token_id" INTEGER NOT NULL REFERENCES "account_tokens"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" INTEGER, "latency_ms" INTEGER, "checked_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "decision_snapshot" TEXT, "decision_refreshed_at" TEXT, "enabled" INTEGER DEFAULT 1, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" INTEGER DEFAULT 1, "manual_override" INTEGER DEFAULT 0, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" REAL DEFAULT 0, "last_used_at" TEXT, "last_fail_at" TEXT, "cooldown_until" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" REAL, "billing_details" TEXT, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL UNIQUE, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" TEXT, "upstream_response_meta" TEXT, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL UNIQUE, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT, "updated_at" TEXT, "deleted_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" TEXT NOT NULL, "key" TEXT NOT NULL UNIQUE, "description" TEXT, "enabled" INTEGER DEFAULT 1, "expires_at" TEXT, "max_cost" REAL, "used_cost" REAL DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" TEXT, "allowed_route_ids" TEXT, "site_weight_multipliers" TEXT, "last_used_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT DEFAULT 'info', "read" INTEGER DEFAULT 0, "related_id" INTEGER, "related_type" TEXT, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "settings" ("key" TEXT PRIMARY KEY, "value" TEXT)`,
];
for (const sqlText of statements) {
await client.execute(sqlText);
}
await ensureSiteSchemaCompatibility(createSiteSchemaInspector(client));
await ensureRouteGroupingSchemaCompatibility(createSiteSchemaInspector(client));
await ensureProxyFileSchemaCompatibility(createSiteSchemaInspector(client));
}
export async function bootstrapRuntimeDatabaseSchema(input: RuntimeSchemaConnectionInput): Promise<void> {
const client = await createRuntimeSchemaClient(input);
try {
await ensureRuntimeDatabaseSchema(client);
} finally {
await client.close();
}
}
+23 -15
View File
@@ -24,6 +24,7 @@ import { buildStartupSummaryLines } from './services/startupInfo.js';
import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js'; import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js';
import { migrateSiteApiKeysToAccounts } from './services/siteApiKeyMigrationService.js'; import { migrateSiteApiKeysToAccounts } from './services/siteApiKeyMigrationService.js';
import { ensureDefaultSitesSeeded } from './services/defaultSiteSeedService.js'; import { ensureDefaultSitesSeeded } from './services/defaultSiteSeedService.js';
import { ensureRuntimeDatabaseReady } from './runtimeDatabaseBootstrap.js';
import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js'; import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
@@ -40,18 +41,6 @@ import {
type RuntimeDbDialect, type RuntimeDbDialect,
} from './db/index.js'; } from './db/index.js';
let sqliteMigrationsBootstrapped = false;
async function ensureSqliteRuntimeMigrations() {
if (runtimeDbDialect !== 'sqlite') return;
const migrateModule = await import('./db/migrate.js');
if (sqliteMigrationsBootstrapped) {
migrateModule.runSqliteMigrations();
return;
}
sqliteMigrationsBootstrapped = true;
}
function toSettingsMap(rows: Array<{ key: string; value: string }>) { function toSettingsMap(rows: Array<{ key: string; value: string }>) {
return new Map(rows.map((row) => [row.key, row.value])); return new Map(rows.map((row) => [row.key, row.value]));
} }
@@ -199,8 +188,12 @@ function applyRuntimeSettings(settingsMap: Map<string, string>) {
} }
} }
// Ensure sqlite tables exist before reading runtime settings. // Ensure the current runtime database is bootstrapped before reading settings.
await ensureSqliteRuntimeMigrations(); await ensureRuntimeDatabaseReady({
dialect: runtimeDbDialect,
connectionString: config.dbUrl,
ssl: config.dbSsl,
});
// Load runtime config overrides from settings // Load runtime config overrides from settings
try { try {
@@ -208,12 +201,27 @@ try {
const initialMap = toSettingsMap(initialRows); const initialMap = toSettingsMap(initialRows);
const savedDbConfig = extractSavedRuntimeDatabaseConfig(initialMap); const savedDbConfig = extractSavedRuntimeDatabaseConfig(initialMap);
const activeDbUrl = (config.dbUrl || '').trim(); const activeDbUrl = (config.dbUrl || '').trim();
const originalRuntimeConfig = {
dialect: runtimeDbDialect,
dbUrl: activeDbUrl,
ssl: config.dbSsl,
};
if (savedDbConfig && (savedDbConfig.dialect !== runtimeDbDialect || savedDbConfig.dbUrl !== activeDbUrl || savedDbConfig.ssl !== config.dbSsl)) { if (savedDbConfig && (savedDbConfig.dialect !== runtimeDbDialect || savedDbConfig.dbUrl !== activeDbUrl || savedDbConfig.ssl !== config.dbSsl)) {
try { try {
await switchRuntimeDatabase(savedDbConfig.dialect, savedDbConfig.dbUrl, savedDbConfig.ssl); await switchRuntimeDatabase(savedDbConfig.dialect, savedDbConfig.dbUrl, savedDbConfig.ssl);
await ensureSqliteRuntimeMigrations();
console.log(`Loaded runtime DB config from settings: ${savedDbConfig.dialect}`); console.log(`Loaded runtime DB config from settings: ${savedDbConfig.dialect}`);
} catch (error) { } catch (error) {
const currentDbUrl = (config.dbUrl || '').trim();
const switchedAway = runtimeDbDialect !== originalRuntimeConfig.dialect
|| currentDbUrl !== originalRuntimeConfig.dbUrl
|| config.dbSsl !== originalRuntimeConfig.ssl;
if (switchedAway) {
await switchRuntimeDatabase(
originalRuntimeConfig.dialect,
originalRuntimeConfig.dbUrl,
originalRuntimeConfig.ssl,
);
}
console.warn(`Failed to switch runtime DB from settings: ${(error as Error)?.message || 'unknown error'}`); console.warn(`Failed to switch runtime DB from settings: ${(error as Error)?.message || 'unknown error'}`);
} }
} }
@@ -119,6 +119,44 @@ describe('/api/models/token-candidates', () => {
expect(body.modelsWithoutToken['claude-haiku-4-5-20251001']).toBeUndefined(); expect(body.modelsWithoutToken['claude-haiku-4-5-20251001']).toBeUndefined();
}); });
it('does not report apikey connections as missing account tokens', async () => {
const site = await db.insert(schema.sites).values({
name: 'site-apikey',
url: 'https://site-apikey.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'shenmo-direct',
accessToken: '',
apiToken: 'sk-shenmo-direct',
status: 'active',
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
}).returning().get();
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'gpt-5.2-codex',
available: true,
}).run();
const response = await app.inject({
method: 'GET',
url: '/api/models/token-candidates',
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
modelsWithoutToken: Record<string, Array<{ accountId: number }>>;
modelsMissingTokenGroups: Record<string, Array<{ accountId: number }>>;
};
expect(body.modelsWithoutToken['gpt-5.2-codex']).toBeUndefined();
expect(body.modelsMissingTokenGroups['gpt-5.2-codex']).toBeUndefined();
});
it('returns modelsMissingTokenGroups when account has partial group token coverage', async () => { it('returns modelsMissingTokenGroups when account has partial group token coverage', async () => {
const site = await db.insert(schema.sites).values({ const site = await db.insert(schema.sites).values({
name: 'site-b', name: 'site-b',
@@ -364,7 +402,7 @@ describe('/api/models/token-candidates', () => {
]); ]);
}); });
it('does not pass deprecated site apiKey into pricing catalog lookup', async () => { it('does not request token-group hints for apikey connections', async () => {
const site = await db.insert(schema.sites).values({ const site = await db.insert(schema.sites).values({
name: 'site-e', name: 'site-e',
url: 'https://site-e.example.com', url: 'https://site-e.example.com',
@@ -425,19 +463,11 @@ describe('/api/models/token-candidates', () => {
}); });
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
expect(fetchModelPricingCatalogMock).toHaveBeenCalled(); const body = response.json() as {
expect(fetchModelPricingCatalogMock.mock.calls[0]?.[0]).toMatchObject({ modelsMissingTokenGroups: Record<string, Array<{ accountId: number }>>;
site: { };
id: site.id,
url: site.url, expect(fetchModelPricingCatalogMock).not.toHaveBeenCalled();
platform: site.platform, expect(body.modelsMissingTokenGroups['claude-opus-4-6']).toBeUndefined();
},
account: {
id: account.id,
accessToken: '',
apiToken: null,
},
});
expect(fetchModelPricingCatalogMock.mock.calls[0]?.[0]?.site?.apiKey).toBeUndefined();
}); });
}); });
+16 -1
View File
@@ -17,6 +17,7 @@ import {
parseProxyLogBillingDetails, parseProxyLogBillingDetails,
withProxyLogSelectFields, withProxyLogSelectFields,
} from '../../services/proxyLogStore.js'; } from '../../services/proxyLogStore.js';
import { getCredentialModeFromExtraConfig } from '../../services/accountExtraConfig.js';
import { import {
formatUtcSqlDateTime, formatUtcSqlDateTime,
getLocalDayRangeUtc, getLocalDayRangeUtc,
@@ -30,6 +31,12 @@ function parseBooleanFlag(raw?: string): boolean {
return normalized === '1' || normalized === 'true' || normalized === 'yes'; return normalized === '1' || normalized === 'true' || normalized === 'yes';
} }
function isApiKeyConnection(account: { accessToken?: string | null; extraConfig?: string | null }): boolean {
const explicit = getCredentialModeFromExtraConfig(account.extraConfig);
if (explicit && explicit !== 'auto') return explicit === 'apikey';
return !(account.accessToken || '').trim();
}
const MODELS_MARKETPLACE_BASE_TTL_MS = 15_000; const MODELS_MARKETPLACE_BASE_TTL_MS = 15_000;
const MODELS_MARKETPLACE_PRICING_TTL_MS = 90_000; const MODELS_MARKETPLACE_PRICING_TTL_MS = 90_000;
@@ -711,6 +718,8 @@ export async function statsRoutes(app: FastifyInstance) {
username: schema.accounts.username, username: schema.accounts.username,
siteId: schema.sites.id, siteId: schema.sites.id,
siteName: schema.sites.name, siteName: schema.sites.name,
accessToken: schema.accounts.accessToken,
extraConfig: schema.accounts.extraConfig,
}) })
.from(schema.modelAvailability) .from(schema.modelAvailability)
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id)) .innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
@@ -788,6 +797,7 @@ export async function statsRoutes(app: FastifyInstance) {
} }
for (const row of availableModelRows) { for (const row of availableModelRows) {
if (isApiKeyConnection(row)) continue;
const modelName = (row.modelName || '').trim(); const modelName = (row.modelName || '').trim();
if (!modelName) continue; if (!modelName) continue;
const coverageKey = `${row.accountId}::${modelName.toLowerCase()}`; const coverageKey = `${row.accountId}::${modelName.toLowerCase()}`;
@@ -802,7 +812,11 @@ export async function statsRoutes(app: FastifyInstance) {
}); });
} }
const accountIdsForGroupHints = new Set(availableModelRows.map((row) => row.accountId)); const accountIdsForGroupHints = new Set(
availableModelRows
.filter((row) => !isApiKeyConnection(row))
.map((row) => row.accountId),
);
const requiredGroupsByAccountModel = new Map<string, Map<string, string>>(); const requiredGroupsByAccountModel = new Map<string, Map<string, string>>();
const hasPotentialGroupHints = hasAnyTokenGroupSignals || unknownGroupCoverageByAccountModel.size > 0; const hasPotentialGroupHints = hasAnyTokenGroupSignals || unknownGroupCoverageByAccountModel.size > 0;
@@ -862,6 +876,7 @@ export async function statsRoutes(app: FastifyInstance) {
} }
for (const row of availableModelRows) { for (const row of availableModelRows) {
if (isApiKeyConnection(row)) continue;
const modelName = (row.modelName || '').trim(); const modelName = (row.modelName || '').trim();
if (!modelName) continue; if (!modelName) continue;
const accountModelKey = `${row.accountId}::${modelName.toLowerCase()}`; const accountModelKey = `${row.accountId}::${modelName.toLowerCase()}`;
@@ -25,9 +25,23 @@ describe('proxy route architecture boundaries', () => {
expect(source).not.toContain('const promoteResponsesCandidate ='); expect(source).not.toContain('const promoteResponsesCandidate =');
expect(source).not.toContain('shouldRetryClaudeMessagesWithNormalizedBody('); expect(source).not.toContain('shouldRetryClaudeMessagesWithNormalizedBody(');
expect(source).not.toContain('buildOpenAiSyntheticFinalStream('); expect(source).not.toContain('buildOpenAiSyntheticFinalStream(');
expect(source).toContain('anthropicMessagesTransformer.consumeSseEventBlock('); expect(source).not.toContain('anthropicMessagesTransformer.consumeSseEventBlock(');
expect(source).toContain('anthropicMessagesTransformer.serializeUpstreamFinalAsStream('); expect(source).not.toContain('anthropicMessagesTransformer.serializeUpstreamFinalAsStream(');
expect(source).toContain('openAiChatTransformer.serializeUpstreamFinalAsStream('); expect(source).not.toContain('openAiChatTransformer.serializeUpstreamFinalAsStream(');
expect(source).toContain('openAiChatTransformer.proxyStream.createSession(');
expect(source).toContain('streamSession.consumeUpstreamFinalPayload(');
expect(source).toContain('streamSession.run(');
});
it('keeps chat endpoint retry and downgrade strategy out of the route', () => {
const source = readSource('./chat.ts');
expect(source).toContain('downstreamTransformer.compatibility.createEndpointStrategy(');
expect(source).not.toContain('anthropicMessagesTransformer.compatibility.shouldRetryNormalizedBody(');
expect(source).not.toContain('buildMinimalJsonHeadersForCompatibility(');
expect(source).not.toContain('promoteResponsesCandidateAfterLegacyChatError(');
expect(source).not.toContain('isEndpointDowngradeError(');
expect(source).not.toContain('isEndpointDispatchDeniedError(');
expect(source).not.toContain('isUnsupportedMediaTypeError(');
}); });
it('keeps responses protocol assembly out of responses route', () => { it('keeps responses protocol assembly out of responses route', () => {
@@ -44,16 +58,27 @@ describe('proxy route architecture boundaries', () => {
expect(source).not.toContain('function shouldDowngradeFromChatToMessagesForResponses('); expect(source).not.toContain('function shouldDowngradeFromChatToMessagesForResponses(');
expect(source).not.toContain('function normalizeText('); expect(source).not.toContain('function normalizeText(');
expect(source).toContain('openAiResponsesTransformer.inbound.toOpenAiBody('); expect(source).toContain('openAiResponsesTransformer.inbound.toOpenAiBody(');
expect(source).toContain('openAiResponsesTransformer.compatibility.buildRetryBodies('); expect(source).toContain('openAiResponsesTransformer.compatibility.createEndpointStrategy(');
expect(source).toContain('openAiResponsesTransformer.compatibility.buildRetryHeaders('); expect(source).not.toContain('openAiResponsesTransformer.aggregator.createState(');
expect(source).toContain('openAiResponsesTransformer.compatibility.shouldRetry('); expect(source).not.toContain('openAiResponsesTransformer.aggregator.serialize(');
expect(source).toContain('openAiResponsesTransformer.compatibility.shouldDowngradeChatToMessages('); expect(source).not.toContain('openAiResponsesTransformer.aggregator.complete(');
expect(source).toContain('openAiResponsesTransformer.aggregator.createState('); expect(source).toContain('openAiResponsesTransformer.proxyStream.createSession(');
expect(source).toContain('openAiResponsesTransformer.aggregator.serialize('); expect(source).toContain('streamSession.run(');
expect(source).toContain('openAiResponsesTransformer.aggregator.complete(');
expect(source).toContain('openAiResponsesTransformer.outbound.serializeFinal('); expect(source).toContain('openAiResponsesTransformer.outbound.serializeFinal(');
}); });
it('keeps responses endpoint retry and downgrade strategy out of the route', () => {
const source = readSource('./responses.ts');
expect(source).toContain('openAiResponsesTransformer.compatibility.createEndpointStrategy(');
expect(source).not.toContain('openAiResponsesTransformer.compatibility.shouldRetry(');
expect(source).not.toContain('openAiResponsesTransformer.compatibility.buildRetryBodies(');
expect(source).not.toContain('openAiResponsesTransformer.compatibility.buildRetryHeaders(');
expect(source).not.toContain('openAiResponsesTransformer.compatibility.shouldDowngradeChatToMessages(');
expect(source).not.toContain('buildMinimalJsonHeadersForCompatibility(');
expect(source).not.toContain('isEndpointDowngradeError(');
expect(source).not.toContain('isUnsupportedMediaTypeError(');
});
it('removes normalizeContentText from upstream endpoint routing', () => { it('removes normalizeContentText from upstream endpoint routing', () => {
const source = readSource('./upstreamEndpoint.ts'); const source = readSource('./upstreamEndpoint.ts');
expect(source).not.toContain('function normalizeContentText('); expect(source).not.toContain('function normalizeContentText(');
@@ -71,5 +96,25 @@ describe('proxy route architecture boundaries', () => {
expect(source).toContain('stream.consumeUpstreamSseBuffer('); expect(source).toContain('stream.consumeUpstreamSseBuffer(');
expect(source).toContain('stream.serializeUpstreamJsonPayload('); expect(source).toContain('stream.serializeUpstreamJsonPayload(');
}); });
it('keeps chat stream lifecycle behind transformer-owned facade', () => {
const source = readSource('./chat.ts');
expect(source).not.toContain("from '../../transformers/shared/protocolLifecycle.js'");
expect(source).not.toContain('createProxyStreamLifecycle');
expect(source).not.toContain('let shouldTerminateEarly = false;');
expect(source).not.toContain('const consumeSseBuffer = (incoming: string): string => {');
expect(source).not.toContain('writeDone();');
expect(source).toContain('openAiChatTransformer.proxyStream.createSession(');
});
it('keeps responses stream lifecycle behind transformer-owned facade', () => {
const source = readSource('./responses.ts');
expect(source).not.toContain("from '../../transformers/shared/protocolLifecycle.js'");
expect(source).not.toContain('createProxyStreamLifecycle');
expect(source).not.toContain('const consumeSseBuffer = (incoming: string): string => {');
expect(source).not.toContain('openAiResponsesTransformer.aggregator.complete(');
expect(source).not.toContain('reply.raw.end();');
expect(source).toContain('openAiResponsesTransformer.proxyStream.createSession(');
});
}); });
@@ -0,0 +1,24 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
function readSource(relativePath: string): string {
return readFileSync(new URL(relativePath, import.meta.url), 'utf8');
}
describe('proxy route semantic ownership boundaries', () => {
it('keeps chat stream closeout semantics out of the route', () => {
const source = readSource('./chat.ts');
expect(source).not.toContain('const finalizeChatStream =');
expect(source).not.toContain('openAiChatTransformer.buildSyntheticChunks(');
expect(source).not.toContain('openAiChatTransformer.aggregator.finalize(');
});
it('keeps responses stream closeout semantics out of the route', () => {
const source = readSource('./responses.ts');
expect(source).not.toContain('const finalizeResponsesSse =');
expect(source).not.toContain("reply.raw.write('data: [DONE]");
expect(source).not.toContain('successfulUpstreamPath === \'/v1/responses\'');
});
});
+150 -2
View File
@@ -201,6 +201,117 @@ describe('chat proxy stream behavior', () => {
expect(response.body).toContain('data: [DONE]'); expect(response.body).toContain('data: [DONE]');
}); });
it('normalizes inline think tags into reasoning_content for /v1/chat/completions streams', async () => {
const encoder = new TextEncoder();
const upstreamBody = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode('data: {"id":"chatcmpl-think","model":"upstream-gpt","choices":[{"delta":{"role":"assistant"},"finish_reason":null}]}\n\n'));
controller.enqueue(encoder.encode('data: {"id":"chatcmpl-think","model":"upstream-gpt","choices":[{"delta":{"content":"<think>plan quietly</think>"},"finish_reason":null}]}\n\n'));
controller.enqueue(encoder.encode('data: {"id":"chatcmpl-think","model":"upstream-gpt","choices":[{"delta":{"content":"visible answer"},"finish_reason":null}]}\n\n'));
controller.enqueue(encoder.encode('data: {"id":"chatcmpl-think","model":"upstream-gpt","choices":[{"delta":{},"finish_reason":"stop"}]}\n\n'));
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
controller.close();
},
});
fetchMock.mockResolvedValue(new Response(upstreamBody, {
status: 200,
headers: { 'content-type': 'text/event-stream; charset=utf-8' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: {
model: 'gpt-4o-mini',
stream: true,
messages: [{ role: 'user', content: 'show your work and answer' }],
},
});
expect(response.statusCode).toBe(200);
expect(response.body).toContain('"reasoning_content":"plan quietly"');
expect(response.body).toContain('"content":"visible answer"');
expect(response.body).not.toContain('<think>');
expect(response.body).not.toContain('</think>');
expect(response.body).toContain('data: [DONE]');
});
it('tracks split inline think tags across SSE chunks for /v1/chat/completions streams', async () => {
const encoder = new TextEncoder();
const upstreamBody = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode('data: {"id":"chatcmpl-think-split","model":"upstream-gpt","choices":[{"delta":{"role":"assistant"},"finish_reason":null}]}\n\n'));
controller.enqueue(encoder.encode('data: {"id":"chatcmpl-think-split","model":"upstream-gpt","choices":[{"delta":{"content":"<thin"},"finish_reason":null}]}\n\n'));
controller.enqueue(encoder.encode('data: {"id":"chatcmpl-think-split","model":"upstream-gpt","choices":[{"delta":{"content":"k>plan "},"finish_reason":null}]}\n\n'));
controller.enqueue(encoder.encode('data: {"id":"chatcmpl-think-split","model":"upstream-gpt","choices":[{"delta":{"content":"quietly</th"},"finish_reason":null}]}\n\n'));
controller.enqueue(encoder.encode('data: {"id":"chatcmpl-think-split","model":"upstream-gpt","choices":[{"delta":{"content":"ink>visible "},"finish_reason":null}]}\n\n'));
controller.enqueue(encoder.encode('data: {"id":"chatcmpl-think-split","model":"upstream-gpt","choices":[{"delta":{"content":"answer"},"finish_reason":null}]}\n\n'));
controller.enqueue(encoder.encode('data: {"id":"chatcmpl-think-split","model":"upstream-gpt","choices":[{"delta":{},"finish_reason":"stop"}]}\n\n'));
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
controller.close();
},
});
fetchMock.mockResolvedValue(new Response(upstreamBody, {
status: 200,
headers: { 'content-type': 'text/event-stream; charset=utf-8' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: {
model: 'gpt-4o-mini',
stream: true,
messages: [{ role: 'user', content: 'show your work and answer' }],
},
});
expect(response.statusCode).toBe(200);
expect(response.body).toContain('"reasoning_content":"plan "');
expect(response.body).toContain('"reasoning_content":"quietly"');
expect(response.body).toContain('"content":"visible "');
expect(response.body).toContain('"content":"answer"');
expect(response.body).not.toContain('<think>');
expect(response.body).not.toContain('</think>');
expect(response.body).not.toContain('<thin');
expect(response.body).not.toContain('quietly</th');
expect(response.body).not.toContain('ink>visible');
expect(response.body).toContain('data: [DONE]');
});
it('synthesizes a terminal finish chunk when /v1/chat/completions upstream EOFs after visible content', async () => {
const encoder = new TextEncoder();
const upstreamBody = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode('data: {"id":"chatcmpl-eof","model":"upstream-gpt","choices":[{"delta":{"role":"assistant"},"finish_reason":null}]}\n\n'));
controller.enqueue(encoder.encode('data: {"id":"chatcmpl-eof","model":"upstream-gpt","choices":[{"delta":{"content":"tail before eof"},"finish_reason":null}]}\n\n'));
controller.close();
},
});
fetchMock.mockResolvedValue(new Response(upstreamBody, {
status: 200,
headers: { 'content-type': 'text/event-stream; charset=utf-8' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: {
model: 'gpt-4o-mini',
stream: true,
messages: [{ role: 'user', content: 'finish cleanly' }],
},
});
expect(response.statusCode).toBe(200);
expect(response.body).toContain('tail before eof');
expect(response.body).toContain('"finish_reason":"stop"');
expect(response.body).toContain('data: [DONE]');
});
it('normalizes anthropic-style SSE events into OpenAI chunks for clients like OpenWebUI', async () => { it('normalizes anthropic-style SSE events into OpenAI chunks for clients like OpenWebUI', async () => {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const upstreamBody = new ReadableStream<Uint8Array>({ const upstreamBody = new ReadableStream<Uint8Array>({
@@ -486,6 +597,40 @@ describe('chat proxy stream behavior', () => {
expect(response.body).not.toContain('event: ping'); expect(response.body).not.toContain('event: ping');
}); });
it('does not synthesize message_stop when anthropic upstream EOFs before terminal event on /v1/messages', async () => {
const encoder = new TextEncoder();
const upstreamBody = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode('event: message_start\ndata: {"type":"message_start","message":{"id":"msg_eof_early","model":"claude-opus-4-6"}}\n\n'));
controller.enqueue(encoder.encode('event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}\n\n'));
controller.enqueue(encoder.encode('event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello"}}\n\n'));
controller.close();
},
});
fetchMock.mockResolvedValue(new Response(upstreamBody, {
status: 200,
headers: { 'content-type': 'text/event-stream; charset=utf-8' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/messages',
payload: {
model: 'claude-opus-4-6',
stream: true,
max_tokens: 256,
messages: [{ role: 'user', content: 'hello' }],
},
});
expect(response.statusCode).toBe(200);
expect(response.body).toContain('event: message_start');
expect(response.body).toContain('event: content_block_delta');
expect(response.body).not.toContain('event: message_stop');
expect(response.body).not.toContain('"stop_reason":"end_turn"');
});
it('normalizes Claude thinking adaptive type for legacy upstreams on /v1/messages', async () => { it('normalizes Claude thinking adaptive type for legacy upstreams on /v1/messages', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({ fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'msg_headers_adaptive', id: 'msg_headers_adaptive',
@@ -839,7 +984,7 @@ describe('chat proxy stream behavior', () => {
expect(body.output_text).toContain('ok from messages fallback'); expect(body.output_text).toContain('ok from messages fallback');
}); });
it('passes through /v1/responses SSE payloads', async () => { it('canonicalizes native /v1/responses SSE payloads instead of passing them through raw', async () => {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const upstreamBody = new ReadableStream<Uint8Array>({ const upstreamBody = new ReadableStream<Uint8Array>({
start(controller) { start(controller) {
@@ -867,6 +1012,9 @@ describe('chat proxy stream behavior', () => {
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
expect(response.headers['content-type']).toContain('text/event-stream'); expect(response.headers['content-type']).toContain('text/event-stream');
expect(response.body).toContain('response.output_text.delta'); expect(response.body).toContain('response.output_text.delta');
expect(response.body).toContain('response.output_text.done');
expect(response.body).toContain('response.output_item.done');
expect(response.body).toContain('response.completed');
expect(response.body).toContain('[DONE]'); expect(response.body).toContain('[DONE]');
}); });
@@ -1524,7 +1672,7 @@ describe('chat proxy stream behavior', () => {
expect(deltaMatches.length).toBe(1); expect(deltaMatches.length).toBe(1);
const textMatches = response.body.match(/I'm Claude, an AI assistant made by Anthropic\./g) || []; const textMatches = response.body.match(/I'm Claude, an AI assistant made by Anthropic\./g) || [];
expect(textMatches.length).toBeGreaterThan(0); expect(textMatches.length).toBeGreaterThan(0);
expect(textMatches.length).toBeLessThanOrEqual(4); expect(textMatches.length).toBeLessThanOrEqual(6);
}); });
it('deduplicates overlapping text windows when /v1/responses is converted from /v1/messages stream', async () => { it('deduplicates overlapping text windows when /v1/responses is converted from /v1/messages stream', async () => {
+112 -256
View File
@@ -11,12 +11,7 @@ import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParse
import { resolveProxyUrlForSite, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; import { resolveProxyUrlForSite, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { type DownstreamFormat } from '../../transformers/shared/normalized.js'; import { type DownstreamFormat } from '../../transformers/shared/normalized.js';
import { import {
buildMinimalJsonHeadersForCompatibility,
buildUpstreamEndpointRequest, buildUpstreamEndpointRequest,
isEndpointDispatchDeniedError,
isEndpointDowngradeError,
isUnsupportedMediaTypeError,
promoteResponsesCandidateAfterLegacyChatError,
resolveUpstreamEndpointCandidates, resolveUpstreamEndpointCandidates,
} from './upstreamEndpoint.js'; } from './upstreamEndpoint.js';
import { import {
@@ -25,11 +20,12 @@ import {
recordDownstreamCostUsage, recordDownstreamCostUsage,
} from './downstreamPolicy.js'; } from './downstreamPolicy.js';
import { composeProxyLogMessage } from './logPathMeta.js'; import { composeProxyLogMessage } from './logPathMeta.js';
import { executeEndpointFlow, withUpstreamPath } from './endpointFlow.js'; import { executeEndpointFlow } from './endpointFlow.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { resolveProxyLogBilling } from './proxyBilling.js'; import { resolveProxyLogBilling } from './proxyBilling.js';
import { openAiChatTransformer } from '../../transformers/openai/chat/index.js'; import { openAiChatTransformer } from '../../transformers/openai/chat/index.js';
import { anthropicMessagesTransformer } from '../../transformers/anthropic/messages/index.js'; import { anthropicMessagesTransformer } from '../../transformers/anthropic/messages/index.js';
import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js';
import { getProxyResourceOwner } from '../../middleware/auth.js'; import { getProxyResourceOwner } from '../../middleware/auth.js';
import { import {
ProxyInputFileResolutionError, ProxyInputFileResolutionError,
@@ -39,10 +35,6 @@ import {
const MAX_RETRIES = 2; const MAX_RETRIES = 2;
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
}
export async function chatProxyRoute(app: FastifyInstance) { export async function chatProxyRoute(app: FastifyInstance) {
app.post('/v1/chat/completions', async (request: FastifyRequest, reply: FastifyReply) => app.post('/v1/chat/completions', async (request: FastifyRequest, reply: FastifyReply) =>
handleChatProxyRequest(request, reply, 'openai')); handleChatProxyRequest(request, reply, 'openai'));
@@ -61,13 +53,24 @@ async function handleChatProxyRequest(
const downstreamTransformer = downstreamFormat === 'claude' const downstreamTransformer = downstreamFormat === 'claude'
? anthropicMessagesTransformer ? anthropicMessagesTransformer
: openAiChatTransformer; : openAiChatTransformer;
const parsedRequest = downstreamTransformer.transformRequest(request.body); const downstreamPath = downstreamFormat === 'claude' ? '/v1/messages' : '/v1/chat/completions';
if (parsedRequest.error) { const clientContext = detectDownstreamClientContext({
return reply.code(parsedRequest.error.statusCode).send(parsedRequest.error.payload); downstreamPath,
headers: request.headers as Record<string, unknown>,
body: request.body,
});
const parsedRequestEnvelope = downstreamTransformer.transformRequest(request.body);
if (parsedRequestEnvelope.error) {
return reply.code(parsedRequestEnvelope.error.statusCode).send(parsedRequestEnvelope.error.payload);
} }
const { requestedModel, isStream, upstreamBody, claudeOriginalBody } = parsedRequest.value!; const requestEnvelope = parsedRequestEnvelope.value!;
const downstreamPath = downstreamFormat === 'claude' ? '/v1/messages' : '/v1/chat/completions'; const {
requestedModel,
isStream,
upstreamBody,
claudeOriginalBody,
} = requestEnvelope.parsed;
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
const downstreamPolicy = getDownstreamRoutingPolicy(request); const downstreamPolicy = getDownstreamRoutingPolicy(request);
const owner = getProxyResourceOwner(request); const owner = getProxyResourceOwner(request);
@@ -124,14 +127,10 @@ async function handleChatProxyRequest(
}, },
), ),
]; ];
let startTime = Date.now(); const buildEndpointRequest = (
endpoint: 'chat' | 'messages' | 'responses',
try { options: { forceNormalizeClaudeBody?: boolean } = {},
const endpointResult = await executeEndpointFlow({ ) => {
siteUrl: selected.site.url,
proxyUrl: resolveProxyUrlForSite(selected.site),
endpointCandidates,
buildRequest: (endpoint) => {
const endpointRequest = buildUpstreamEndpointRequest({ const endpointRequest = buildUpstreamEndpointRequest({
endpoint, endpoint,
modelName, modelName,
@@ -142,6 +141,7 @@ async function handleChatProxyRequest(
openaiBody: resolvedOpenAiBody, openaiBody: resolvedOpenAiBody,
downstreamFormat, downstreamFormat,
claudeOriginalBody, claudeOriginalBody,
forceNormalizeClaudeBody: options.forceNormalizeClaudeBody,
downstreamHeaders: request.headers as Record<string, unknown>, downstreamHeaders: request.headers as Record<string, unknown>,
}); });
return { return {
@@ -150,107 +150,37 @@ async function handleChatProxyRequest(
headers: endpointRequest.headers, headers: endpointRequest.headers,
body: endpointRequest.body as Record<string, unknown>, body: endpointRequest.body as Record<string, unknown>,
}; };
}, };
tryRecover: async (ctx) => { const endpointStrategy = downstreamTransformer.compatibility.createEndpointStrategy({
if (anthropicMessagesTransformer.compatibility.shouldRetryNormalizedBody({
downstreamFormat, downstreamFormat,
endpointPath: ctx.request.path, endpointCandidates,
status: ctx.response.status,
upstreamErrorText: ctx.rawErrText,
})) {
const normalizedClaudeRequest = buildUpstreamEndpointRequest({
endpoint: ctx.request.endpoint,
modelName,
stream: isStream,
tokenValue: selected.tokenValue,
sitePlatform: selected.site.platform,
siteUrl: selected.site.url,
openaiBody: resolvedOpenAiBody,
downstreamFormat,
claudeOriginalBody,
forceNormalizeClaudeBody: true,
downstreamHeaders: request.headers as Record<string, unknown>,
});
const normalizedTargetUrl = `${selected.site.url}${normalizedClaudeRequest.path}`;
const normalizedResponse = await fetch(normalizedTargetUrl, withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
headers: normalizedClaudeRequest.headers,
body: JSON.stringify(normalizedClaudeRequest.body),
}));
if (normalizedResponse.ok) {
return {
upstream: normalizedResponse,
upstreamPath: normalizedClaudeRequest.path,
};
}
ctx.request = {
...ctx.request,
path: normalizedClaudeRequest.path,
headers: normalizedClaudeRequest.headers,
body: normalizedClaudeRequest.body as Record<string, unknown>,
};
ctx.response = normalizedResponse;
ctx.rawErrText = await normalizedResponse.text().catch(() => 'unknown error');
}
if (!isUnsupportedMediaTypeError(ctx.response.status, ctx.rawErrText)) {
return null;
}
const minimalHeaders = buildMinimalJsonHeadersForCompatibility({
headers: ctx.request.headers,
endpoint: ctx.request.endpoint,
stream: isStream,
});
const normalizedCurrentHeaders = Object.fromEntries(
Object.entries(ctx.request.headers).map(([key, value]) => [key.toLowerCase(), value]),
);
if (JSON.stringify(minimalHeaders) === JSON.stringify(normalizedCurrentHeaders)) {
return null;
}
const minimalResponse = await fetch(ctx.targetUrl, withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
headers: minimalHeaders,
body: JSON.stringify(ctx.request.body),
}));
if (minimalResponse.ok) {
return {
upstream: minimalResponse,
upstreamPath: ctx.request.path,
};
}
ctx.request = {
...ctx.request,
headers: minimalHeaders,
};
ctx.response = minimalResponse;
ctx.rawErrText = await minimalResponse.text().catch(() => 'unknown error');
return null;
},
shouldDowngrade: (ctx) => (
(() => {
promoteResponsesCandidateAfterLegacyChatError(endpointCandidates, {
status: ctx.response.status,
upstreamErrorText: ctx.rawErrText,
downstreamFormat,
sitePlatform: selected.site.platform,
modelName, modelName,
requestedModelHint: requestedModel, requestedModelHint: requestedModel,
currentEndpoint: ctx.request.endpoint, sitePlatform: selected.site.platform,
}); isStream,
return ( buildRequest: ({ endpoint, forceNormalizeClaudeBody }) => buildEndpointRequest(
ctx.response.status >= 500 endpoint,
|| isEndpointDowngradeError(ctx.response.status, ctx.rawErrText) { forceNormalizeClaudeBody },
|| anthropicMessagesTransformer.compatibility.isMessagesRequiredError(ctx.rawErrText)
|| isEndpointDispatchDeniedError(ctx.response.status, ctx.rawErrText)
);
})()
), ),
dispatchRequest: (compatibilityRequest, targetUrl) => fetch(
targetUrl ?? `${selected.site.url}${compatibilityRequest.path}`,
withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
headers: compatibilityRequest.headers,
body: JSON.stringify(compatibilityRequest.body),
}),
),
});
let startTime = Date.now();
try {
const endpointResult = await executeEndpointFlow({
siteUrl: selected.site.url,
proxyUrl: resolveProxyUrlForSite(selected.site),
endpointCandidates,
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
tryRecover: endpointStrategy.tryRecover,
shouldDowngrade: endpointStrategy.shouldDowngrade,
onDowngrade: (ctx) => { onDowngrade: (ctx) => {
logProxy( logProxy(
selected, selected,
@@ -261,6 +191,13 @@ async function handleChatProxyRequest(
ctx.errText, ctx.errText,
retryCount, retryCount,
downstreamPath, downstreamPath,
0,
0,
0,
0,
null,
null,
clientContext,
); );
}, },
}); });
@@ -269,7 +206,23 @@ async function handleChatProxyRequest(
const status = endpointResult.status || 502; const status = endpointResult.status || 502;
const errText = endpointResult.errText || 'unknown error'; const errText = endpointResult.errText || 'unknown error';
tokenRouter.recordFailure(selected.channel.id); tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', status, Date.now() - startTime, errText, retryCount, downstreamPath); logProxy(
selected,
requestedModel,
'failed',
status,
Date.now() - startTime,
errText,
retryCount,
downstreamPath,
0,
0,
0,
0,
null,
null,
clientContext,
);
if (isTokenExpiredError({ status, message: errText })) { if (isTokenExpiredError({ status, message: errText })) {
await reportTokenExpired({ await reportTokenExpired({
@@ -306,8 +259,6 @@ async function handleChatProxyRequest(
reply.raw.setHeader('Connection', 'keep-alive'); reply.raw.setHeader('Connection', 'keep-alive');
reply.raw.setHeader('X-Accel-Buffering', 'no'); reply.raw.setHeader('X-Accel-Buffering', 'no');
const streamContext = downstreamTransformer.createStreamContext(modelName);
const claudeContext = anthropicMessagesTransformer.createDownstreamContext();
let parsedUsage: ReturnType<typeof parseProxyUsage> = { let parsedUsage: ReturnType<typeof parseProxyUsage> = {
promptTokens: 0, promptTokens: 0,
completionTokens: 0, completionTokens: 0,
@@ -322,10 +273,19 @@ async function handleChatProxyRequest(
reply.raw.write(line); reply.raw.write(line);
} }
}; };
const streamSession = openAiChatTransformer.proxyStream.createSession({
const writeDone = () => { downstreamFormat,
writeLines(downstreamTransformer.serializeDone(streamContext, claudeContext)); modelName,
}; onParsedPayload: (payload) => {
if (payload && typeof payload === 'object') {
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(payload));
}
},
writeLines,
writeRaw: (chunk) => {
reply.raw.write(chunk);
},
});
const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase(); const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase();
if (!upstreamContentType.includes('text/event-stream')) { if (!upstreamContentType.includes('text/event-stream')) {
@@ -336,29 +296,7 @@ async function handleChatProxyRequest(
} catch { } catch {
fallbackData = fallbackText; fallbackData = fallbackText;
} }
streamSession.consumeUpstreamFinalPayload(fallbackData, fallbackText, reply.raw);
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(fallbackData));
if (downstreamFormat === 'openai') {
const syntheticLines = openAiChatTransformer.serializeUpstreamFinalAsStream(
fallbackData,
modelName,
fallbackText,
streamContext,
);
writeLines(syntheticLines);
} else {
writeLines(
anthropicMessagesTransformer.serializeUpstreamFinalAsStream(
fallbackData,
modelName,
fallbackText,
streamContext,
claudeContext,
),
);
}
writeDone();
reply.raw.end();
const latency = Date.now() - startTime; const latency = Date.now() - startTime;
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
@@ -407,111 +345,7 @@ async function handleChatProxyRequest(
} }
const reader = upstream.body?.getReader(); const reader = upstream.body?.getReader();
if (!reader) { await streamSession.run(reader, reply.raw);
writeDone();
reply.raw.end();
return;
}
const decoder = new TextDecoder();
let sseBuffer = '';
let shouldTerminateEarly = false;
const consumeSseBuffer = (incoming: string): string => {
const pulled = downstreamTransformer.pullSseEvents(incoming);
for (const eventBlock of pulled.events) {
if (eventBlock.data === '[DONE]') {
writeDone();
shouldTerminateEarly = true;
continue;
}
let parsedPayload: unknown = null;
if (downstreamFormat === 'claude') {
const consumed = anthropicMessagesTransformer.consumeSseEventBlock(
eventBlock,
streamContext,
claudeContext,
modelName,
);
parsedPayload = consumed.parsedPayload;
if (parsedPayload && typeof parsedPayload === 'object') {
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(parsedPayload));
}
if (consumed.handled) {
writeLines(consumed.lines);
if (consumed.done) {
shouldTerminateEarly = true;
break;
}
continue;
}
} else {
try {
parsedPayload = JSON.parse(eventBlock.data);
} catch {
parsedPayload = null;
}
if (parsedPayload && typeof parsedPayload === 'object') {
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(parsedPayload));
}
}
if (parsedPayload && typeof parsedPayload === 'object') {
const normalizedEvent = downstreamTransformer.transformStreamEvent(parsedPayload, streamContext, modelName);
writeLines(downstreamTransformer.serializeStreamEvent(normalizedEvent, streamContext, claudeContext));
if (downstreamFormat === 'claude' && claudeContext.doneSent) {
shouldTerminateEarly = true;
break;
}
if (streamContext.doneSent) {
shouldTerminateEarly = true;
break;
}
continue;
}
if (downstreamFormat === 'openai') {
reply.raw.write(`data: ${eventBlock.data}\n\n`);
} else {
writeLines(anthropicMessagesTransformer.serializeStreamEvent({
contentDelta: eventBlock.data,
}, streamContext, claudeContext));
if (claudeContext.doneSent) {
shouldTerminateEarly = true;
break;
}
}
}
return pulled.rest;
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
sseBuffer += decoder.decode(value, { stream: true });
sseBuffer = consumeSseBuffer(sseBuffer);
if (shouldTerminateEarly) {
await reader.cancel().catch(() => {});
break;
}
}
if (!shouldTerminateEarly) {
sseBuffer += decoder.decode();
}
if (!shouldTerminateEarly && sseBuffer.trim().length > 0) {
sseBuffer = consumeSseBuffer(`${sseBuffer}\n\n`);
}
} finally {
reader.releaseLock();
writeDone();
reply.raw.end();
}
const latency = Date.now() - startTime; const latency = Date.now() - startTime;
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
@@ -618,7 +452,23 @@ async function handleChatProxyRequest(
return reply.send(downstreamResponse); return reply.send(downstreamResponse);
} catch (err: any) { } catch (err: any) {
tokenRouter.recordFailure(selected.channel.id); tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err?.message || 'network error', retryCount, downstreamPath); logProxy(
selected,
requestedModel,
'failed',
0,
Date.now() - startTime,
err?.message || 'network error',
retryCount,
downstreamPath,
0,
0,
0,
0,
null,
null,
clientContext,
);
if (retryCount < MAX_RETRIES) { if (retryCount < MAX_RETRIES) {
retryCount += 1; retryCount += 1;
@@ -655,10 +505,16 @@ async function logProxy(
estimatedCost = 0, estimatedCost = 0,
billingDetails: unknown = null, billingDetails: unknown = null,
upstreamPath: string | null = null, upstreamPath: string | null = null,
clientContext: DownstreamClientContext | null = null,
) { ) {
try { try {
const createdAt = formatUtcSqlDateTime(new Date()); const createdAt = formatUtcSqlDateTime(new Date());
const normalizedErrorMessage = composeProxyLogMessage({ const normalizedErrorMessage = composeProxyLogMessage({
clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic'
? clientContext.clientKind
: null,
sessionId: clientContext?.sessionId || null,
traceHint: clientContext?.traceHint || null,
downstreamPath, downstreamPath,
upstreamPath, upstreamPath,
errorMessage, errorMessage,
@@ -0,0 +1,246 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const fetchMock = vi.fn();
const selectChannelMock = vi.fn();
const selectNextChannelMock = vi.fn();
const recordSuccessMock = vi.fn();
const recordFailureMock = vi.fn();
const refreshModelsAndRebuildRoutesMock = vi.fn();
const reportProxyAllFailedMock = vi.fn();
const reportTokenExpiredMock = vi.fn();
const estimateProxyCostMock = vi.fn(async (_arg?: any) => 0);
const buildProxyBillingDetailsMock = vi.fn(async (_arg?: any) => null);
const fetchModelPricingCatalogMock = vi.fn(async (_arg?: any): Promise<any> => null);
const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) => ({
...usage,
estimatedCostFromQuota: 0,
recoveredFromSelfLog: false,
}));
const dbValuesMock = vi.fn((_arg?: any) => ({
run: () => undefined,
}));
const dbInsertMock = vi.fn((_arg?: any) => ({
values: (arg: any) => dbValuesMock(arg),
}));
vi.mock('undici', () => ({
fetch: (...args: unknown[]) => fetchMock(...args),
}));
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),
},
}));
vi.mock('../../services/modelService.js', () => ({
refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args),
}));
vi.mock('../../services/alertService.js', () => ({
reportProxyAllFailed: (...args: unknown[]) => reportProxyAllFailedMock(...args),
reportTokenExpired: (...args: unknown[]) => reportTokenExpiredMock(...args),
}));
vi.mock('../../services/alertRules.js', () => ({
isTokenExpiredError: () => false,
}));
vi.mock('../../services/modelPricingService.js', () => ({
estimateProxyCost: (arg: any) => estimateProxyCostMock(arg),
buildProxyBillingDetails: (arg: any) => buildProxyBillingDetailsMock(arg),
fetchModelPricingCatalog: (arg: any) => fetchModelPricingCatalogMock(arg),
}));
vi.mock('../../services/proxyRetryPolicy.js', () => ({
shouldRetryProxyRequest: () => false,
}));
vi.mock('../../services/proxyUsageFallbackService.js', () => ({
resolveProxyUsageWithSelfLogFallback: (arg: any) => resolveProxyUsageWithSelfLogFallbackMock(arg),
}));
vi.mock('../../db/index.js', () => ({
db: {
insert: (arg: any) => dbInsertMock(arg),
},
schema: {
proxyLogs: {},
},
}));
describe('downstream client context route logging', () => {
let app: FastifyInstance;
beforeAll(async () => {
const { claudeMessagesProxyRoute } = await import('./chat.js');
const { responsesProxyRoute } = await import('./responses.js');
app = Fastify();
await app.register(claudeMessagesProxyRoute);
await app.register(responsesProxyRoute);
});
beforeEach(() => {
fetchMock.mockReset();
selectChannelMock.mockReset();
selectNextChannelMock.mockReset();
recordSuccessMock.mockReset();
recordFailureMock.mockReset();
refreshModelsAndRebuildRoutesMock.mockReset();
reportProxyAllFailedMock.mockReset();
reportTokenExpiredMock.mockReset();
estimateProxyCostMock.mockClear();
buildProxyBillingDetailsMock.mockClear();
fetchModelPricingCatalogMock.mockReset();
resolveProxyUsageWithSelfLogFallbackMock.mockClear();
dbInsertMock.mockClear();
dbValuesMock.mockClear();
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { id: 44, name: 'demo-site', url: 'https://upstream.example.com', platform: 'openai' },
account: { id: 33, username: 'demo-user' },
tokenName: 'default',
tokenValue: 'sk-demo',
actualModel: 'upstream-gpt',
});
selectNextChannelMock.mockReturnValue(null);
fetchModelPricingCatalogMock.mockResolvedValue(null);
});
afterAll(async () => {
await app.close();
});
it('includes Codex client and session metadata in /v1/responses failure logs', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({
error: {
message: 'bad request',
type: 'upstream_error',
},
}), {
status: 400,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/responses',
headers: {
originator: 'codex_cli_rs',
Session_id: 'codex-session-123',
},
payload: {
model: 'gpt-5.2',
input: 'hello',
},
});
expect(response.statusCode).toBe(400);
expect(dbValuesMock).toHaveBeenCalled();
const insertedLog = dbValuesMock.mock.calls.at(-1)?.[0];
expect(insertedLog.errorMessage).toContain('[client:codex]');
expect(insertedLog.errorMessage).toContain('[session:codex-session-123]');
expect(insertedLog.errorMessage).toContain('[downstream:/v1/responses]');
});
it('reuses the same Codex detection on /v1/responses/compact failure logs', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({
error: {
message: 'compact failed',
type: 'upstream_error',
},
}), {
status: 400,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/responses/compact',
headers: {
'x-stainless-lang': 'typescript',
Session_id: 'codex-session-compact',
},
payload: {
model: 'gpt-5.2',
input: 'hello',
},
});
expect(response.statusCode).toBe(400);
expect(dbValuesMock).toHaveBeenCalled();
const insertedLog = dbValuesMock.mock.calls.at(-1)?.[0];
expect(insertedLog.errorMessage).toContain('[client:codex]');
expect(insertedLog.errorMessage).toContain('[session:codex-session-compact]');
expect(insertedLog.errorMessage).toContain('[downstream:/v1/responses/compact]');
});
it('includes Claude Code client and session metadata in /v1/messages failure logs', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({
error: {
message: 'messages failed',
type: 'upstream_error',
},
}), {
status: 400,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/messages',
payload: {
model: 'claude-opus-4-6',
max_tokens: 256,
messages: [{ role: 'user', content: 'hello' }],
metadata: {
user_id: 'user_20836b5653ed68aa981604f502c0a491397f6053826a93c953423632578d38ad_account__session_f25958b8-e75c-455d-8b40-f006d87cc2a4',
},
},
});
expect(response.statusCode).toBe(400);
expect(dbValuesMock).toHaveBeenCalled();
const insertedLog = dbValuesMock.mock.calls.at(-1)?.[0];
expect(insertedLog.errorMessage).toContain('[client:claude_code]');
expect(insertedLog.errorMessage).toContain('[session:f25958b8-e75c-455d-8b40-f006d87cc2a4]');
expect(insertedLog.errorMessage).toContain('[downstream:/v1/messages]');
});
it('keeps invalid Claude metadata.user_id requests generic in failure logs', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({
error: {
message: 'messages failed',
type: 'upstream_error',
},
}), {
status: 400,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/messages',
payload: {
model: 'claude-opus-4-6',
max_tokens: 256,
messages: [{ role: 'user', content: 'hello' }],
metadata: {
user_id: 'user_123',
},
},
});
expect(response.statusCode).toBe(400);
expect(dbValuesMock).toHaveBeenCalled();
const insertedLog = dbValuesMock.mock.calls.at(-1)?.[0];
expect(insertedLog.errorMessage).toContain('[downstream:/v1/messages]');
expect(insertedLog.errorMessage).not.toContain('[client:');
expect(insertedLog.errorMessage).not.toContain('[session:');
});
});
@@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest';
import {
detectDownstreamClientContext,
extractClaudeCodeSessionId,
isCodexResponsesSurface,
} from './downstreamClientContext.js';
describe('extractClaudeCodeSessionId', () => {
it('extracts session uuid from axonhub-compatible Claude Code user ids', () => {
expect(extractClaudeCodeSessionId(
'user_20836b5653ed68aa981604f502c0a491397f6053826a93c953423632578d38ad_account__session_f25958b8-e75c-455d-8b40-f006d87cc2a4',
)).toBe('f25958b8-e75c-455d-8b40-f006d87cc2a4');
});
it('returns null for non-Claude-Code user ids', () => {
expect(extractClaudeCodeSessionId('user_123')).toBe(null);
expect(extractClaudeCodeSessionId('session_f25958b8-e75c-455d-8b40-f006d87cc2a4')).toBe(null);
});
});
describe('isCodexResponsesSurface', () => {
it('detects Codex responses surface from originator and stainless headers', () => {
expect(isCodexResponsesSurface({
originator: 'codex_cli_rs',
})).toBe(true);
expect(isCodexResponsesSurface({
'x-stainless-lang': 'typescript',
})).toBe(true);
});
it('returns false for generic responses clients', () => {
expect(isCodexResponsesSurface({
'content-type': 'application/json',
})).toBe(false);
});
});
describe('detectDownstreamClientContext', () => {
it('recognizes Codex requests and attaches Session_id as session and trace hint', () => {
expect(detectDownstreamClientContext({
downstreamPath: '/v1/responses',
headers: {
originator: 'codex_cli_rs',
Session_id: 'codex-session-123',
},
})).toEqual({
clientKind: 'codex',
sessionId: 'codex-session-123',
traceHint: 'codex-session-123',
});
});
it('keeps Codex requests without Session_id as client-only context', () => {
expect(detectDownstreamClientContext({
downstreamPath: '/v1/responses/compact',
headers: {
'x-stainless-lang': 'typescript',
},
})).toEqual({
clientKind: 'codex',
});
});
it('recognizes Claude Code requests from metadata.user_id without mutating the body', () => {
const body = {
model: 'claude-opus-4-6',
metadata: {
user_id: 'user_20836b5653ed68aa981604f502c0a491397f6053826a93c953423632578d38ad_account__session_f25958b8-e75c-455d-8b40-f006d87cc2a4',
},
};
expect(detectDownstreamClientContext({
downstreamPath: '/v1/messages',
body,
})).toEqual({
clientKind: 'claude_code',
sessionId: 'f25958b8-e75c-455d-8b40-f006d87cc2a4',
traceHint: 'f25958b8-e75c-455d-8b40-f006d87cc2a4',
});
expect(body).toEqual({
model: 'claude-opus-4-6',
metadata: {
user_id: 'user_20836b5653ed68aa981604f502c0a491397f6053826a93c953423632578d38ad_account__session_f25958b8-e75c-455d-8b40-f006d87cc2a4',
},
});
});
it('falls back to generic when Claude metadata.user_id is missing or invalid', () => {
expect(detectDownstreamClientContext({
downstreamPath: '/v1/messages',
body: {
metadata: {
user_id: 'user_123',
},
},
})).toEqual({
clientKind: 'generic',
});
expect(detectDownstreamClientContext({
downstreamPath: '/v1/messages',
body: {
metadata: {
session_id: 'abc123',
},
},
})).toEqual({
clientKind: 'generic',
});
});
});
@@ -0,0 +1,120 @@
export type DownstreamClientKind = 'generic' | 'codex' | 'claude_code';
export type DownstreamClientContext = {
clientKind: DownstreamClientKind;
sessionId?: string;
traceHint?: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
}
function headerValueToString(value: unknown): string | null {
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed || null;
}
if (Array.isArray(value)) {
for (const item of value) {
if (typeof item !== 'string') continue;
const trimmed = item.trim();
if (trimmed) return trimmed;
}
}
return null;
}
function getHeaderValue(headers: Record<string, unknown> | undefined, targetKey: string): string | null {
if (!headers) return null;
const normalizedTarget = targetKey.trim().toLowerCase();
for (const [rawKey, rawValue] of Object.entries(headers)) {
if (rawKey.trim().toLowerCase() !== normalizedTarget) continue;
return headerValueToString(rawValue);
}
return null;
}
export function isCodexResponsesSurface(headers?: Record<string, unknown>): boolean {
if (!headers) return false;
let sawOpenAiBeta = false;
let sawStainless = false;
for (const [rawKey, rawValue] of Object.entries(headers)) {
const key = rawKey.trim().toLowerCase();
const value = headerValueToString(rawValue);
if (!key || !value) continue;
if (key === 'originator' && value.toLowerCase() === 'codex_cli_rs') {
return true;
}
if (key === 'openai-beta') {
sawOpenAiBeta = true;
}
if (key.startsWith('x-stainless-')) {
sawStainless = true;
}
}
return sawOpenAiBeta || sawStainless;
}
const claudeCodeUserIdPattern = /^user_[0-9a-f]{64}_account__session_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function extractClaudeCodeSessionId(userId: string): string | null {
const trimmed = userId.trim();
if (!claudeCodeUserIdPattern.test(trimmed)) return null;
const sessionPrefix = '__session_';
const sessionIndex = trimmed.lastIndexOf(sessionPrefix);
if (sessionIndex === -1) return null;
const sessionId = trimmed.slice(sessionIndex + sessionPrefix.length).trim();
return sessionId || null;
}
export function detectDownstreamClientContext(input: {
downstreamPath: string;
headers?: Record<string, unknown>;
body?: unknown;
}): DownstreamClientContext {
const normalizedPath = input.downstreamPath.trim().toLowerCase();
if (normalizedPath === '/v1/messages' || normalizedPath === '/anthropic/v1/messages') {
if (isRecord(input.body) && isRecord(input.body.metadata)) {
const userId = typeof input.body.metadata.user_id === 'string'
? input.body.metadata.user_id.trim()
: '';
const sessionId = userId ? extractClaudeCodeSessionId(userId) : null;
if (sessionId) {
return {
clientKind: 'claude_code',
sessionId,
traceHint: sessionId,
};
}
}
return { clientKind: 'generic' };
}
if (normalizedPath.startsWith('/v1/responses') && isCodexResponsesSurface(input.headers)) {
const sessionId = getHeaderValue(input.headers, 'session_id') || getHeaderValue(input.headers, 'session-id');
if (sessionId) {
return {
clientKind: 'codex',
sessionId,
traceHint: sessionId,
};
}
return { clientKind: 'codex' };
}
return { clientKind: 'generic' };
}
@@ -31,4 +31,22 @@ describe('composeProxyLogMessage', () => {
errorMessage: '', errorMessage: '',
})).toBe(null); })).toBe(null);
}); });
it('adds client and session metadata ahead of path metadata', () => {
expect(composeProxyLogMessage({
clientKind: 'codex',
sessionId: 'codex-session-123',
downstreamPath: '/v1/responses',
errorMessage: 'upstream failed',
})).toBe('[client:codex] [session:codex-session-123] [downstream:/v1/responses] upstream failed');
});
it('reuses existing client and session metadata without duplication', () => {
expect(composeProxyLogMessage({
clientKind: 'claude_code',
sessionId: 'session-123',
downstreamPath: '/v1/messages',
errorMessage: '[client:claude_code] [session:session-123] [upstream:/v1/messages] bad request',
})).toBe('[client:claude_code] [session:session-123] [downstream:/v1/messages] [upstream:/v1/messages] bad request');
});
}); });
+20 -1
View File
@@ -1,20 +1,32 @@
type ComposeProxyLogMessageArgs = { type ComposeProxyLogMessageArgs = {
clientKind?: string | null;
sessionId?: string | null;
traceHint?: string | null;
downstreamPath?: string | null; downstreamPath?: string | null;
upstreamPath?: string | null; upstreamPath?: string | null;
errorMessage?: string | null; errorMessage?: string | null;
}; };
type ParsedPathMeta = { type ParsedPathMeta = {
clientKind: string | null;
sessionId: string | null;
downstreamPath: string | null; downstreamPath: string | null;
upstreamPath: string | null; upstreamPath: string | null;
messageText: string; messageText: string;
}; };
function parseExistingPathMeta(rawMessage: string): ParsedPathMeta { function parseExistingPathMeta(rawMessage: string): ParsedPathMeta {
const clientMatch = rawMessage.match(/\[client:([^\]]+)\]/i);
const sessionMatch = rawMessage.match(/\[session:([^\]]+)\]/i);
const downstreamMatch = rawMessage.match(/\[downstream:([^\]]+)\]/i); const downstreamMatch = rawMessage.match(/\[downstream:([^\]]+)\]/i);
const upstreamMatch = rawMessage.match(/\[upstream:([^\]]+)\]/i); const upstreamMatch = rawMessage.match(/\[upstream:([^\]]+)\]/i);
const messageText = rawMessage.replace(/^\s*(?:\[(?:downstream|upstream):[^\]]+\]\s*)+/i, '').trim(); const messageText = rawMessage.replace(
/^\s*(?:\[(?:client|session|downstream|upstream):[^\]]+\]\s*)+/i,
'',
).trim();
return { return {
clientKind: clientMatch?.[1]?.trim() || null,
sessionId: sessionMatch?.[1]?.trim() || null,
downstreamPath: downstreamMatch?.[1]?.trim() || null, downstreamPath: downstreamMatch?.[1]?.trim() || null,
upstreamPath: upstreamMatch?.[1]?.trim() || null, upstreamPath: upstreamMatch?.[1]?.trim() || null,
messageText, messageText,
@@ -22,17 +34,24 @@ function parseExistingPathMeta(rawMessage: string): ParsedPathMeta {
} }
export function composeProxyLogMessage({ export function composeProxyLogMessage({
clientKind,
sessionId,
traceHint,
downstreamPath, downstreamPath,
upstreamPath, upstreamPath,
errorMessage, errorMessage,
}: ComposeProxyLogMessageArgs): string | null { }: ComposeProxyLogMessageArgs): string | null {
const rawMessage = typeof errorMessage === 'string' ? errorMessage.trim() : ''; const rawMessage = typeof errorMessage === 'string' ? errorMessage.trim() : '';
const parsed = parseExistingPathMeta(rawMessage); const parsed = parseExistingPathMeta(rawMessage);
const finalClientKind = (clientKind || parsed.clientKind || '').trim();
const finalSessionId = (sessionId || traceHint || parsed.sessionId || '').trim();
const finalDownstreamPath = (downstreamPath || parsed.downstreamPath || '').trim(); const finalDownstreamPath = (downstreamPath || parsed.downstreamPath || '').trim();
const finalUpstreamPath = (upstreamPath || parsed.upstreamPath || '').trim(); const finalUpstreamPath = (upstreamPath || parsed.upstreamPath || '').trim();
const finalMessageText = parsed.messageText.trim(); const finalMessageText = parsed.messageText.trim();
const prefixParts: string[] = []; const prefixParts: string[] = [];
if (finalClientKind) prefixParts.push(`[client:${finalClientKind}]`);
if (finalSessionId) prefixParts.push(`[session:${finalSessionId}]`);
if (finalDownstreamPath) prefixParts.push(`[downstream:${finalDownstreamPath}]`); if (finalDownstreamPath) prefixParts.push(`[downstream:${finalDownstreamPath}]`);
if (finalUpstreamPath) prefixParts.push(`[upstream:${finalUpstreamPath}]`); if (finalUpstreamPath) prefixParts.push(`[upstream:${finalUpstreamPath}]`);
+104 -242
View File
@@ -11,10 +11,7 @@ import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParse
import { resolveProxyUrlForSite, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; import { resolveProxyUrlForSite, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js'; import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
import { import {
buildMinimalJsonHeadersForCompatibility,
buildUpstreamEndpointRequest, buildUpstreamEndpointRequest,
isEndpointDowngradeError,
isUnsupportedMediaTypeError,
resolveUpstreamEndpointCandidates, resolveUpstreamEndpointCandidates,
} from './upstreamEndpoint.js'; } from './upstreamEndpoint.js';
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js'; import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
@@ -23,6 +20,7 @@ import { executeEndpointFlow, withUpstreamPath } from './endpointFlow.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { resolveProxyLogBilling } from './proxyBilling.js'; import { resolveProxyLogBilling } from './proxyBilling.js';
import { getProxyResourceOwner } from '../../middleware/auth.js'; import { getProxyResourceOwner } from '../../middleware/auth.js';
import { detectDownstreamClientContext, isCodexResponsesSurface, type DownstreamClientContext } from './downstreamClientContext.js';
import { normalizeInputFileBlock } from '../../transformers/shared/inputFile.js'; import { normalizeInputFileBlock } from '../../transformers/shared/inputFile.js';
import { import {
ProxyInputFileResolutionError, ProxyInputFileResolutionError,
@@ -85,42 +83,6 @@ function carriesResponsesReasoningContinuity(value: unknown): boolean {
|| carriesResponsesReasoningContinuity(value.content); || carriesResponsesReasoningContinuity(value.content);
} }
function isCodexResponsesSurface(headers?: Record<string, unknown>): boolean {
if (!headers) return false;
const normalizeHeaderValue = (value: unknown): string => {
if (typeof value === 'string') return value.trim();
if (Array.isArray(value)) {
return value
.filter((item): item is string => typeof item === 'string')
.map((item) => item.trim())
.find((item) => item.length > 0) || '';
}
return '';
};
let sawOpenAiBeta = false;
let sawStainless = false;
for (const [rawKey, rawValue] of Object.entries(headers)) {
const key = rawKey.trim().toLowerCase();
const value = normalizeHeaderValue(rawValue);
if (!key || !value) continue;
if (key === 'originator' && value.toLowerCase() === 'codex_cli_rs') {
return true;
}
if (key === 'openai-beta') {
sawOpenAiBeta = true;
}
if (key.startsWith('x-stainless-')) {
sawStainless = true;
}
}
return sawOpenAiBeta || sawStainless;
}
function wantsNativeResponsesReasoning(body: unknown): boolean { function wantsNativeResponsesReasoning(body: unknown): boolean {
if (!isRecord(body)) return false; if (!isRecord(body)) return false;
const include = normalizeIncludeList(body.include); const include = normalizeIncludeList(body.include);
@@ -156,16 +118,27 @@ export async function responsesProxyRoute(app: FastifyInstance) {
reply: FastifyReply, reply: FastifyReply,
downstreamPath: string, downstreamPath: string,
) => { ) => {
const body = request.body as any; const body = request.body as Record<string, unknown>;
const requestedModel = typeof body?.model === 'string' ? body.model.trim() : ''; const clientContext = detectDownstreamClientContext({
if (!requestedModel) { downstreamPath,
return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } }); headers: request.headers as Record<string, unknown>,
body,
});
const defaultEncryptedReasoningInclude = isCodexResponsesSurface(
request.headers as Record<string, unknown>,
);
const parsedRequestEnvelope = openAiResponsesTransformer.transformRequest(body, {
defaultEncryptedReasoningInclude,
});
if (parsedRequestEnvelope.error) {
return reply.code(parsedRequestEnvelope.error.statusCode).send(parsedRequestEnvelope.error.payload);
} }
const requestEnvelope = parsedRequestEnvelope.value!;
const requestedModel = requestEnvelope.model;
const isStream = requestEnvelope.stream;
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
const downstreamPolicy = getDownstreamRoutingPolicy(request); const downstreamPolicy = getDownstreamRoutingPolicy(request);
const isCompactRequest = downstreamPath === '/v1/responses/compact'; const isCompactRequest = downstreamPath === '/v1/responses/compact';
const isStream = body.stream === true;
const excludeChannelIds: number[] = []; const excludeChannelIds: number[] = [];
let retryCount = 0; let retryCount = 0;
@@ -193,15 +166,11 @@ export async function responsesProxyRoute(app: FastifyInstance) {
const modelName = selected.actualModel || requestedModel; const modelName = selected.actualModel || requestedModel;
const owner = getProxyResourceOwner(request); const owner = getProxyResourceOwner(request);
const defaultEncryptedReasoningInclude = isCodexResponsesSurface( let normalizedResponsesBody: Record<string, unknown> = {
request.headers as Record<string, unknown>, ...requestEnvelope.parsed.normalizedBody,
); model: modelName,
let normalizedResponsesBody = openAiResponsesTransformer.inbound.sanitizeProxyBody( stream: isStream,
body, };
modelName,
isStream,
{ defaultEncryptedReasoningInclude },
);
if (owner) { if (owner) {
try { try {
normalizedResponsesBody = await resolveResponsesBodyInputFiles(normalizedResponsesBody, owner); normalizedResponsesBody = await resolveResponsesBodyInputFiles(normalizedResponsesBody, owner);
@@ -248,15 +217,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
if (endpointCandidates.length === 0) { if (endpointCandidates.length === 0) {
endpointCandidates.push('responses', 'chat', 'messages'); endpointCandidates.push('responses', 'chat', 'messages');
} }
const buildEndpointRequest = (endpoint: 'chat' | 'messages' | 'responses') => {
const startTime = Date.now();
try {
const endpointResult = await executeEndpointFlow({
siteUrl: selected.site.url,
proxyUrl: resolveProxyUrlForSite(selected.site),
endpointCandidates,
buildRequest: (endpoint) => {
const endpointRequest = buildUpstreamEndpointRequest({ const endpointRequest = buildUpstreamEndpointRequest({
endpoint, endpoint,
modelName, modelName,
@@ -280,90 +241,30 @@ export async function responsesProxyRoute(app: FastifyInstance) {
headers: endpointRequest.headers, headers: endpointRequest.headers,
body: endpointRequest.body as Record<string, unknown>, body: endpointRequest.body as Record<string, unknown>,
}; };
}, };
tryRecover: async (ctx) => { const endpointStrategy = openAiResponsesTransformer.compatibility.createEndpointStrategy({
if (openAiResponsesTransformer.compatibility.shouldRetry({
endpoint: ctx.request.endpoint,
status: ctx.response.status,
rawErrText: ctx.rawErrText,
})) {
const compatibilityBodies = openAiResponsesTransformer.compatibility.buildRetryBodies(ctx.request.body);
const compatibilityHeaders = openAiResponsesTransformer.compatibility.buildRetryHeaders(
ctx.request.headers,
isStream, isStream,
); requiresNativeResponsesFileUrl,
dispatchRequest: (compatibilityRequest, targetUrl) => fetch(
for (const compatibilityHeadersCandidate of compatibilityHeaders) { targetUrl ?? `${selected.site.url}${compatibilityRequest.path}`,
for (const compatibilityBody of compatibilityBodies) {
const compatibilityResponse = await fetch(
ctx.targetUrl,
withSiteRecordProxyRequestInit(selected.site, { withSiteRecordProxyRequestInit(selected.site, {
method: 'POST', method: 'POST',
headers: compatibilityHeadersCandidate, headers: compatibilityRequest.headers,
body: JSON.stringify(compatibilityBody), body: JSON.stringify(compatibilityRequest.body),
}), }),
);
if (compatibilityResponse.ok) {
return {
upstream: compatibilityResponse,
upstreamPath: ctx.request.path,
};
}
ctx.request = {
...ctx.request,
headers: compatibilityHeadersCandidate,
body: compatibilityBody,
};
ctx.response = compatibilityResponse;
ctx.rawErrText = await compatibilityResponse.text().catch(() => 'unknown error');
}
}
}
if (!isUnsupportedMediaTypeError(ctx.response.status, ctx.rawErrText)) {
return null;
}
const minimalHeaders = buildMinimalJsonHeadersForCompatibility({
headers: ctx.request.headers,
endpoint: ctx.request.endpoint,
stream: isStream,
});
const minimalResponse = await fetch(
ctx.targetUrl,
withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
headers: minimalHeaders,
body: JSON.stringify(ctx.request.body),
}),
);
if (minimalResponse.ok) {
return {
upstream: minimalResponse,
upstreamPath: ctx.request.path,
};
}
ctx.request = {
...ctx.request,
headers: minimalHeaders,
};
ctx.response = minimalResponse;
ctx.rawErrText = await minimalResponse.text().catch(() => 'unknown error');
return null;
},
shouldDowngrade: (ctx) => (
!requiresNativeResponsesFileUrl && (
ctx.response.status >= 500
|| isEndpointDowngradeError(ctx.response.status, ctx.rawErrText)
|| openAiResponsesTransformer.compatibility.shouldDowngradeChatToMessages(
ctx.request.path,
ctx.response.status,
ctx.rawErrText,
)
)
), ),
});
const startTime = Date.now();
try {
const endpointResult = await executeEndpointFlow({
siteUrl: selected.site.url,
proxyUrl: resolveProxyUrlForSite(selected.site),
endpointCandidates,
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
tryRecover: endpointStrategy.tryRecover,
shouldDowngrade: endpointStrategy.shouldDowngrade,
onDowngrade: (ctx) => { onDowngrade: (ctx) => {
logProxy( logProxy(
selected, selected,
@@ -374,6 +275,13 @@ export async function responsesProxyRoute(app: FastifyInstance) {
ctx.errText, ctx.errText,
retryCount, retryCount,
downstreamPath, downstreamPath,
0,
0,
0,
0,
null,
null,
clientContext,
); );
}, },
}); });
@@ -382,7 +290,23 @@ export async function responsesProxyRoute(app: FastifyInstance) {
const status = endpointResult.status || 502; const status = endpointResult.status || 502;
const errText = endpointResult.errText || 'unknown error'; const errText = endpointResult.errText || 'unknown error';
tokenRouter.recordFailure(selected.channel.id); tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', status, Date.now() - startTime, errText, retryCount, downstreamPath); logProxy(
selected,
requestedModel,
'failed',
status,
Date.now() - startTime,
errText,
retryCount,
downstreamPath,
0,
0,
0,
0,
null,
null,
clientContext,
);
if (isTokenExpiredError({ status, message: errText })) { if (isTokenExpiredError({ status, message: errText })) {
await reportTokenExpired({ await reportTokenExpired({
@@ -416,12 +340,6 @@ export async function responsesProxyRoute(app: FastifyInstance) {
reply.raw.setHeader('X-Accel-Buffering', 'no'); reply.raw.setHeader('X-Accel-Buffering', 'no');
const reader = upstream.body?.getReader(); const reader = upstream.body?.getReader();
if (!reader) {
reply.raw.end();
return;
}
const decoder = new TextDecoder();
let parsedUsage: UsageSummary = { let parsedUsage: UsageSummary = {
promptTokens: 0, promptTokens: 0,
completionTokens: 0, completionTokens: 0,
@@ -430,102 +348,24 @@ export async function responsesProxyRoute(app: FastifyInstance) {
cacheCreationTokens: 0, cacheCreationTokens: 0,
promptTokensIncludeCache: null, promptTokensIncludeCache: null,
}; };
let sseBuffer = '';
const passthroughResponsesStream = successfulUpstreamPath === '/v1/responses';
const streamContext = openAiResponsesTransformer.createStreamContext(modelName);
const responsesState = openAiResponsesTransformer.aggregator.createState(modelName);
const writeLines = (lines: string[]) => { const writeLines = (lines: string[]) => {
for (const line of lines) reply.raw.write(line); for (const line of lines) reply.raw.write(line);
}; };
const streamSession = openAiResponsesTransformer.proxyStream.createSession({
const consumeSseBuffer = (incoming: string): string => { modelName,
const pulled = openAiResponsesTransformer.pullSseEvents(incoming); successfulUpstreamPath,
for (const eventBlock of pulled.events) { getUsage: () => parsedUsage,
if (eventBlock.data === '[DONE]') { onParsedPayload: (payload) => {
if (passthroughResponsesStream) { if (payload && typeof payload === 'object') {
reply.raw.write('data: [DONE]\n\n'); parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(payload));
} else if (!responsesState.completed) {
writeLines(openAiResponsesTransformer.aggregator.complete(responsesState, streamContext, parsedUsage));
}
continue;
}
let parsedPayload: unknown = null;
try {
parsedPayload = JSON.parse(eventBlock.data);
} catch {
parsedPayload = null;
}
if (parsedPayload && typeof parsedPayload === 'object') {
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(parsedPayload));
}
if (passthroughResponsesStream) {
const eventName = eventBlock.event ? `event: ${eventBlock.event}\n` : '';
reply.raw.write(`${eventName}data: ${eventBlock.data}\n\n`);
continue;
}
const payloadType = (isRecord(parsedPayload) && typeof parsedPayload.type === 'string')
? parsedPayload.type
: '';
const isFailureEvent = (
eventBlock.event === 'error'
|| eventBlock.event === 'response.failed'
|| payloadType === 'error'
|| payloadType === 'response.failed'
);
if (isFailureEvent) {
writeLines(openAiResponsesTransformer.aggregator.fail(responsesState, streamContext, parsedUsage, parsedPayload));
continue;
}
if (parsedPayload && typeof parsedPayload === 'object') {
const normalizedEvent = openAiResponsesTransformer.transformStreamEvent(parsedPayload, streamContext, modelName);
writeLines(openAiResponsesTransformer.aggregator.serialize({
state: responsesState,
streamContext,
event: normalizedEvent,
usage: parsedUsage,
}));
continue;
}
writeLines(openAiResponsesTransformer.aggregator.serialize({
state: responsesState,
streamContext,
event: { contentDelta: eventBlock.data },
usage: parsedUsage,
}));
}
return pulled.rest;
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
sseBuffer += decoder.decode(value, { stream: true });
sseBuffer = consumeSseBuffer(sseBuffer);
}
sseBuffer += decoder.decode();
if (sseBuffer.trim().length > 0) {
sseBuffer = consumeSseBuffer(`${sseBuffer}\n\n`);
}
} finally {
reader.releaseLock();
if (!passthroughResponsesStream && !responsesState.completed) {
writeLines(openAiResponsesTransformer.aggregator.complete(responsesState, streamContext, parsedUsage));
}
reply.raw.end();
} }
},
writeLines,
writeRaw: (chunk) => {
reply.raw.write(chunk);
},
});
await streamSession.run(reader, reply.raw);
const latency = Date.now() - startTime; const latency = Date.now() - startTime;
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
@@ -612,7 +452,23 @@ export async function responsesProxyRoute(app: FastifyInstance) {
return reply.send(downstreamData); return reply.send(downstreamData);
} catch (err: any) { } catch (err: any) {
tokenRouter.recordFailure(selected.channel.id); tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, downstreamPath); logProxy(
selected,
requestedModel,
'failed',
0,
Date.now() - startTime,
err.message,
retryCount,
downstreamPath,
0,
0,
0,
0,
null,
null,
clientContext,
);
if (retryCount < MAX_RETRIES) { if (retryCount < MAX_RETRIES) {
retryCount += 1; retryCount += 1;
continue; continue;
@@ -649,10 +505,16 @@ async function logProxy(
estimatedCost = 0, estimatedCost = 0,
billingDetails: unknown = null, billingDetails: unknown = null,
upstreamPath: string | null = null, upstreamPath: string | null = null,
clientContext: DownstreamClientContext | null = null,
) { ) {
try { try {
const createdAt = formatUtcSqlDateTime(new Date()); const createdAt = formatUtcSqlDateTime(new Date());
const normalizedErrorMessage = composeProxyLogMessage({ const normalizedErrorMessage = composeProxyLogMessage({
clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic'
? clientContext.clientKind
: null,
sessionId: clientContext?.sessionId || null,
traceHint: clientContext?.traceHint || null,
downstreamPath, downstreamPath,
upstreamPath, upstreamPath,
errorMessage, errorMessage,
+8 -189
View File
@@ -8,6 +8,14 @@ import {
convertOpenAiBodyToAnthropicMessagesBody, convertOpenAiBodyToAnthropicMessagesBody,
sanitizeAnthropicMessagesBody, sanitizeAnthropicMessagesBody,
} from '../../transformers/anthropic/messages/conversion.js'; } from '../../transformers/anthropic/messages/conversion.js';
export {
buildMinimalJsonHeadersForCompatibility,
isEndpointDispatchDeniedError,
isEndpointDowngradeError,
isUnsupportedMediaTypeError,
promoteResponsesCandidateAfterLegacyChatError,
shouldPreferResponsesAfterLegacyChatError,
} from '../../transformers/shared/endpointCompatibility.js';
export type UpstreamEndpoint = 'chat' | 'messages' | 'responses'; export type UpstreamEndpoint = 'chat' | 'messages' | 'responses';
export type EndpointPreference = DownstreamFormat | 'responses'; export type EndpointPreference = DownstreamFormat | 'responses';
@@ -562,193 +570,4 @@ export function buildUpstreamEndpointRequest(input: {
}; };
} }
function normalizeHeaderMap(headers: Record<string, string>): Record<string, string> {
const normalized: Record<string, string> = {};
for (const [rawKey, rawValue] of Object.entries(headers)) {
const key = rawKey.trim().toLowerCase();
if (!key) continue;
const value = headerValueToString(rawValue);
if (!value) continue;
normalized[key] = value;
}
return normalized;
}
export function buildMinimalJsonHeadersForCompatibility(input: {
headers: Record<string, string>;
endpoint: UpstreamEndpoint;
stream: boolean;
}): Record<string, string> {
const source = normalizeHeaderMap(input.headers);
const minimal: Record<string, string> = {};
if (source.authorization) minimal.authorization = source.authorization;
if (source['x-api-key']) minimal['x-api-key'] = source['x-api-key'];
if (input.endpoint === 'messages') {
for (const [key, value] of Object.entries(source)) {
if (!key.startsWith('anthropic-')) continue;
minimal[key] = value;
}
if (!minimal['anthropic-version']) {
minimal['anthropic-version'] = '2023-06-01';
}
}
minimal['content-type'] = 'application/json';
minimal.accept = input.stream ? 'text/event-stream' : 'application/json';
return minimal;
}
export function isUnsupportedMediaTypeError(status: number, upstreamErrorText?: string | null): boolean {
if (status < 400) return false;
if (status !== 400 && status !== 415) return false;
const text = (upstreamErrorText || '').toLowerCase();
if (!text) return status === 415;
return (
text.includes('unsupported media type')
|| text.includes("only 'application/json' is allowed")
|| text.includes('only "application/json" is allowed')
|| text.includes('application/json')
|| text.includes('content-type')
);
}
export function isEndpointDispatchDeniedError(status: number, upstreamErrorText?: string | null): boolean {
if (status !== 403) return false;
const text = (upstreamErrorText || '').toLowerCase();
if (!text) return false;
return (
/does\s+not\s+allow\s+\/v1\/[a-z0-9/_:-]+\s+dispatch/i.test(upstreamErrorText || '')
|| text.includes('dispatch denied')
);
}
export function shouldPreferResponsesAfterLegacyChatError(input: {
status: number;
upstreamErrorText?: string | null;
downstreamFormat: EndpointPreference;
sitePlatform?: string | null;
modelName?: string | null;
requestedModelHint?: string | null;
currentEndpoint?: UpstreamEndpoint | null;
}): boolean {
if (input.status < 400) return false;
if (input.downstreamFormat !== 'openai') return false;
if (input.currentEndpoint !== 'chat') return false;
const sitePlatform = normalizePlatformName(input.sitePlatform);
if (sitePlatform === 'openai' || sitePlatform === 'claude' || sitePlatform === 'gemini' || sitePlatform === 'anyrouter') {
return false;
}
const modelName = asTrimmedString(input.modelName);
const requestedModelHint = asTrimmedString(input.requestedModelHint);
if (isClaudeFamilyModel(modelName) || isClaudeFamilyModel(requestedModelHint)) {
return false;
}
const text = (input.upstreamErrorText || '').toLowerCase();
return (
text.includes('unsupported legacy protocol')
&& text.includes('/v1/chat/completions')
&& text.includes('/v1/responses')
);
}
export function promoteResponsesCandidateAfterLegacyChatError(
endpointCandidates: UpstreamEndpoint[],
input: Parameters<typeof shouldPreferResponsesAfterLegacyChatError>[0],
): void {
if (!shouldPreferResponsesAfterLegacyChatError(input)) return;
const currentIndex = endpointCandidates.findIndex((endpoint) => endpoint === input.currentEndpoint);
const responsesIndex = endpointCandidates.indexOf('responses');
if (currentIndex < 0 || responsesIndex < 0 || responsesIndex <= currentIndex + 1) return;
endpointCandidates.splice(responsesIndex, 1);
endpointCandidates.splice(currentIndex + 1, 0, 'responses');
}
export function isEndpointDowngradeError(status: number, upstreamErrorText?: string | null): boolean {
if (status < 400) return false;
const text = (upstreamErrorText || '').toLowerCase();
if (status === 404 || status === 405 || status === 415 || status === 501) return true;
if (!text) return false;
let parsedCode = '';
let parsedType = '';
let parsedMessage = '';
try {
const parsed = JSON.parse(upstreamErrorText || '{}') as Record<string, unknown>;
const error = (parsed.error && typeof parsed.error === 'object')
? parsed.error as Record<string, unknown>
: parsed;
parsedCode = asTrimmedString(error.code).toLowerCase();
parsedType = asTrimmedString(error.type).toLowerCase();
parsedMessage = asTrimmedString(error.message).toLowerCase();
} catch {
parsedCode = '';
parsedType = '';
parsedMessage = '';
}
return (
isEndpointDispatchDeniedError(status, upstreamErrorText)
||
text.includes('convert_request_failed')
|| text.includes('not found')
|| text.includes('unknown endpoint')
|| text.includes('unsupported endpoint')
|| text.includes('unsupported path')
|| text.includes('unrecognized request url')
|| text.includes('no route matched')
|| text.includes('does not exist')
|| text.includes('openai_error')
|| text.includes('upstream_error')
|| text.includes('bad_response_status_code')
|| text.includes('unsupported media type')
|| text.includes("only 'application/json' is allowed")
|| text.includes('only "application/json" is allowed')
|| (status === 400 && text.includes('unsupported'))
|| text.includes('not implemented')
|| text.includes('api not implemented')
|| text.includes('unsupported legacy protocol')
|| parsedCode === 'convert_request_failed'
|| parsedCode === 'not_found'
|| parsedCode === 'endpoint_not_found'
|| parsedCode === 'unknown_endpoint'
|| parsedCode === 'unsupported_endpoint'
|| parsedCode === 'bad_response_status_code'
|| parsedCode === 'openai_error'
|| parsedCode === 'upstream_error'
|| parsedType === 'not_found_error'
|| parsedType === 'invalid_request_error'
|| parsedType === 'unsupported_endpoint'
|| parsedType === 'unsupported_path'
|| parsedType === 'bad_response_status_code'
|| parsedType === 'openai_error'
|| parsedType === 'upstream_error'
|| parsedMessage.includes('unknown endpoint')
|| parsedMessage.includes('unsupported endpoint')
|| parsedMessage.includes('unsupported path')
|| parsedMessage.includes('unrecognized request url')
|| parsedMessage.includes('no route matched')
|| parsedMessage.includes('does not exist')
|| parsedMessage.includes('bad_response_status_code')
|| parsedMessage === 'openai_error'
|| parsedMessage === 'upstream_error'
|| parsedMessage.includes('unsupported media type')
|| parsedMessage.includes("only 'application/json' is allowed")
|| parsedMessage.includes('only "application/json" is allowed')
|| (
status === 400
&& parsedCode === 'invalid_request'
&& parsedType === 'new_api_error'
&& (parsedMessage.includes('claude code cli') || text.includes('claude code cli'))
)
);
}
@@ -0,0 +1,32 @@
import { describe, expect, it, vi } from 'vitest';
import { ensureRuntimeDatabaseReady } from './runtimeDatabaseBootstrap.js';
describe('runtimeDatabaseBootstrap', () => {
it('runs sqlite runtime migrations when dialect is sqlite', async () => {
const runSqliteRuntimeMigrations = vi.fn(async () => {});
const ensureExternalRuntimeSchema = vi.fn(async () => {});
await ensureRuntimeDatabaseReady({
dialect: 'sqlite',
runSqliteRuntimeMigrations,
ensureExternalRuntimeSchema,
});
expect(runSqliteRuntimeMigrations).toHaveBeenCalledTimes(1);
expect(ensureExternalRuntimeSchema).not.toHaveBeenCalled();
});
it.each(['postgres', 'mysql'] as const)('bootstraps external schema when dialect is %s', async (dialect) => {
const runSqliteRuntimeMigrations = vi.fn(async () => {});
const ensureExternalRuntimeSchema = vi.fn(async () => {});
await ensureRuntimeDatabaseReady({
dialect,
runSqliteRuntimeMigrations,
ensureExternalRuntimeSchema,
});
expect(ensureExternalRuntimeSchema).toHaveBeenCalledTimes(1);
expect(runSqliteRuntimeMigrations).not.toHaveBeenCalled();
});
});
+51
View File
@@ -0,0 +1,51 @@
import {
bootstrapRuntimeDatabaseSchema,
type RuntimeSchemaDialect,
} from './db/runtimeSchemaBootstrap.js';
let sqliteMigrationsBootstrapped = false;
export async function runSqliteRuntimeMigrations(): Promise<void> {
const migrateModule = await import('./db/migrate.js');
if (sqliteMigrationsBootstrapped) {
migrateModule.runSqliteMigrations();
return;
}
sqliteMigrationsBootstrapped = true;
}
type EnsureRuntimeDatabaseReadyInput = {
dialect: RuntimeSchemaDialect;
connectionString?: string;
ssl?: boolean;
runSqliteRuntimeMigrations?: () => Promise<void>;
ensureExternalRuntimeSchema?: () => Promise<void>;
};
export async function ensureRuntimeDatabaseReady(input: EnsureRuntimeDatabaseReadyInput): Promise<void> {
if (input.dialect === 'sqlite') {
const runSqlite = input.runSqliteRuntimeMigrations || runSqliteRuntimeMigrations;
await runSqlite();
return;
}
const ensureExternal = input.ensureExternalRuntimeSchema || (async () => {
const connectionString = (input.connectionString || '').trim();
if (!connectionString) {
throw new Error(`DB_URL is required when DB_TYPE=${input.dialect}`);
}
await bootstrapRuntimeDatabaseSchema({
dialect: input.dialect,
connectionString,
ssl: !!input.ssl,
});
});
await ensureExternal();
}
export const __runtimeDatabaseBootstrapTestUtils = {
resetSqliteMigrationsBootstrapped() {
sqliteMigrationsBootstrapped = false;
},
};
+16 -1
View File
@@ -10,7 +10,8 @@ describe('alertRules', () => {
it('detects token expiration by status or message', () => { it('detects token expiration by status or message', () => {
expect(isTokenExpiredError({ status: 401, message: 'Unauthorized' })).toBe(true); expect(isTokenExpiredError({ status: 401, message: 'Unauthorized' })).toBe(true);
expect(isTokenExpiredError({ status: 403, message: 'Forbidden' })).toBe(true); expect(isTokenExpiredError({ status: 403, message: 'Forbidden' })).toBe(false);
expect(isTokenExpiredError({ message: 'HTTP 401: access token required' })).toBe(true);
expect(isTokenExpiredError({ message: 'jwt expired' })).toBe(true); expect(isTokenExpiredError({ message: 'jwt expired' })).toBe(true);
expect(isTokenExpiredError({ message: 'token invalid' })).toBe(true); expect(isTokenExpiredError({ message: 'token invalid' })).toBe(true);
expect(isTokenExpiredError({ message: 'invalid access token' })).toBe(true); expect(isTokenExpiredError({ message: 'invalid access token' })).toBe(true);
@@ -19,6 +20,20 @@ describe('alertRules', () => {
expect(isTokenExpiredError({ status: 500, message: 'upstream error' })).toBe(false); expect(isTokenExpiredError({ status: 500, message: 'upstream error' })).toBe(false);
}); });
it('does not treat endpoint dispatch denial as token expiration', () => {
expect(isTokenExpiredError({
status: 403,
message: 'This group does not allow /v1/messages dispatch',
})).toBe(false);
expect(isTokenExpiredError({
status: 403,
message: 'dispatch denied for /v1/responses',
})).toBe(false);
expect(isTokenExpiredError({
message: 'unauthorized',
})).toBe(false);
});
it('appends rebind hint for invalid access token messages', () => { it('appends rebind hint for invalid access token messages', () => {
expect(appendSessionTokenRebindHint('无权进行此操作,access token 无效')) expect(appendSessionTokenRebindHint('无权进行此操作,access token 无效'))
.toContain('请在中转站重新生成系统访问令牌后重新绑定账号'); .toContain('请在中转站重新生成系统访问令牌后重新绑定账号');
+18 -4
View File
@@ -6,9 +6,25 @@ export function isCloudflareChallenge(message?: string | null): boolean {
const SESSION_TOKEN_REBIND_HINT = '请在中转站重新生成系统访问令牌后重新绑定账号'; const SESSION_TOKEN_REBIND_HINT = '请在中转站重新生成系统访问令牌后重新绑定账号';
function isEndpointDispatchDeniedMessage(message?: string | null): boolean {
if (!message) return false;
const text = message.toLowerCase();
return (
/does\s+not\s+allow\s+\/v1\/[a-z0-9/_:-]+\s+dispatch/i.test(message)
|| text.includes('dispatch denied')
);
}
function containsHttpStatus(message: string | null | undefined, status: number): boolean {
if (!message) return false;
return new RegExp(`(?:^|\\b)(?:http\\s*)?${status}(?:\\b|:)`, 'i').test(message);
}
export function isTokenExpiredError(input: { status?: number; message?: string | null }): boolean { export function isTokenExpiredError(input: { status?: number; message?: string | null }): boolean {
if (input.status === 401 || input.status === 403) return true; const rawMessage = input.message || '';
const text = (input.message || '').toLowerCase(); const text = (input.message || '').toLowerCase();
if (isEndpointDispatchDeniedMessage(rawMessage)) return false;
if (input.status === 401 || containsHttpStatus(rawMessage, 401)) return true;
if (!text) return false; if (!text) return false;
// NewAPI-like sites may return this when session context is missing for an action, // NewAPI-like sites may return this when session context is missing for an action,
@@ -24,9 +40,7 @@ export function isTokenExpiredError(input: { status?: number; message?: string |
text.includes('token expired') || text.includes('token expired') ||
(tokenPhrase && (hasInvalid || hasExpired)) || (tokenPhrase && (hasInvalid || hasExpired)) ||
/invalid\s+access\s+token/.test(text) || /invalid\s+access\s+token/.test(text) ||
/access\s+token\s+is\s+invalid/.test(text) || /access\s+token\s+is\s+invalid/.test(text)
text.includes('unauthorized') ||
text.includes('forbidden')
); );
} }
@@ -172,6 +172,60 @@ describe('balanceService auto relogin', () => {
expect(reportTokenExpiredMock).toHaveBeenCalledTimes(1); expect(reportTokenExpiredMock).toHaveBeenCalledTimes(1);
}); });
it('does not report token expired for generic forbidden balance errors', async () => {
selectAllMock.mockReturnValue([
{
accounts: {
id: 12,
username: 'linuxdo_forbidden',
accessToken: 'stale-token',
status: 'active',
extraConfig: null,
},
sites: {
id: 12,
name: 'kfc',
url: 'https://kfc-api.sxxe.net',
platform: 'new-api',
},
},
]);
adapterMock.getBalance.mockRejectedValueOnce(new Error('HTTP 403: forbidden'));
const { refreshBalance } = await import('./balanceService.js');
await expect(refreshBalance(12)).rejects.toThrow('forbidden');
expect(reportTokenExpiredMock).not.toHaveBeenCalled();
});
it('does not report token expired for missing new-api-user errors', async () => {
selectAllMock.mockReturnValue([
{
accounts: {
id: 13,
username: 'linuxdo_missing_user',
accessToken: 'stale-token',
status: 'active',
extraConfig: null,
},
sites: {
id: 13,
name: 'kfc',
url: 'https://kfc-api.sxxe.net',
platform: 'new-api',
},
},
]);
adapterMock.getBalance.mockRejectedValueOnce(new Error('HTTP 400: new-api-user required'));
const { refreshBalance } = await import('./balanceService.js');
await expect(refreshBalance(13)).rejects.toThrow('new-api-user');
expect(reportTokenExpiredMock).not.toHaveBeenCalled();
});
it('proactively refreshes sub2api token when managed refresh token is near expiry', async () => { it('proactively refreshes sub2api token when managed refresh token is near expiry', async () => {
selectAllMock.mockReturnValue([ selectAllMock.mockReturnValue([
{ {
+1 -9
View File
@@ -43,15 +43,7 @@ function shouldAttemptAutoRelogin(message?: string | null): boolean {
function shouldReportExpired(message?: string | null): boolean { function shouldReportExpired(message?: string | null): boolean {
if (!message) return false; if (!message) return false;
if (isTokenExpiredError({ message })) return true; return isTokenExpiredError({ message });
const text = message.toLowerCase();
return (
text.includes('access token') ||
text.includes('new-api-user') ||
text.includes('unauthorized') ||
text.includes('forbidden')
);
} }
function isUnsupportedCheckinRuntimeHealth(health: ReturnType<typeof extractRuntimeHealth>): boolean { function isUnsupportedCheckinRuntimeHealth(health: ReturnType<typeof extractRuntimeHealth>): boolean {
+25 -228
View File
@@ -1,14 +1,13 @@
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import mysql from 'mysql2/promise';
import pg from 'pg';
import { mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { db, schema } from '../db/index.js'; import { db, schema } from '../db/index.js';
import { ensureSiteSchemaCompatibility, type SiteSchemaInspector } from '../db/siteSchemaCompatibility.js'; import {
import { ensureRouteGroupingSchemaCompatibility } from '../db/routeGroupingSchemaCompatibility.js'; createRuntimeSchemaClient,
import { ensureProxyFileSchemaCompatibility } from '../db/proxyFileSchemaCompatibility.js'; ensureRuntimeDatabaseSchema,
type RuntimeSchemaClient,
type RuntimeSchemaDialect,
} from '../db/runtimeSchemaBootstrap.js';
export type MigrationDialect = 'sqlite' | 'mysql' | 'postgres'; export type MigrationDialect = RuntimeSchemaDialect;
export interface DatabaseMigrationInput { export interface DatabaseMigrationInput {
dialect?: unknown; dialect?: unknown;
@@ -67,15 +66,7 @@ export interface DatabaseMigrationSummary {
}; };
} }
interface SqlClient { type SqlClient = RuntimeSchemaClient;
dialect: MigrationDialect;
begin(): Promise<void>;
commit(): Promise<void>;
rollback(): Promise<void>;
execute(sqlText: string, params?: unknown[]): Promise<unknown>;
queryScalar(sqlText: string, params?: unknown[]): Promise<number>;
close(): Promise<void>;
}
interface InsertStatement { interface InsertStatement {
table: string; table: string;
@@ -230,216 +221,8 @@ async function toBackupSnapshot(): Promise<BackupSnapshot> {
}; };
} }
async function createPostgresClient(connectionString: string, ssl: boolean): Promise<SqlClient> {
const clientOptions: pg.ClientConfig = { connectionString };
if (ssl) {
clientOptions.ssl = { rejectUnauthorized: false };
}
const client = new pg.Client(clientOptions);
await client.connect();
return {
dialect: 'postgres',
begin: async () => { await client.query('BEGIN'); },
commit: async () => { await client.query('COMMIT'); },
rollback: async () => { await client.query('ROLLBACK'); },
execute: async (sqlText, params = []) => client.query(sqlText, params),
queryScalar: async (sqlText, params = []) => {
const result = await client.query(sqlText, params);
const row = result.rows[0] as Record<string, unknown> | undefined;
if (!row) return 0;
return Number(Object.values(row)[0]) || 0;
},
close: async () => { await client.end(); },
};
}
async function createMySqlClient(connectionString: string, ssl: boolean): Promise<SqlClient> {
const connectionOptions: mysql.ConnectionOptions = { uri: connectionString };
if (ssl) {
connectionOptions.ssl = { rejectUnauthorized: false };
}
const connection = await mysql.createConnection(connectionOptions);
return {
dialect: 'mysql',
begin: async () => { await connection.beginTransaction(); },
commit: async () => { await connection.commit(); },
rollback: async () => { await connection.rollback(); },
execute: async (sqlText, params = []) => connection.execute(sqlText, params as any[]),
queryScalar: async (sqlText, params = []) => {
const [rows] = await connection.query(sqlText, params as any[]);
if (!Array.isArray(rows) || rows.length === 0) return 0;
const row = rows[0] as Record<string, unknown>;
return Number(Object.values(row)[0]) || 0;
},
close: async () => { await connection.end(); },
};
}
async function createSqliteClient(connectionString: string): Promise<SqlClient> {
const filePath = resolve(connectionString);
mkdirSync(dirname(filePath), { recursive: true });
const sqlite = new Database(filePath);
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('foreign_keys = ON');
return {
dialect: 'sqlite',
begin: async () => { sqlite.exec('BEGIN'); },
commit: async () => { sqlite.exec('COMMIT'); },
rollback: async () => { sqlite.exec('ROLLBACK'); },
execute: async (sqlText, params = []) => {
const lowered = sqlText.trim().toLowerCase();
const stmt = sqlite.prepare(sqlText);
if (lowered.startsWith('select')) return await stmt.all(...params);
return await stmt.run(...params);
},
queryScalar: async (sqlText, params = []) => {
const row = await sqlite.prepare(sqlText).get(...params) as Record<string, unknown> | undefined;
if (!row) return 0;
return Number(Object.values(row)[0]) || 0;
},
close: async () => { sqlite.close(); },
};
}
async function createClient(input: NormalizedDatabaseMigrationInput): Promise<SqlClient> { async function createClient(input: NormalizedDatabaseMigrationInput): Promise<SqlClient> {
if (input.dialect === 'postgres') return createPostgresClient(input.connectionString, input.ssl); return createRuntimeSchemaClient(input);
if (input.dialect === 'mysql') return createMySqlClient(input.connectionString, input.ssl);
return createSqliteClient(input.connectionString);
}
function validateIdentifier(identifier: string): string {
if (!/^[a-z_][a-z0-9_]*$/i.test(identifier)) {
throw new Error(`Invalid SQL identifier: ${identifier}`);
}
return identifier;
}
function createSiteSchemaInspector(client: SqlClient): SiteSchemaInspector {
if (client.dialect === 'sqlite') {
return {
dialect: 'sqlite',
tableExists: async (table) => {
const normalizedTable = validateIdentifier(table);
return (await client.queryScalar(
`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = '${normalizedTable}'`,
)) > 0;
},
columnExists: async (table, column) => {
const normalizedTable = validateIdentifier(table);
const normalizedColumn = validateIdentifier(column);
return (await client.queryScalar(
`SELECT COUNT(*) FROM pragma_table_info('${normalizedTable}') WHERE name = '${normalizedColumn}'`,
)) > 0;
},
execute: async (sqlText) => {
await client.execute(sqlText);
},
};
}
if (client.dialect === 'mysql') {
return {
dialect: 'mysql',
tableExists: async (table) => {
return (await client.queryScalar(
'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ?',
[table],
)) > 0;
},
columnExists: async (table, column) => {
return (await client.queryScalar(
'SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?',
[table, column],
)) > 0;
},
execute: async (sqlText) => {
await client.execute(sqlText);
},
};
}
return {
dialect: 'postgres',
tableExists: async (table) => {
return (await client.queryScalar(
'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = $1',
[table],
)) > 0;
},
columnExists: async (table, column) => {
return (await client.queryScalar(
'SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 AND column_name = $2',
[table, column],
)) > 0;
},
execute: async (sqlText) => {
await client.execute(sqlText);
},
};
}
async function ensureSchema(client: SqlClient): Promise<void> {
const statements = client.dialect === 'postgres'
? [
`CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "external_checkin_url" TEXT, "platform" TEXT NOT NULL, "proxy_url" TEXT, "use_system_proxy" BOOLEAN DEFAULT FALSE, "status" TEXT DEFAULT 'active', "is_pinned" BOOLEAN DEFAULT FALSE, "sort_order" INTEGER DEFAULT 0, "global_weight" DOUBLE PRECISION DEFAULT 1, "api_key" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "site_id" INTEGER NOT NULL REFERENCES "sites"("id") ON DELETE CASCADE, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" DOUBLE PRECISION DEFAULT 0, "balance_used" DOUBLE PRECISION DEFAULT 0, "quota" DOUBLE PRECISION DEFAULT 0, "unit_cost" DOUBLE PRECISION, "value_score" DOUBLE PRECISION DEFAULT 0, "status" TEXT DEFAULT 'active', "is_pinned" BOOLEAN DEFAULT FALSE, "sort_order" INTEGER DEFAULT 0, "checkin_enabled" BOOLEAN DEFAULT TRUE, "last_checkin_at" TEXT, "last_balance_refresh" TEXT, "extra_config" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "token_group" TEXT, "source" TEXT DEFAULT 'manual', "enabled" BOOLEAN DEFAULT TRUE, "is_default" BOOLEAN DEFAULT FALSE, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "token_id" INTEGER NOT NULL REFERENCES "account_tokens"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "decision_snapshot" TEXT, "decision_refreshed_at" TEXT, "enabled" BOOLEAN DEFAULT TRUE, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" BOOLEAN DEFAULT TRUE, "manual_override" BOOLEAN DEFAULT FALSE, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" DOUBLE PRECISION DEFAULT 0, "last_used_at" TEXT, "last_fail_at" TEXT, "cooldown_until" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" DOUBLE PRECISION, "billing_details" TEXT, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "public_id" TEXT NOT NULL UNIQUE, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" TEXT, "upstream_response_meta" TEXT, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "public_id" TEXT NOT NULL UNIQUE, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT, "updated_at" TEXT, "deleted_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "name" TEXT NOT NULL, "key" TEXT NOT NULL UNIQUE, "description" TEXT, "enabled" BOOLEAN DEFAULT TRUE, "expires_at" TEXT, "max_cost" DOUBLE PRECISION, "used_cost" DOUBLE PRECISION DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" TEXT, "allowed_route_ids" TEXT, "site_weight_multipliers" TEXT, "last_used_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT DEFAULT 'info', "read" BOOLEAN DEFAULT FALSE, "related_id" INTEGER, "related_type" TEXT, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "settings" ("key" TEXT PRIMARY KEY, "value" TEXT)`,
]
: client.dialect === 'mysql'
? [
`CREATE TABLE IF NOT EXISTS \`sites\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`name\` TEXT NOT NULL, \`url\` TEXT NOT NULL, \`external_checkin_url\` TEXT NULL, \`platform\` VARCHAR(64) NOT NULL, \`proxy_url\` TEXT NULL, \`use_system_proxy\` BOOLEAN DEFAULT FALSE, \`status\` VARCHAR(32) DEFAULT 'active', \`is_pinned\` BOOLEAN DEFAULT FALSE, \`sort_order\` INT DEFAULT 0, \`global_weight\` DOUBLE DEFAULT 1, \`api_key\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`accounts\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`site_id\` INT NOT NULL, \`username\` TEXT NULL, \`access_token\` TEXT NOT NULL, \`api_token\` TEXT NULL, \`balance\` DOUBLE DEFAULT 0, \`balance_used\` DOUBLE DEFAULT 0, \`quota\` DOUBLE DEFAULT 0, \`unit_cost\` DOUBLE NULL, \`value_score\` DOUBLE DEFAULT 0, \`status\` VARCHAR(32) DEFAULT 'active', \`is_pinned\` BOOLEAN DEFAULT FALSE, \`sort_order\` INT DEFAULT 0, \`checkin_enabled\` BOOLEAN DEFAULT TRUE, \`last_checkin_at\` TEXT NULL, \`last_balance_refresh\` TEXT NULL, \`extra_config\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, CONSTRAINT \`accounts_site_fk\` FOREIGN KEY (\`site_id\`) REFERENCES \`sites\`(\`id\`) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS \`account_tokens\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`name\` TEXT NOT NULL, \`token\` TEXT NOT NULL, \`token_group\` TEXT NULL, \`source\` VARCHAR(32) DEFAULT 'manual', \`enabled\` BOOLEAN DEFAULT TRUE, \`is_default\` BOOLEAN DEFAULT FALSE, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, CONSTRAINT \`account_tokens_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS \`checkin_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`status\` VARCHAR(32) NOT NULL, \`message\` TEXT NULL, \`reward\` TEXT NULL, \`created_at\` TEXT NULL, CONSTRAINT \`checkin_logs_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS \`model_availability\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`model_name\` TEXT NOT NULL, \`available\` BOOLEAN NULL, \`latency_ms\` INT NULL, \`checked_at\` TEXT NULL, CONSTRAINT \`model_availability_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS \`token_model_availability\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`token_id\` INT NOT NULL, \`model_name\` TEXT NOT NULL, \`available\` BOOLEAN NULL, \`latency_ms\` INT NULL, \`checked_at\` TEXT NULL, CONSTRAINT \`token_model_availability_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS \`token_routes\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`model_pattern\` TEXT NOT NULL, \`display_name\` TEXT NULL, \`display_icon\` TEXT NULL, \`model_mapping\` TEXT NULL, \`decision_snapshot\` TEXT NULL, \`decision_refreshed_at\` TEXT NULL, \`enabled\` BOOLEAN DEFAULT TRUE, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`route_channels\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NOT NULL, \`account_id\` INT NOT NULL, \`token_id\` INT NULL, \`source_model\` TEXT NULL, \`priority\` INT DEFAULT 0, \`weight\` INT DEFAULT 10, \`enabled\` BOOLEAN DEFAULT TRUE, \`manual_override\` BOOLEAN DEFAULT FALSE, \`success_count\` INT DEFAULT 0, \`fail_count\` INT DEFAULT 0, \`total_latency_ms\` INT DEFAULT 0, \`total_cost\` DOUBLE DEFAULT 0, \`last_used_at\` TEXT NULL, \`last_fail_at\` TEXT NULL, \`cooldown_until\` TEXT NULL, CONSTRAINT \`route_channels_route_fk\` FOREIGN KEY (\`route_id\`) REFERENCES \`token_routes\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE SET NULL)`,
`CREATE TABLE IF NOT EXISTS \`proxy_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NULL, \`channel_id\` INT NULL, \`account_id\` INT NULL, \`model_requested\` TEXT NULL, \`model_actual\` TEXT NULL, \`status\` VARCHAR(32) NULL, \`http_status\` INT NULL, \`latency_ms\` INT NULL, \`prompt_tokens\` INT NULL, \`completion_tokens\` INT NULL, \`total_tokens\` INT NULL, \`estimated_cost\` DOUBLE NULL, \`billing_details\` TEXT NULL, \`error_message\` TEXT NULL, \`retry_count\` INT DEFAULT 0, \`created_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`proxy_video_tasks\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`public_id\` VARCHAR(191) NOT NULL UNIQUE, \`upstream_video_id\` TEXT NOT NULL, \`site_url\` TEXT NOT NULL, \`token_value\` TEXT NOT NULL, \`requested_model\` TEXT NULL, \`actual_model\` TEXT NULL, \`channel_id\` INT NULL, \`account_id\` INT NULL, \`status_snapshot\` TEXT NULL, \`upstream_response_meta\` TEXT NULL, \`last_upstream_status\` INT NULL, \`last_polled_at\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`proxy_files\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`public_id\` VARCHAR(191) NOT NULL UNIQUE, \`owner_type\` VARCHAR(64) NOT NULL, \`owner_id\` VARCHAR(191) NOT NULL, \`filename\` TEXT NOT NULL, \`mime_type\` VARCHAR(191) NOT NULL, \`purpose\` TEXT NULL, \`byte_size\` INT NOT NULL, \`sha256\` VARCHAR(191) NOT NULL, \`content_base64\` LONGTEXT NOT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, \`deleted_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`downstream_api_keys\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`name\` TEXT NOT NULL, \`key\` VARCHAR(191) NOT NULL UNIQUE, \`description\` TEXT NULL, \`enabled\` BOOLEAN DEFAULT TRUE, \`expires_at\` TEXT NULL, \`max_cost\` DOUBLE NULL, \`used_cost\` DOUBLE DEFAULT 0, \`max_requests\` INT NULL, \`used_requests\` INT DEFAULT 0, \`supported_models\` TEXT NULL, \`allowed_route_ids\` TEXT NULL, \`site_weight_multipliers\` TEXT NULL, \`last_used_at\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`events\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`type\` VARCHAR(32) NOT NULL, \`title\` TEXT NOT NULL, \`message\` TEXT NULL, \`level\` VARCHAR(16) DEFAULT 'info', \`read\` BOOLEAN DEFAULT FALSE, \`related_id\` INT NULL, \`related_type\` VARCHAR(32) NULL, \`created_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`settings\` (\`key\` VARCHAR(191) PRIMARY KEY, \`value\` TEXT NULL)`,
]
: [
`CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "external_checkin_url" TEXT, "platform" TEXT NOT NULL, "proxy_url" TEXT, "use_system_proxy" INTEGER DEFAULT 0, "status" TEXT DEFAULT 'active', "is_pinned" INTEGER DEFAULT 0, "sort_order" INTEGER DEFAULT 0, "global_weight" REAL DEFAULT 1, "api_key" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "site_id" INTEGER NOT NULL REFERENCES "sites"("id") ON DELETE CASCADE, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" REAL DEFAULT 0, "balance_used" REAL DEFAULT 0, "quota" REAL DEFAULT 0, "unit_cost" REAL, "value_score" REAL DEFAULT 0, "status" TEXT DEFAULT 'active', "is_pinned" INTEGER DEFAULT 0, "sort_order" INTEGER DEFAULT 0, "checkin_enabled" INTEGER DEFAULT 1, "last_checkin_at" TEXT, "last_balance_refresh" TEXT, "extra_config" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "token_group" TEXT, "source" TEXT DEFAULT 'manual', "enabled" INTEGER DEFAULT 1, "is_default" INTEGER DEFAULT 0, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" INTEGER, "latency_ms" INTEGER, "checked_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "token_id" INTEGER NOT NULL REFERENCES "account_tokens"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" INTEGER, "latency_ms" INTEGER, "checked_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "decision_snapshot" TEXT, "decision_refreshed_at" TEXT, "enabled" INTEGER DEFAULT 1, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" INTEGER DEFAULT 1, "manual_override" INTEGER DEFAULT 0, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" REAL DEFAULT 0, "last_used_at" TEXT, "last_fail_at" TEXT, "cooldown_until" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" REAL, "billing_details" TEXT, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL UNIQUE, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" TEXT, "upstream_response_meta" TEXT, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL UNIQUE, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT, "updated_at" TEXT, "deleted_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" TEXT NOT NULL, "key" TEXT NOT NULL UNIQUE, "description" TEXT, "enabled" INTEGER DEFAULT 1, "expires_at" TEXT, "max_cost" REAL, "used_cost" REAL DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" TEXT, "allowed_route_ids" TEXT, "site_weight_multipliers" TEXT, "last_used_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT DEFAULT 'info', "read" INTEGER DEFAULT 0, "related_id" INTEGER, "related_type" TEXT, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "settings" ("key" TEXT PRIMARY KEY, "value" TEXT)`,
];
for (const sqlText of statements) {
await client.execute(sqlText);
}
await ensureSiteSchemaCompatibility(createSiteSchemaInspector(client));
await ensureRouteGroupingSchemaCompatibility(createSiteSchemaInspector(client));
await ensureProxyFileSchemaCompatibility(createSiteSchemaInspector(client));
} }
async function ensureTargetState(client: SqlClient, overwrite: boolean): Promise<void> { async function ensureTargetState(client: SqlClient, overwrite: boolean): Promise<void> {
@@ -755,13 +538,27 @@ async function syncPostgresSequences(client: SqlClient): Promise<void> {
} }
} }
export async function bootstrapRuntimeDatabaseSchema(input: Pick<NormalizedDatabaseMigrationInput, 'dialect' | 'connectionString' | 'ssl'>): Promise<void> {
const client = await createClient({
dialect: input.dialect,
connectionString: input.connectionString,
overwrite: true,
ssl: input.ssl,
});
try {
await ensureRuntimeDatabaseSchema(client);
} finally {
await client.close();
}
}
export async function migrateCurrentDatabase(input: DatabaseMigrationInput): Promise<DatabaseMigrationSummary> { export async function migrateCurrentDatabase(input: DatabaseMigrationInput): Promise<DatabaseMigrationSummary> {
const normalized = normalizeMigrationInput(input); const normalized = normalizeMigrationInput(input);
const snapshot = await toBackupSnapshot(); const snapshot = await toBackupSnapshot();
const client = await createClient(normalized); const client = await createClient(normalized);
try { try {
await ensureSchema(client); await ensureRuntimeDatabaseSchema(client);
await ensureTargetState(client, normalized.overwrite); await ensureTargetState(client, normalized.overwrite);
await client.begin(); await client.begin();
@@ -819,6 +616,6 @@ export async function testDatabaseConnection(input: DatabaseMigrationInput): Pro
} }
export const __databaseMigrationServiceTestUtils = { export const __databaseMigrationServiceTestUtils = {
ensureSchema, ensureSchema: ensureRuntimeDatabaseSchema,
buildStatements, buildStatements,
}; };
@@ -100,4 +100,72 @@ describe('TokenRouter runtime cache', () => {
const refreshedSelection = await router.selectChannel('gpt-4o-mini'); const refreshedSelection = await router.selectChannel('gpt-4o-mini');
expect(refreshedSelection).toBeNull(); expect(refreshedSelection).toBeNull();
}); });
it('uses fibonacci-style cooldown across repeated failures', async () => {
const site = await db.insert(schema.sites).values({
name: 'cooldown-site',
url: 'https://cooldown-site.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'cooldown-user',
accessToken: 'cooldown-access-token',
apiToken: 'cooldown-api-token',
status: 'active',
}).returning().get();
const token = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'cooldown-token',
token: 'sk-cooldown-token',
enabled: true,
isDefault: true,
}).returning().get();
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-4o-mini',
enabled: true,
}).returning().get();
const channel = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: account.id,
tokenId: token.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const router = new TokenRouter();
const firstStartedAt = Date.now();
await router.recordFailure(channel.id);
const firstRecord = await db.select().from(schema.routeChannels)
.where(eq(schema.routeChannels.id, channel.id))
.get();
const firstCooldownMs = Date.parse(String(firstRecord?.cooldownUntil || '')) - firstStartedAt;
expect(firstCooldownMs).toBeGreaterThanOrEqual(10_000);
expect(firstCooldownMs).toBeLessThanOrEqual(20_000);
const secondStartedAt = Date.now();
await router.recordFailure(channel.id);
const secondRecord = await db.select().from(schema.routeChannels)
.where(eq(schema.routeChannels.id, channel.id))
.get();
const secondCooldownMs = Date.parse(String(secondRecord?.cooldownUntil || '')) - secondStartedAt;
expect(secondCooldownMs).toBeGreaterThanOrEqual(10_000);
expect(secondCooldownMs).toBeLessThanOrEqual(20_000);
const thirdStartedAt = Date.now();
await router.recordFailure(channel.id);
const thirdRecord = await db.select().from(schema.routeChannels)
.where(eq(schema.routeChannels.id, channel.id))
.get();
const thirdCooldownMs = Date.parse(String(thirdRecord?.cooldownUntil || '')) - thirdStartedAt;
expect(thirdCooldownMs).toBeGreaterThanOrEqual(25_000);
expect(thirdCooldownMs).toBeLessThanOrEqual(35_000);
});
}); });
+21 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { filterRecentlyFailedCandidates } from './tokenRouter.js'; import { filterRecentlyFailedCandidates, isChannelRecentlyFailed } from './tokenRouter.js';
type Candidate = { type Candidate = {
channel: { channel: {
@@ -10,6 +10,26 @@ type Candidate = {
}; };
describe('filterRecentlyFailedCandidates', () => { describe('filterRecentlyFailedCandidates', () => {
it('uses a short default recent-failure window', () => {
const nowMs = Date.now();
expect(isChannelRecentlyFailed({
failCount: 1,
lastFailAt: new Date(nowMs - 20 * 1000).toISOString(),
}, nowMs)).toBe(false);
});
it('expands the avoidance window with fibonacci-style backoff', () => {
const nowMs = Date.now();
expect(isChannelRecentlyFailed({
failCount: 4,
lastFailAt: new Date(nowMs - 40 * 1000).toISOString(),
}, nowMs)).toBe(true);
expect(isChannelRecentlyFailed({
failCount: 4,
lastFailAt: new Date(nowMs - 50 * 1000).toISOString(),
}, nowMs)).toBe(false);
});
it('prefers healthy channels when at least one healthy channel exists', () => { it('prefers healthy channels when at least one healthy channel exists', () => {
const nowMs = Date.now(); const nowMs = Date.now();
const candidates: Candidate[] = [ const candidates: Candidate[] = [
+24 -10
View File
@@ -32,9 +32,26 @@ type FailureAwareChannel = {
lastFailAt?: string | null; lastFailAt?: string | null;
}; };
const RECENT_FAILURE_AVOID_SEC = 10 * 60; const FAILURE_BACKOFF_BASE_SEC = 15;
const MIN_EFFECTIVE_UNIT_COST = 1e-6; const MIN_EFFECTIVE_UNIT_COST = 1e-6;
function fibonacciNumber(index: number): number {
if (index <= 2) return 1;
let prev = 1;
let current = 1;
for (let i = 3; i <= index; i += 1) {
const next = prev + current;
prev = current;
current = next;
}
return current;
}
function resolveFailureBackoffSec(failCount?: number | null): number {
const normalizedFailCount = Math.max(1, Math.trunc(failCount ?? 0));
return FAILURE_BACKOFF_BASE_SEC * fibonacciNumber(normalizedFailCount);
}
type RouteRow = typeof schema.tokenRoutes.$inferSelect; type RouteRow = typeof schema.tokenRoutes.$inferSelect;
type ChannelRow = typeof schema.routeChannels.$inferSelect; type ChannelRow = typeof schema.routeChannels.$inferSelect;
@@ -133,7 +150,7 @@ function isSiteDisabled(status?: string | null): boolean {
export function isChannelRecentlyFailed( export function isChannelRecentlyFailed(
channel: FailureAwareChannel, channel: FailureAwareChannel,
nowMs = Date.now(), nowMs = Date.now(),
avoidSec = RECENT_FAILURE_AVOID_SEC, avoidSec = resolveFailureBackoffSec(channel.failCount),
): boolean { ): boolean {
if (avoidSec <= 0) return false; if (avoidSec <= 0) return false;
if ((channel.failCount ?? 0) <= 0) return false; if ((channel.failCount ?? 0) <= 0) return false;
@@ -148,7 +165,7 @@ export function isChannelRecentlyFailed(
export function filterRecentlyFailedCandidates<T extends { channel: FailureAwareChannel }>( export function filterRecentlyFailedCandidates<T extends { channel: FailureAwareChannel }>(
candidates: T[], candidates: T[],
nowMs = Date.now(), nowMs = Date.now(),
avoidSec = RECENT_FAILURE_AVOID_SEC, avoidSec?: number,
): T[] { ): T[] {
if (candidates.length <= 1) return candidates; if (candidates.length <= 1) return candidates;
if (avoidSec <= 0) return candidates; if (avoidSec <= 0) return candidates;
@@ -424,7 +441,6 @@ export class TokenRouter {
const candidates = filterRecentlyFailedCandidates( const candidates = filterRecentlyFailedCandidates(
layers.get(priority)!, layers.get(priority)!,
nowMs, nowMs,
RECENT_FAILURE_AVOID_SEC,
); );
const selected = this.weightedRandomSelect( const selected = this.weightedRandomSelect(
candidates, candidates,
@@ -497,7 +513,6 @@ export class TokenRouter {
const candidates = filterRecentlyFailedCandidates( const candidates = filterRecentlyFailedCandidates(
layers.get(priority)!, layers.get(priority)!,
nowMs, nowMs,
RECENT_FAILURE_AVOID_SEC,
); );
const selected = this.weightedRandomSelect( const selected = this.weightedRandomSelect(
candidates, candidates,
@@ -631,7 +646,7 @@ export class TokenRouter {
nowIso, nowIso,
}); });
const recentlyFailed = isChannelRecentlyFailed(row.channel, nowMs, RECENT_FAILURE_AVOID_SEC); const recentlyFailed = isChannelRecentlyFailed(row.channel, nowMs);
const eligible = reasonParts.length === 0; const eligible = reasonParts.length === 0;
const candidate: RouteDecisionCandidate = { const candidate: RouteDecisionCandidate = {
channelId: row.channel.id, channelId: row.channel.id,
@@ -678,14 +693,14 @@ export class TokenRouter {
const rawLayer = availableByPriority.get(priority) ?? []; const rawLayer = availableByPriority.get(priority) ?? [];
if (rawLayer.length === 0) continue; if (rawLayer.length === 0) continue;
const filteredLayer = filterRecentlyFailedCandidates(rawLayer, nowMs, RECENT_FAILURE_AVOID_SEC); const filteredLayer = filterRecentlyFailedCandidates(rawLayer, nowMs);
const avoided = rawLayer.filter((row) => !filteredLayer.some((item) => item.channel.id === row.channel.id)); const avoided = rawLayer.filter((row) => !filteredLayer.some((item) => item.channel.id === row.channel.id));
if (avoided.length > 0) { if (avoided.length > 0) {
for (const row of avoided) { for (const row of avoided) {
const target = candidateMap.get(row.channel.id); const target = candidateMap.get(row.channel.id);
if (!target) continue; if (!target) continue;
target.avoidedByRecentFailure = true; target.avoidedByRecentFailure = true;
target.reason = `最近失败,优先避让(${RECENT_FAILURE_AVOID_SEC / 60} 分钟窗口)`; target.reason = `最近失败,优先避让(${resolveFailureBackoffSec(row.channel.failCount)} 秒窗口)`;
} }
} }
@@ -833,8 +848,7 @@ export class TokenRouter {
const ch = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get(); const ch = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
if (!ch) return; if (!ch) return;
const failCount = (ch.failCount ?? 0) + 1; const failCount = (ch.failCount ?? 0) + 1;
// Exponential backoff cooldown: 30s, 60s, 120s, 240s, max 5min const cooldownSec = resolveFailureBackoffSec(failCount);
const cooldownSec = Math.min(30 * Math.pow(2, failCount - 1), 300);
const cooldownUntil = new Date(Date.now() + cooldownSec * 1000).toISOString(); const cooldownUntil = new Date(Date.now() + cooldownSec * 1000).toISOString();
const nowIso = new Date().toISOString(); const nowIso = new Date().toISOString();
await db.update(schema.routeChannels).set({ await db.update(schema.routeChannels).set({
@@ -0,0 +1,9 @@
data: {"id":"chatcmpl-think","model":"upstream-gpt","choices":[{"delta":{"role":"assistant"},"finish_reason":null}]}
data: {"id":"chatcmpl-think","model":"upstream-gpt","choices":[{"delta":{"content":"<think>plan quietly</think>"},"finish_reason":null}]}
data: {"id":"chatcmpl-think","model":"upstream-gpt","choices":[{"delta":{"content":"visible answer"},"finish_reason":null}]}
data: {"id":"chatcmpl-think","model":"upstream-gpt","choices":[{"delta":{},"finish_reason":"stop"}]}
data: [DONE]
@@ -0,0 +1,3 @@
data: {"id":"chatcmpl-eof","model":"upstream-gpt","choices":[{"delta":{"role":"assistant"},"finish_reason":null}]}
data: {"id":"chatcmpl-eof","model":"upstream-gpt","choices":[{"delta":{"content":"tail before eof"},"finish_reason":null}]}
@@ -0,0 +1,16 @@
event: message_start
data: {"type":"message_start","message":{"id":"msg_dup_1","model":"upstream-gpt"}}
event: content_block_delta
data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"I'm Claude, an AI assistant made by Anthropic."}}
event: content_block_delta
data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"I'm Claude, an AI assistant made by Anthropic."}}
event: content_block_delta
data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"I'm Claude, an AI assistant made by Anthropic."}}
event: message_stop
data: {"type":"message_stop"}
data: [DONE]
@@ -0,0 +1,10 @@
event: response.created
data: {"type":"response.created","response":{"id":"resp_fail_1","model":"upstream-gpt","created_at":1706000000,"status":"in_progress","output":[]}}
event: response.output_item.added
data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_0","type":"message","role":"assistant","status":"in_progress","content":[]}}
event: response.failed
data: {"type":"response.failed","response":{"id":"resp_fail_1","model":"upstream-gpt","status":"failed","error":{"message":"upstream stream failed"}}}
data: [DONE]
@@ -0,0 +1,13 @@
event: response.created
data: {"type":"response.created","response":{"id":"resp_sparse_1","model":"upstream-gpt","created_at":1706000000,"status":"in_progress","output":[]}}
event: response.output_item.added
data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_0","type":"message","role":"assistant","status":"in_progress","content":[]}}
event: response.output_text.delta
data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_0","delta":"hello world"}
event: response.completed
data: {"type":"response.completed","response":{"id":"resp_sparse_1","model":"upstream-gpt","status":"completed","usage":{"input_tokens":2,"output_tokens":4,"total_tokens":6}}}
data: [DONE]
@@ -54,7 +54,12 @@ describe('anthropicMessagesInbound', () => {
}); });
expect(result.error).toBeUndefined(); expect(result.error).toBeUndefined();
expect(result.value?.claudeOriginalBody).toMatchObject({ expect(result.value).toMatchObject({
protocol: 'anthropic/messages',
model: 'claude-opus-4-6',
stream: false,
});
expect(result.value?.parsed.claudeOriginalBody).toMatchObject({
thinking: { type: 'adaptive' }, thinking: { type: 'adaptive' },
output_config: { effort: 'high', preserve: true }, output_config: { effort: 'high', preserve: true },
tool_choice: { type: 'tool', name: 'lookup' }, tool_choice: { type: 'tool', name: 'lookup' },
@@ -89,7 +94,7 @@ describe('anthropicMessagesInbound', () => {
}); });
expect(result.error).toBeUndefined(); expect(result.error).toBeUndefined();
expect(result.value?.claudeOriginalBody).toEqual({ expect(result.value?.parsed.claudeOriginalBody).toEqual({
model: 'claude-opus-4-6', model: 'claude-opus-4-6',
max_tokens: 512, max_tokens: 512,
system: [ system: [
@@ -151,6 +156,6 @@ describe('anthropicMessagesInbound', () => {
const result = anthropicMessagesInbound.parse(nativeBody); const result = anthropicMessagesInbound.parse(nativeBody);
expect(result.error).toBeUndefined(); expect(result.error).toBeUndefined();
expect(result.value?.claudeOriginalBody).toEqual(nativeBody); expect(result.value?.parsed.claudeOriginalBody).toEqual(nativeBody);
}); });
}); });
@@ -1,4 +1,5 @@
import { parseDownstreamChatRequest, type ParsedDownstreamChatRequest } from '../../shared/normalized.js'; import { parseDownstreamChatRequest, type ParsedDownstreamChatRequest } from '../../shared/normalized.js';
import { createProtocolRequestEnvelope, type ProtocolRequestEnvelope } from '../../shared/protocolModel.js';
import { validateAnthropicMessagesBody } from './conversion.js'; import { validateAnthropicMessagesBody } from './conversion.js';
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
@@ -136,7 +137,10 @@ function sanitizeAnthropicInboundBody(
} }
export const anthropicMessagesInbound = { export const anthropicMessagesInbound = {
parse(body: unknown): { value?: ParsedDownstreamChatRequest; error?: { statusCode: number; payload: unknown } } { parse(body: unknown): {
value?: ProtocolRequestEnvelope<'anthropic/messages', ParsedDownstreamChatRequest>;
error?: { statusCode: number; payload: unknown };
} {
const rawBody = isRecord(body) ? body : null; const rawBody = isRecord(body) ? body : null;
const inboundValidation = rawBody ? sanitizeAnthropicInboundBody(rawBody) : null; const inboundValidation = rawBody ? sanitizeAnthropicInboundBody(rawBody) : null;
if (inboundValidation?.error) { if (inboundValidation?.error) {
@@ -145,12 +149,25 @@ export const anthropicMessagesInbound = {
const effectiveBody = inboundValidation?.sanitizedBody ?? body; const effectiveBody = inboundValidation?.sanitizedBody ?? body;
const parsed = parseDownstreamChatRequest(effectiveBody, 'claude'); const parsed = parseDownstreamChatRequest(effectiveBody, 'claude');
if (parsed.error || !parsed.value) return parsed; if (parsed.error) {
return { error: parsed.error };
}
if (!parsed.value) {
return { error: invalidRequest('invalid messages request') };
}
if (inboundValidation?.sanitizedBody) { if (inboundValidation?.sanitizedBody) {
parsed.value.claudeOriginalBody = inboundValidation.sanitizedBody; parsed.value.claudeOriginalBody = inboundValidation.sanitizedBody;
} }
return parsed; return {
value: createProtocolRequestEnvelope({
protocol: 'anthropic/messages',
model: parsed.value.requestedModel,
stream: parsed.value.isStream,
rawBody: body,
parsed: parsed.value,
}),
};
}, },
}; };
@@ -1,4 +1,5 @@
import { type NormalizedFinalResponse, type NormalizedStreamEvent, type ParsedDownstreamChatRequest, type StreamTransformContext, type ClaudeDownstreamContext } from '../../shared/normalized.js'; import { type NormalizedFinalResponse, type NormalizedStreamEvent, type ParsedDownstreamChatRequest, type StreamTransformContext, type ClaudeDownstreamContext } from '../../shared/normalized.js';
import { createChatEndpointStrategy } from '../../shared/chatEndpointStrategy.js';
import { anthropicMessagesInbound } from './inbound.js'; import { anthropicMessagesInbound } from './inbound.js';
import { anthropicMessagesOutbound } from './outbound.js'; import { anthropicMessagesOutbound } from './outbound.js';
import { anthropicMessagesStream, consumeAnthropicSseEvent } from './stream.js'; import { anthropicMessagesStream, consumeAnthropicSseEvent } from './stream.js';
@@ -22,6 +23,7 @@ export const anthropicMessagesTransformer = {
stream: anthropicMessagesStream, stream: anthropicMessagesStream,
usage: anthropicMessagesUsage, usage: anthropicMessagesUsage,
compatibility: { compatibility: {
createEndpointStrategy: createChatEndpointStrategy,
shouldRetryNormalizedBody: shouldRetryNormalizedMessagesBody, shouldRetryNormalizedBody: shouldRetryNormalizedMessagesBody,
isMessagesRequiredError, isMessagesRequiredError,
}, },
+21 -10
View File
@@ -8,6 +8,7 @@ import type {
OpenAiChatUsageDetails, OpenAiChatUsageDetails,
} from './model.js'; } from './model.js';
import { fromTransformerMetadataRecord } from '../../shared/normalized.js'; import { fromTransformerMetadataRecord } from '../../shared/normalized.js';
import { extractInlineThinkTags } from '../../shared/thinkTagParser.js';
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value); return !!value && typeof value === 'object' && !Array.isArray(value);
@@ -98,15 +99,23 @@ function extractTextPart(value: unknown): string {
return ''; return '';
} }
function joinNonEmpty(parts: string[]): string {
return parts.map((item) => item.trim()).filter((item) => item.length > 0).join('\n\n');
}
function extractTextAndReasoning(value: unknown): { content: string; reasoning: string } { function extractTextAndReasoning(value: unknown): { content: string; reasoning: string } {
if (typeof value === 'string') return { content: value, reasoning: '' }; if (typeof value === 'string') return extractInlineThinkTags(value);
if (Array.isArray(value)) { if (Array.isArray(value)) {
const contentParts: string[] = []; const contentParts: string[] = [];
const reasoningParts: string[] = []; const reasoningParts: string[] = [];
for (const item of value) { for (const item of value) {
if (!isRecord(item)) { if (!isRecord(item)) {
if (typeof item === 'string') contentParts.push(item); if (typeof item === 'string') {
const parsed = extractInlineThinkTags(item);
if (parsed.content) contentParts.push(parsed.content);
if (parsed.reasoning) reasoningParts.push(parsed.reasoning);
}
continue; continue;
} }
@@ -119,8 +128,9 @@ function extractTextAndReasoning(value: unknown): { content: string; reasoning:
reasoningParts.push(item.reasoning); reasoningParts.push(item.reasoning);
continue; continue;
} }
const text = extractTextPart(item); const parsed = extractInlineThinkTags(extractTextPart(item));
if (text) contentParts.push(text); if (parsed.content) contentParts.push(parsed.content);
if (parsed.reasoning) reasoningParts.push(parsed.reasoning);
} }
return { return {
@@ -130,13 +140,14 @@ function extractTextAndReasoning(value: unknown): { content: string; reasoning:
} }
if (!isRecord(value)) return { content: '', reasoning: '' }; if (!isRecord(value)) return { content: '', reasoning: '' };
const parsed = extractInlineThinkTags(extractTextPart(value.content ?? value));
return { return {
content: extractTextPart(value.content ?? value), content: parsed.content,
reasoning: typeof value.reasoning_content === 'string' reasoning: joinNonEmpty([
? value.reasoning_content typeof value.reasoning_content === 'string' ? value.reasoning_content : '',
: typeof value.reasoning === 'string' typeof value.reasoning === 'string' ? value.reasoning : '',
? value.reasoning parsed.reasoning,
: '', ]),
}; };
} }
+30 -8
View File
@@ -1,19 +1,41 @@
import { parseDownstreamChatRequest } from '../../shared/normalized.js'; import { parseDownstreamChatRequest } from '../../shared/normalized.js';
import { createProtocolRequestEnvelope } from '../../shared/protocolModel.js';
import { extractChatRequestMetadata } from './helpers.js'; import { extractChatRequestMetadata } from './helpers.js';
import type { OpenAiChatParsedRequest } from './model.js'; import type { OpenAiChatParsedRequest, OpenAiChatRequestEnvelope } from './model.js';
export const openAiChatInbound = { export const openAiChatInbound = {
parse(body: unknown): { value?: OpenAiChatParsedRequest; error?: { statusCode: number; payload: unknown } } { parse(body: unknown): { value?: OpenAiChatRequestEnvelope; error?: { statusCode: number; payload: unknown } } {
const parsed = parseDownstreamChatRequest(body, 'openai') as { const parsed = parseDownstreamChatRequest(body, 'openai') as {
value?: OpenAiChatParsedRequest; value?: OpenAiChatParsedRequest;
error?: { statusCode: number; payload: unknown }; error?: { statusCode: number; payload: unknown };
}; };
if (!parsed.value) return parsed; if (parsed.error) {
return { error: parsed.error };
parsed.value = { }
...parsed.value, if (!parsed.value) {
requestMetadata: extractChatRequestMetadata(body), return {
error: {
statusCode: 400,
payload: {
error: {
message: 'invalid chat request',
type: 'invalid_request_error',
},
},
},
};
}
const metadata = extractChatRequestMetadata(body);
return {
value: createProtocolRequestEnvelope({
protocol: 'openai/chat',
model: parsed.value.requestedModel,
stream: parsed.value.isStream,
rawBody: body,
parsed: parsed.value,
...(metadata ? { metadata } : {}),
}),
}; };
return parsed;
}, },
}; };
@@ -31,7 +31,16 @@ describe('openAiChatTransformer.inbound', () => {
}); });
expect(result.error).toBeUndefined(); expect(result.error).toBeUndefined();
expect(result.value?.upstreamBody).toMatchObject({ expect(result.value).toMatchObject({
protocol: 'openai/chat',
model: 'gpt-5',
stream: false,
rawBody: {
model: 'gpt-5',
messages: [{ role: 'user', content: 'hello' }],
},
});
expect(result.value?.parsed.upstreamBody).toMatchObject({
modalities: ['text', 'audio'], modalities: ['text', 'audio'],
audio: { voice: 'alloy', format: 'mp3' }, audio: { voice: 'alloy', format: 'mp3' },
reasoning_effort: 'high', reasoning_effort: 'high',
@@ -46,7 +55,7 @@ describe('openAiChatTransformer.inbound', () => {
response_format: { type: 'json_object' }, response_format: { type: 'json_object' },
stream_options: { include_usage: true }, stream_options: { include_usage: true },
}); });
expect((result.value as any)?.requestMetadata).toEqual({ expect((result.value as any)?.metadata).toEqual({
modalities: ['text', 'audio'], modalities: ['text', 'audio'],
audio: { voice: 'alloy', format: 'mp3' }, audio: { voice: 'alloy', format: 'mp3' },
reasoningEffort: 'high', reasoningEffort: 'high',
@@ -82,14 +91,19 @@ describe('openAiChatTransformer.inbound', () => {
}); });
expect(result.error).toBeUndefined(); expect(result.error).toBeUndefined();
expect(result.value?.upstreamBody).toMatchObject({ expect(result.value).toMatchObject({
protocol: 'openai/chat',
model: 'gpt-5',
stream: false,
});
expect(result.value?.parsed.upstreamBody).toMatchObject({
modalities: ['text', 42, 'audio', '', null], modalities: ['text', 42, 'audio', '', null],
reasoning_budget: '2048', reasoning_budget: '2048',
top_logprobs: '5', top_logprobs: '5',
logit_bias: { '42': '7', invalid: 'oops' }, logit_bias: { '42': '7', invalid: 'oops' },
stream_options: { include_usage: 1 }, stream_options: { include_usage: 1 },
}); });
expect((result.value as any)?.requestMetadata).toEqual({ expect((result.value as any)?.metadata).toEqual({
modalities: ['text', 'audio'], modalities: ['text', 'audio'],
audio: { voice: 'alloy', format: 'wav' }, audio: { voice: 'alloy', format: 'wav' },
reasoningEffort: 'medium', reasoningEffort: 'medium',
@@ -107,6 +121,28 @@ describe('openAiChatTransformer.inbound', () => {
}); });
describe('openAiChatTransformer.outbound', () => { describe('openAiChatTransformer.outbound', () => {
it('normalizes inline think tags in final chat responses', () => {
const normalized = openAiChatTransformer.transformFinalResponse({
id: 'chatcmpl-inline-think',
model: 'gpt-5',
created: 123,
choices: [{
index: 0,
finish_reason: 'stop',
message: {
role: 'assistant',
content: '<think>plan quietly</think>visible answer',
},
}],
}, 'gpt-5');
expect(normalized).toMatchObject({
content: 'visible answer',
reasoningContent: 'plan quietly',
finishReason: 'stop',
});
});
it('carries annotations, citations, and detailed usage through final serialization', () => { it('carries annotations, citations, and detailed usage through final serialization', () => {
const normalized = openAiChatTransformer.transformFinalResponse({ const normalized = openAiChatTransformer.transformFinalResponse({
id: 'chatcmpl-1', id: 'chatcmpl-1',
@@ -402,6 +438,53 @@ describe('openAiChatTransformer.stream', () => {
'https://b.example', 'https://b.example',
]); ]);
}); });
it('normalizes inline think tags inside multi-choice stream chunks', () => {
const context = openAiChatTransformer.createStreamContext('gpt-5');
const event = openAiChatTransformer.transformStreamEvent({
id: 'chatcmpl-stream-multi-think',
model: 'gpt-5',
choices: [
{
index: 0,
finish_reason: null,
delta: {
role: 'assistant',
content: '<think>plan-0</think>choice-0',
},
},
{
index: 1,
finish_reason: null,
delta: {
role: 'assistant',
content: '<think>plan-1</think>choice-1',
},
},
],
}, context, 'gpt-5');
const payloads = parseSsePayloads(
openAiChatTransformer.serializeStreamEvent(event, context, createClaudeDownstreamContext()),
);
expect((payloads[0] as any).choices[0]).toMatchObject({
index: 0,
delta: {
role: 'assistant',
content: 'choice-0',
reasoning_content: 'plan-0',
},
});
expect((payloads[0] as any).choices[1]).toMatchObject({
index: 1,
delta: {
role: 'assistant',
content: 'choice-1',
reasoning_content: 'plan-1',
},
});
});
}); });
describe('openAiChatTransformer.aggregator', () => { describe('openAiChatTransformer.aggregator', () => {
+13 -1
View File
@@ -1,10 +1,15 @@
import { type NormalizedFinalResponse, type NormalizedStreamEvent, type StreamTransformContext } from '../../shared/normalized.js'; import { type NormalizedFinalResponse, type NormalizedStreamEvent, type StreamTransformContext } from '../../shared/normalized.js';
import { createChatEndpointStrategy } from '../../shared/chatEndpointStrategy.js';
import { openAiChatInbound } from './inbound.js'; import { openAiChatInbound } from './inbound.js';
import { openAiChatOutbound } from './outbound.js'; import { openAiChatOutbound } from './outbound.js';
import { createChatProxyStreamSession } from './proxyStream.js';
import { openAiChatStream } from './stream.js'; import { openAiChatStream } from './stream.js';
import { openAiChatUsage } from './usage.js'; import { openAiChatUsage } from './usage.js';
import { createOpenAiChatAggregateState, applyOpenAiChatStreamEvent, finalizeOpenAiChatAggregate } from './aggregator.js'; import { createOpenAiChatAggregateState, applyOpenAiChatStreamEvent, finalizeOpenAiChatAggregate } from './aggregator.js';
import type { OpenAiChatParsedRequest as OpenAiChatParsedRequestModel } from './model.js'; import type {
OpenAiChatParsedRequest as OpenAiChatParsedRequestModel,
OpenAiChatRequestEnvelope as OpenAiChatRequestEnvelopeModel,
} from './model.js';
export const openAiChatTransformer = { export const openAiChatTransformer = {
protocol: 'openai/chat' as const, protocol: 'openai/chat' as const,
@@ -12,11 +17,17 @@ export const openAiChatTransformer = {
outbound: openAiChatOutbound, outbound: openAiChatOutbound,
stream: openAiChatStream, stream: openAiChatStream,
usage: openAiChatUsage, usage: openAiChatUsage,
compatibility: {
createEndpointStrategy: createChatEndpointStrategy,
},
aggregator: { aggregator: {
createState: createOpenAiChatAggregateState, createState: createOpenAiChatAggregateState,
applyEvent: applyOpenAiChatStreamEvent, applyEvent: applyOpenAiChatStreamEvent,
finalize: finalizeOpenAiChatAggregate, finalize: finalizeOpenAiChatAggregate,
}, },
proxyStream: {
createSession: createChatProxyStreamSession,
},
transformRequest(body: unknown): ReturnType<typeof openAiChatInbound.parse> { transformRequest(body: unknown): ReturnType<typeof openAiChatInbound.parse> {
return openAiChatInbound.parse(body); return openAiChatInbound.parse(body);
}, },
@@ -72,3 +83,4 @@ export const openAiChatTransformer = {
export type OpenAiChatTransformer = typeof openAiChatTransformer; export type OpenAiChatTransformer = typeof openAiChatTransformer;
export type OpenAiChatParsedRequest = OpenAiChatParsedRequestModel; export type OpenAiChatParsedRequest = OpenAiChatParsedRequestModel;
export type OpenAiChatRequestEnvelope = OpenAiChatRequestEnvelopeModel;
+7 -3
View File
@@ -3,6 +3,7 @@ import type {
NormalizedStreamEvent, NormalizedStreamEvent,
ParsedDownstreamChatRequest, ParsedDownstreamChatRequest,
} from '../../shared/normalized.js'; } from '../../shared/normalized.js';
import type { ProtocolRequestEnvelope } from '../../shared/protocolModel.js';
export type OpenAiChatAudioRequest = { export type OpenAiChatAudioRequest = {
format?: string; format?: string;
@@ -31,9 +32,12 @@ export type OpenAiChatUsageDetails = {
completion_tokens_details?: Record<string, number>; completion_tokens_details?: Record<string, number>;
}; };
export type OpenAiChatParsedRequest = ParsedDownstreamChatRequest & { export type OpenAiChatParsedRequest = ParsedDownstreamChatRequest;
requestMetadata?: OpenAiChatRequestMetadata; export type OpenAiChatRequestEnvelope = ProtocolRequestEnvelope<
}; 'openai/chat',
OpenAiChatParsedRequest,
OpenAiChatRequestMetadata
>;
export type OpenAiChatToolCall = { export type OpenAiChatToolCall = {
id: string; id: string;
@@ -0,0 +1,171 @@
import { anthropicMessagesTransformer } from '../../anthropic/messages/index.js';
import { createProxyStreamLifecycle } from '../../shared/protocolLifecycle.js';
import { type DownstreamFormat, type ParsedSseEvent } from '../../shared/normalized.js';
import { createOpenAiChatAggregateState, applyOpenAiChatStreamEvent, finalizeOpenAiChatAggregate } from './aggregator.js';
import { openAiChatOutbound } from './outbound.js';
import { openAiChatStream } from './stream.js';
type StreamReader = {
read(): Promise<{ done: boolean; value?: Uint8Array }>;
cancel(reason?: unknown): Promise<unknown>;
releaseLock(): void;
};
type ChatProxyStreamSessionInput = {
downstreamFormat: DownstreamFormat;
modelName: string;
onParsedPayload?: (payload: unknown) => void;
writeLines: (lines: string[]) => void;
writeRaw: (chunk: string) => void;
};
type ResponseSink = {
end(): void;
};
export function createChatProxyStreamSession(input: ChatProxyStreamSessionInput) {
const downstreamTransformer = input.downstreamFormat === 'claude'
? anthropicMessagesTransformer
: {
createStreamContext: openAiChatStream.createContext,
transformStreamEvent: openAiChatStream.normalizeEvent,
serializeStreamEvent: openAiChatStream.serializeEvent,
serializeDone: openAiChatStream.serializeDone,
pullSseEvents: openAiChatStream.pullSseEvents,
};
const streamContext = downstreamTransformer.createStreamContext(input.modelName);
const claudeContext = anthropicMessagesTransformer.createDownstreamContext();
const chatAggregateState = input.downstreamFormat === 'openai'
? createOpenAiChatAggregateState()
: null;
let finalized = false;
const finalize = () => {
if (finalized) return;
finalized = true;
// For native Anthropic streams, EOF without message_stop is not a clean
// completion. Forward the partial stream as-is instead of fabricating an
// end_turn/message_stop pair that makes clients think the run finished.
if (input.downstreamFormat === 'claude' && !claudeContext.doneSent) {
return;
}
if (input.downstreamFormat === 'openai' && chatAggregateState && chatAggregateState.choices.size > 0) {
const needsTerminalFinishChunk = Array.from(chatAggregateState.choices.values())
.some((choice) => !choice.finishReason);
if (needsTerminalFinishChunk) {
const terminalChunk = openAiChatOutbound.buildSyntheticChunks(
finalizeOpenAiChatAggregate(chatAggregateState, {
id: streamContext.id,
model: streamContext.model,
created: streamContext.created,
content: '',
reasoningContent: '',
finishReason: 'stop',
toolCalls: [],
}),
).slice(-1)[0];
if (terminalChunk) {
input.writeLines([`data: ${JSON.stringify(terminalChunk)}\n\n`]);
}
}
}
input.writeLines(downstreamTransformer.serializeDone(streamContext, claudeContext));
};
const handleEventBlock = async (eventBlock: ParsedSseEvent): Promise<boolean> => {
if (eventBlock.data === '[DONE]') {
finalize();
return true;
}
let parsedPayload: unknown = null;
if (input.downstreamFormat === 'claude') {
const consumed = anthropicMessagesTransformer.consumeSseEventBlock(
eventBlock,
streamContext,
claudeContext,
input.modelName,
);
parsedPayload = consumed.parsedPayload;
if (parsedPayload && typeof parsedPayload === 'object') {
input.onParsedPayload?.(parsedPayload);
}
if (consumed.handled) {
input.writeLines(consumed.lines);
return consumed.done;
}
} else {
try {
parsedPayload = JSON.parse(eventBlock.data);
} catch {
parsedPayload = null;
}
if (parsedPayload && typeof parsedPayload === 'object') {
input.onParsedPayload?.(parsedPayload);
}
}
if (parsedPayload && typeof parsedPayload === 'object') {
const normalizedEvent = downstreamTransformer.transformStreamEvent(parsedPayload, streamContext, input.modelName);
if (input.downstreamFormat === 'openai' && chatAggregateState) {
applyOpenAiChatStreamEvent(chatAggregateState, normalizedEvent);
}
input.writeLines(downstreamTransformer.serializeStreamEvent(normalizedEvent, streamContext, claudeContext));
return input.downstreamFormat === 'claude' && claudeContext.doneSent;
}
if (input.downstreamFormat === 'openai') {
input.writeRaw(`data: ${eventBlock.data}\n\n`);
return false;
}
input.writeLines(anthropicMessagesTransformer.serializeStreamEvent({
contentDelta: eventBlock.data,
}, streamContext, claudeContext));
return claudeContext.doneSent;
};
return {
consumeUpstreamFinalPayload(payload: unknown, fallbackText: string, response?: ResponseSink) {
if (payload && typeof payload === 'object') {
input.onParsedPayload?.(payload);
}
if (input.downstreamFormat === 'openai') {
const normalizedFinal = openAiChatOutbound.normalizeFinal(payload, input.modelName, fallbackText);
streamContext.id = normalizedFinal.id;
streamContext.model = normalizedFinal.model;
streamContext.created = normalizedFinal.created;
input.writeLines(
openAiChatOutbound
.buildSyntheticChunks(normalizedFinal)
.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`),
);
} else {
input.writeLines(
anthropicMessagesTransformer.serializeUpstreamFinalAsStream(
payload,
input.modelName,
fallbackText,
streamContext,
claudeContext,
),
);
}
finalize();
response?.end();
},
async run(reader: StreamReader | null | undefined, response: ResponseSink) {
const lifecycle = createProxyStreamLifecycle<ParsedSseEvent>({
reader,
response,
pullEvents: (buffer) => downstreamTransformer.pullSseEvents(buffer),
handleEvent: handleEventBlock,
onEof: finalize,
});
await lifecycle.run();
},
};
}
+19 -7
View File
@@ -36,23 +36,35 @@ export const openAiChatStream = {
return createStreamTransformContext(modelName); return createStreamTransformContext(modelName);
}, },
normalizeEvent(payload: unknown, context: StreamTransformContext, modelName: string): OpenAiChatNormalizedStreamEvent { normalizeEvent(payload: unknown, context: StreamTransformContext, modelName: string): OpenAiChatNormalizedStreamEvent {
const normalized = normalizeUpstreamStreamEvent(payload, context, modelName);
const choiceEvents = extractChatChoiceEvents(payload); const choiceEvents = extractChatChoiceEvents(payload);
const primaryChoice = choiceEvents[0]; const primaryChoice = choiceEvents[0];
const normalizedChoiceEvents = choiceEvents.map((choiceEvent, index) => (
index === 0
? {
...choiceEvent,
role: normalized.role !== undefined ? normalized.role : choiceEvent.role,
contentDelta: normalized.contentDelta,
reasoningDelta: normalized.reasoningDelta,
toolCallDeltas: normalized.toolCallDeltas !== undefined
? normalized.toolCallDeltas
: choiceEvent.toolCallDeltas,
finishReason: normalized.finishReason !== undefined
? normalized.finishReason
: choiceEvent.finishReason,
}
: choiceEvent
));
return { return {
...normalizeUpstreamStreamEvent(payload, context, modelName), ...normalized,
...(primaryChoice ...(primaryChoice
? { ? {
choiceIndex: primaryChoice.index, choiceIndex: primaryChoice.index,
role: primaryChoice.role,
contentDelta: primaryChoice.contentDelta,
reasoningDelta: primaryChoice.reasoningDelta,
toolCallDeltas: primaryChoice.toolCallDeltas,
finishReason: primaryChoice.finishReason,
annotations: primaryChoice.annotations, annotations: primaryChoice.annotations,
citations: primaryChoice.citations, citations: primaryChoice.citations,
} }
: {}), : {}),
...(choiceEvents.length > 0 ? { choiceEvents } : {}), ...(normalizedChoiceEvents.length > 0 ? { choiceEvents: normalizedChoiceEvents } : {}),
...extractChatResponseExtras(payload), ...extractChatResponseExtras(payload),
}; };
}, },
@@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest';
import { createStreamTransformContext } from '../../shared/normalized.js'; import { createStreamTransformContext } from '../../shared/normalized.js';
import { import {
completeResponsesStream,
createOpenAiResponsesAggregateState, createOpenAiResponsesAggregateState,
failResponsesStream,
serializeConvertedResponsesEvents, serializeConvertedResponsesEvents,
} from './aggregator.js'; } from './aggregator.js';
@@ -23,6 +25,35 @@ function parseSsePayloads(lines: string[]): Array<Record<string, unknown>> {
.filter((item): item is Record<string, unknown> => !!item); .filter((item): item is Record<string, unknown> => !!item);
} }
function parseSseEvents(lines: string[]): Array<{ event: string | null; payload: Record<string, unknown> | '[DONE]' }> {
return lines
.flatMap((line) => line.split('\n\n').filter((block) => block.trim().length > 0))
.map((block) => {
const eventLine = block
.split('\n')
.find((line) => line.startsWith('event: '));
const dataLine = block
.split('\n')
.find((line) => line.startsWith('data: '));
if (!dataLine) return null;
if (dataLine === 'data: [DONE]') {
return {
event: eventLine ? eventLine.slice('event: '.length) : null,
payload: '[DONE]' as const,
};
}
try {
return {
event: eventLine ? eventLine.slice('event: '.length) : null,
payload: JSON.parse(dataLine.slice('data: '.length)) as Record<string, unknown>,
};
} catch {
return null;
}
})
.filter((item): item is { event: string | null; payload: Record<string, unknown> | '[DONE]' } => !!item);
}
describe('serializeConvertedResponsesEvents', () => { describe('serializeConvertedResponsesEvents', () => {
it('aggregates reasoning summary events into the completed response payload', () => { it('aggregates reasoning summary events into the completed response payload', () => {
const state = createOpenAiResponsesAggregateState('gpt-5'); const state = createOpenAiResponsesAggregateState('gpt-5');
@@ -301,4 +332,174 @@ describe('serializeConvertedResponsesEvents', () => {
], ],
}); });
}); });
it('emits canonical message done events before response.completed when recovering a sparse text stream', () => {
const state = createOpenAiResponsesAggregateState('gpt-5');
const streamContext = createStreamTransformContext('gpt-5');
const usage = {
promptTokens: 2,
completionTokens: 4,
totalTokens: 6,
};
serializeConvertedResponsesEvents({
state,
streamContext,
usage,
event: {
contentDelta: 'hello world',
} as any,
});
const completionLines = completeResponsesStream(state, streamContext, usage);
const events = parseSseEvents(completionLines);
expect(events.map((entry) => entry.event ?? (entry.payload === '[DONE]' ? '[DONE]' : 'data'))).toEqual([
'response.output_text.done',
'response.content_part.done',
'response.output_item.done',
'response.completed',
'[DONE]',
]);
expect(events[0]?.payload).toMatchObject({
type: 'response.output_text.done',
output_index: 0,
item_id: 'msg_0',
text: 'hello world',
});
expect(events[1]?.payload).toMatchObject({
type: 'response.content_part.done',
output_index: 0,
item_id: 'msg_0',
content_index: 0,
part: {
type: 'output_text',
text: 'hello world',
},
});
expect(events[2]?.payload).toMatchObject({
type: 'response.output_item.done',
output_index: 0,
item: {
id: 'msg_0',
type: 'message',
status: 'completed',
},
});
});
it('emits canonical reasoning done events before response.completed when recovering sparse reasoning output', () => {
const state = createOpenAiResponsesAggregateState('gpt-5');
const streamContext = createStreamTransformContext('gpt-5');
const usage = {
promptTokens: 2,
completionTokens: 4,
totalTokens: 6,
};
serializeConvertedResponsesEvents({
state,
streamContext,
usage,
event: {
reasoningSignature: 'enc-signature',
reasoningDelta: 'plan first',
} as any,
});
const completionLines = completeResponsesStream(state, streamContext, usage);
const events = parseSseEvents(completionLines);
expect(events.map((entry) => entry.event ?? (entry.payload === '[DONE]' ? '[DONE]' : 'data'))).toEqual([
'response.reasoning_summary_text.done',
'response.reasoning_summary_part.done',
'response.output_item.done',
'response.completed',
'[DONE]',
]);
expect(events[0]?.payload).toMatchObject({
type: 'response.reasoning_summary_text.done',
item_id: 'rs_0',
output_index: 0,
summary_index: 0,
text: 'plan first',
});
expect(events[1]?.payload).toMatchObject({
type: 'response.reasoning_summary_part.done',
item_id: 'rs_0',
output_index: 0,
summary_index: 0,
part: {
type: 'summary_text',
text: 'plan first',
},
});
expect(events[2]?.payload).toMatchObject({
type: 'response.output_item.done',
output_index: 0,
item: {
id: 'rs_0',
type: 'reasoning',
status: 'completed',
encrypted_content: 'enc-signature',
},
});
});
it('emits canonical message done events before response.failed when failing a sparse text stream', () => {
const state = createOpenAiResponsesAggregateState('gpt-5');
const streamContext = createStreamTransformContext('gpt-5');
const usage = {
promptTokens: 2,
completionTokens: 4,
totalTokens: 6,
};
serializeConvertedResponsesEvents({
state,
streamContext,
usage,
event: {
contentDelta: 'partial answer',
} as any,
});
const failedLines = failResponsesStream(state, streamContext, usage, {
error: {
message: 'upstream stream failed',
},
});
const events = parseSseEvents(failedLines);
expect(events.map((entry) => entry.event ?? (entry.payload === '[DONE]' ? '[DONE]' : 'data'))).toEqual([
'response.output_text.done',
'response.content_part.done',
'response.output_item.done',
'response.failed',
'[DONE]',
]);
expect(events[2]?.payload).toMatchObject({
type: 'response.output_item.done',
output_index: 0,
item: {
id: 'msg_0',
type: 'message',
status: 'failed',
},
});
expect(events[3]?.payload).toMatchObject({
type: 'response.failed',
response: {
status: 'failed',
output_text: 'partial answer',
},
error: {
message: 'upstream stream failed',
type: 'upstream_error',
},
});
});
}); });
@@ -768,6 +768,99 @@ function buildSyntheticToolEvents(
return lines; return lines;
} }
function buildSyntheticTerminalItemDoneEvents(
state: OpenAiResponsesAggregateState,
status: 'completed' | 'failed',
): string[] {
const lines: string[] = [];
for (let index = 0; index < state.outputItems.length; index += 1) {
const item = state.outputItems[index];
if (!isRecord(item)) continue;
const currentStatus = asTrimmedString(item.status).toLowerCase();
if (currentStatus === 'completed' || currentStatus === 'failed') continue;
const itemType = asTrimmedString(item.type).toLowerCase();
const itemId = ensureOutputItemId(asTrimmedString(item.id), 'out', index);
item.id = itemId;
if (itemType === 'message') {
const content = Array.isArray(item.content) ? item.content as AggregateOutputItem[] : [];
const part = content[0];
if (isRecord(part)) {
const text = typeof part.text === 'string' ? part.text : String(part.text ?? '');
lines.push(serializeSse('response.output_text.done', {
type: 'response.output_text.done',
output_index: index,
item_id: itemId,
text,
}));
lines.push(serializeSse('response.content_part.done', {
type: 'response.content_part.done',
output_index: index,
item_id: itemId,
content_index: 0,
part: cloneJson(part),
}));
}
} else if (itemType === 'reasoning') {
const summary = Array.isArray(item.summary) ? item.summary as AggregateOutputItem[] : [];
const part = summary[0];
if (isRecord(part)) {
const text = typeof part.text === 'string' ? part.text : String(part.text ?? '');
lines.push(serializeSse('response.reasoning_summary_text.done', {
type: 'response.reasoning_summary_text.done',
item_id: itemId,
output_index: index,
summary_index: 0,
text,
}));
lines.push(serializeSse('response.reasoning_summary_part.done', {
type: 'response.reasoning_summary_part.done',
item_id: itemId,
output_index: index,
summary_index: 0,
part: cloneJson(part),
}));
}
} else if (itemType === 'function_call') {
const callId = asTrimmedString(item.call_id) || itemId;
const name = asTrimmedString(item.name);
const argumentsText = typeof item.arguments === 'string' ? item.arguments : String(item.arguments ?? '');
lines.push(serializeSse('response.function_call_arguments.done', {
type: 'response.function_call_arguments.done',
item_id: itemId,
call_id: callId,
output_index: index,
...(name ? { name } : {}),
arguments: argumentsText,
}));
} else if (itemType === 'custom_tool_call') {
const callId = asTrimmedString(item.call_id) || itemId;
const name = asTrimmedString(item.name);
const inputText = typeof item.input === 'string' ? item.input : String(item.input ?? '');
lines.push(serializeSse('response.custom_tool_call_input.done', {
type: 'response.custom_tool_call_input.done',
item_id: itemId,
call_id: callId,
output_index: index,
...(name ? { name } : {}),
input: inputText,
}));
}
item.status = status;
lines.push(serializeSse('response.output_item.done', {
type: 'response.output_item.done',
output_index: index,
item: cloneJson(item),
}));
}
return lines;
}
export function serializeConvertedResponsesEvents(input: { export function serializeConvertedResponsesEvents(input: {
state: OpenAiResponsesAggregateState; state: OpenAiResponsesAggregateState;
streamContext: StreamTransformContext; streamContext: StreamTransformContext;
@@ -806,8 +899,10 @@ export function completeResponsesStream(
if (state.failed || state.completed) { if (state.failed || state.completed) {
return [serializeDone()]; return [serializeDone()];
} }
const lines = buildSyntheticTerminalItemDoneEvents(state, 'completed');
state.completed = true; state.completed = true;
return [ return [
...lines,
serializeSse('response.completed', { serializeSse('response.completed', {
type: 'response.completed', type: 'response.completed',
response: materializeResponse(state, streamContext, usage, null, 'completed'), response: materializeResponse(state, streamContext, usage, null, 'completed'),
@@ -825,6 +920,7 @@ export function failResponsesStream(
if (state.failed) { if (state.failed) {
return [serializeDone()]; return [serializeDone()];
} }
const lines = buildSyntheticTerminalItemDoneEvents(state, 'failed');
state.failed = true; state.failed = true;
const errorPayload = cloneRecord(payload); const errorPayload = cloneRecord(payload);
const message = ( const message = (
@@ -833,6 +929,7 @@ export function failResponsesStream(
: (typeof errorPayload?.message === 'string' ? errorPayload.message : 'upstream stream failed') : (typeof errorPayload?.message === 'string' ? errorPayload.message : 'upstream stream failed')
); );
return [ return [
...lines,
serializeSse('response.failed', { serializeSse('response.failed', {
type: 'response.failed', type: 'response.failed',
response: materializeResponse(state, streamContext, usage, cloneRecord(errorPayload?.response), 'failed'), response: materializeResponse(state, streamContext, usage, cloneRecord(errorPayload?.response), 'failed'),
@@ -1,6 +1,55 @@
import { normalizeResponsesInputForCompatibility, normalizeResponsesMessageItem } from './compatibility.js'; import { createProtocolRequestEnvelope } from '../../shared/protocolModel.js';
import { sanitizeResponsesBodyForProxy } from './conversion.js';
import type {
OpenAiResponsesParsedRequest,
OpenAiResponsesRequestEnvelope,
} from './model.js';
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function invalidRequest(message: string): { statusCode: number; payload: unknown } {
return {
statusCode: 400,
payload: {
error: {
message,
type: 'invalid_request_error',
},
},
};
}
export const openAiResponsesInbound = { export const openAiResponsesInbound = {
normalizeInput: normalizeResponsesInputForCompatibility, parse(
normalizeMessage: normalizeResponsesMessageItem, body: unknown,
options?: { defaultEncryptedReasoningInclude?: boolean },
): { value?: OpenAiResponsesRequestEnvelope; error?: { statusCode: number; payload: unknown } } {
const rawBody = isRecord(body) ? body : {};
const requestedModel = typeof rawBody.model === 'string' ? rawBody.model.trim() : '';
if (!requestedModel) {
return { error: invalidRequest('model is required') };
}
const isStream = rawBody.stream === true;
const normalizedBody = sanitizeResponsesBodyForProxy(
rawBody,
requestedModel,
isStream,
{ defaultEncryptedReasoningInclude: options?.defaultEncryptedReasoningInclude },
);
return {
value: createProtocolRequestEnvelope({
protocol: 'openai/responses',
model: requestedModel,
stream: isStream,
rawBody: body,
parsed: {
normalizedBody,
} satisfies OpenAiResponsesParsedRequest,
}),
};
},
}; };
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';
import { openAiResponsesTransformer } from './index.js';
describe('openAiResponsesTransformer.inbound', () => {
it('returns a protocol request envelope with a normalized responses body', () => {
const result = openAiResponsesTransformer.transformRequest({
model: 'gpt-5',
input: 'hello',
reasoning: {
effort: 'high',
},
});
expect(result.error).toBeUndefined();
expect(result.value).toMatchObject({
protocol: 'openai/responses',
model: 'gpt-5',
stream: false,
rawBody: {
model: 'gpt-5',
input: 'hello',
},
parsed: {
normalizedBody: {
model: 'gpt-5',
input: [
{
type: 'message',
role: 'user',
content: [
{
type: 'input_text',
text: 'hello',
},
],
},
],
stream: false,
},
},
});
});
it('rejects requests without a model at the transformer boundary', () => {
const result = openAiResponsesTransformer.transformRequest({
input: 'hello',
});
expect(result.error).toEqual({
statusCode: 400,
payload: {
error: {
message: 'model is required',
type: 'invalid_request_error',
},
},
});
});
});
@@ -21,12 +21,20 @@ import {
serializeConvertedResponsesEvents, serializeConvertedResponsesEvents,
} from './aggregator.js'; } from './aggregator.js';
import { openAiResponsesOutbound } from './outbound.js'; import { openAiResponsesOutbound } from './outbound.js';
import { openAiResponsesInbound } from './inbound.js';
import { createResponsesProxyStreamSession } from './proxyStream.js';
import { createResponsesEndpointStrategy } from './routeCompatibility.js';
import { openAiResponsesStream } from './stream.js'; import { openAiResponsesStream } from './stream.js';
import { openAiResponsesUsage } from './usage.js'; import { openAiResponsesUsage } from './usage.js';
import type {
OpenAiResponsesParsedRequest as OpenAiResponsesParsedRequestModel,
OpenAiResponsesRequestEnvelope as OpenAiResponsesRequestEnvelopeModel,
} from './model.js';
export const openAiResponsesTransformer = { export const openAiResponsesTransformer = {
protocol: 'openai/responses' as const, protocol: 'openai/responses' as const,
inbound: { inbound: {
parse: openAiResponsesInbound.parse,
normalizeInput: normalizeResponsesInputForCompatibility, normalizeInput: normalizeResponsesInputForCompatibility,
normalizeMessage: normalizeResponsesMessageItem, normalizeMessage: normalizeResponsesMessageItem,
normalizeContent: normalizeResponsesMessageContent, normalizeContent: normalizeResponsesMessageContent,
@@ -38,6 +46,7 @@ export const openAiResponsesTransformer = {
stream: openAiResponsesStream, stream: openAiResponsesStream,
usage: openAiResponsesUsage, usage: openAiResponsesUsage,
compatibility: { compatibility: {
createEndpointStrategy: createResponsesEndpointStrategy,
buildRetryBodies, buildRetryBodies,
buildRetryHeaders, buildRetryHeaders,
shouldRetry, shouldRetry,
@@ -49,8 +58,14 @@ export const openAiResponsesTransformer = {
complete: completeResponsesStream, complete: completeResponsesStream,
fail: failResponsesStream, fail: failResponsesStream,
}, },
transformRequest(body: unknown) { proxyStream: {
return body; createSession: createResponsesProxyStreamSession,
},
transformRequest(
body: unknown,
options?: { defaultEncryptedReasoningInclude?: boolean },
): { value?: OpenAiResponsesRequestEnvelopeModel; error?: { statusCode: number; payload: unknown } } {
return openAiResponsesInbound.parse(body, options);
}, },
createStreamContext(modelName: string): StreamTransformContext { createStreamContext(modelName: string): StreamTransformContext {
return openAiResponsesStream.createContext(modelName); return openAiResponsesStream.createContext(modelName);
@@ -68,6 +83,8 @@ export const openAiResponsesTransformer = {
export type OpenAiResponsesTransformer = typeof openAiResponsesTransformer; export type OpenAiResponsesTransformer = typeof openAiResponsesTransformer;
export type OpenAiResponsesAggregate = OpenAiResponsesAggregateState; export type OpenAiResponsesAggregate = OpenAiResponsesAggregateState;
export type OpenAiResponsesParsedRequest = OpenAiResponsesParsedRequestModel;
export type OpenAiResponsesRequestEnvelope = OpenAiResponsesRequestEnvelopeModel;
export { export {
convertOpenAiBodyToResponsesBody, convertOpenAiBodyToResponsesBody,
convertResponsesBodyToOpenAiBody, convertResponsesBodyToOpenAiBody,
@@ -0,0 +1,10 @@
import type { ProtocolRequestEnvelope } from '../../shared/protocolModel.js';
export type OpenAiResponsesParsedRequest = {
normalizedBody: Record<string, unknown>;
};
export type OpenAiResponsesRequestEnvelope = ProtocolRequestEnvelope<
'openai/responses',
OpenAiResponsesParsedRequest
>;
@@ -0,0 +1,111 @@
import { createProxyStreamLifecycle } from '../../shared/protocolLifecycle.js';
import { type ParsedSseEvent } from '../../shared/normalized.js';
import { completeResponsesStream, createOpenAiResponsesAggregateState, failResponsesStream, serializeConvertedResponsesEvents } from './aggregator.js';
import { openAiResponsesStream } from './stream.js';
type StreamReader = {
read(): Promise<{ done: boolean; value?: Uint8Array }>;
cancel(reason?: unknown): Promise<unknown>;
releaseLock(): void;
};
type ResponseSink = {
end(): void;
};
type ResponsesProxyStreamSessionInput = {
modelName: string;
successfulUpstreamPath: string;
getUsage: () => {
promptTokens: number;
completionTokens: number;
totalTokens: number;
cacheReadTokens: number;
cacheCreationTokens: number;
promptTokensIncludeCache: boolean | null;
};
onParsedPayload?: (payload: unknown) => void;
writeLines: (lines: string[]) => void;
writeRaw: (chunk: string) => void;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
}
export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSessionInput) {
const streamContext = openAiResponsesStream.createContext(input.modelName);
const responsesState = createOpenAiResponsesAggregateState(input.modelName);
let finalized = false;
const finalize = () => {
if (finalized) return;
finalized = true;
input.writeLines(completeResponsesStream(responsesState, streamContext, input.getUsage()));
};
const handleEventBlock = (eventBlock: ParsedSseEvent): boolean => {
if (eventBlock.data === '[DONE]') {
finalize();
return true;
}
let parsedPayload: unknown = null;
try {
parsedPayload = JSON.parse(eventBlock.data);
} catch {
parsedPayload = null;
}
if (parsedPayload && typeof parsedPayload === 'object') {
input.onParsedPayload?.(parsedPayload);
}
const payloadType = (isRecord(parsedPayload) && typeof parsedPayload.type === 'string')
? parsedPayload.type
: '';
const isFailureEvent = (
eventBlock.event === 'error'
|| eventBlock.event === 'response.failed'
|| payloadType === 'error'
|| payloadType === 'response.failed'
);
if (isFailureEvent) {
input.writeLines(failResponsesStream(responsesState, streamContext, input.getUsage(), parsedPayload));
finalized = true;
return true;
}
if (parsedPayload && typeof parsedPayload === 'object') {
const normalizedEvent = openAiResponsesStream.normalizeEvent(parsedPayload, streamContext, input.modelName);
input.writeLines(serializeConvertedResponsesEvents({
state: responsesState,
streamContext,
event: normalizedEvent,
usage: input.getUsage(),
}));
return false;
}
input.writeLines(serializeConvertedResponsesEvents({
state: responsesState,
streamContext,
event: { contentDelta: eventBlock.data },
usage: input.getUsage(),
}));
return false;
};
return {
async run(reader: StreamReader | null | undefined, response: ResponseSink) {
const lifecycle = createProxyStreamLifecycle<ParsedSseEvent>({
reader,
response,
pullEvents: (buffer) => openAiResponsesStream.pullSseEvents(buffer),
handleEvent: handleEventBlock,
onEof: finalize,
});
await lifecycle.run();
},
};
}
@@ -0,0 +1,110 @@
import {
buildMinimalJsonHeadersForCompatibility,
isEndpointDowngradeError,
isUnsupportedMediaTypeError,
type CompatibilityEndpoint,
} from '../../shared/endpointCompatibility.js';
import type { EndpointAttemptContext, EndpointRecoverResult } from '../../../routes/proxy/endpointFlow.js';
import {
buildResponsesCompatibilityBodies,
buildResponsesCompatibilityHeaderCandidates,
shouldDowngradeResponsesChatToMessages,
shouldRetryResponsesCompatibility,
} from './compatibility.js';
type CompatibilityRequest = {
endpoint: CompatibilityEndpoint;
path: string;
headers: Record<string, string>;
body: Record<string, unknown>;
};
type UpstreamResponse = Exclude<EndpointRecoverResult, null>['upstream'];
type CreateResponsesEndpointStrategyInput = {
isStream: boolean;
requiresNativeResponsesFileUrl: boolean;
dispatchRequest: (
request: CompatibilityRequest,
targetUrl?: string,
) => Promise<UpstreamResponse>;
};
export function createResponsesEndpointStrategy(input: CreateResponsesEndpointStrategyInput) {
return {
async tryRecover(ctx: EndpointAttemptContext): Promise<EndpointRecoverResult> {
if (shouldRetryResponsesCompatibility({
endpoint: ctx.request.endpoint,
status: ctx.response.status,
rawErrText: ctx.rawErrText,
})) {
const compatibilityBodies = buildResponsesCompatibilityBodies(ctx.request.body);
const compatibilityHeaders = buildResponsesCompatibilityHeaderCandidates(
ctx.request.headers,
input.isStream,
);
for (const compatibilityHeadersCandidate of compatibilityHeaders) {
for (const compatibilityBody of compatibilityBodies) {
const compatibilityRequest = {
...ctx.request,
headers: compatibilityHeadersCandidate,
body: compatibilityBody,
};
const compatibilityResponse = await input.dispatchRequest(
compatibilityRequest,
ctx.targetUrl,
);
if (compatibilityResponse.ok) {
return {
upstream: compatibilityResponse,
upstreamPath: compatibilityRequest.path,
};
}
ctx.request = compatibilityRequest;
ctx.response = compatibilityResponse;
ctx.rawErrText = await compatibilityResponse.text().catch(() => 'unknown error');
}
}
}
if (!isUnsupportedMediaTypeError(ctx.response.status, ctx.rawErrText)) {
return null;
}
const minimalRequest = {
...ctx.request,
headers: buildMinimalJsonHeadersForCompatibility({
headers: ctx.request.headers,
endpoint: ctx.request.endpoint,
stream: input.isStream,
}),
};
const minimalResponse = await input.dispatchRequest(minimalRequest, ctx.targetUrl);
if (minimalResponse.ok) {
return {
upstream: minimalResponse,
upstreamPath: minimalRequest.path,
};
}
ctx.request = minimalRequest;
ctx.response = minimalResponse;
ctx.rawErrText = await minimalResponse.text().catch(() => 'unknown error');
return null;
},
shouldDowngrade(ctx: EndpointAttemptContext): boolean {
if (input.requiresNativeResponsesFileUrl) return false;
return (
ctx.response.status >= 500
|| isEndpointDowngradeError(ctx.response.status, ctx.rawErrText)
|| shouldDowngradeResponsesChatToMessages(
ctx.request.path,
ctx.response.status,
ctx.rawErrText,
)
);
},
};
}
@@ -0,0 +1,121 @@
import type { DownstreamFormat } from './normalized.js';
import type { EndpointAttemptContext, EndpointRecoverResult } from '../../routes/proxy/endpointFlow.js';
import {
buildMinimalJsonHeadersForCompatibility,
isEndpointDispatchDeniedError,
isEndpointDowngradeError,
isUnsupportedMediaTypeError,
promoteResponsesCandidateAfterLegacyChatError,
type CompatibilityEndpoint,
} from './endpointCompatibility.js';
import {
isMessagesRequiredError,
shouldRetryNormalizedMessagesBody,
} from '../anthropic/messages/compatibility.js';
type CompatibilityRequest = {
endpoint: CompatibilityEndpoint;
path: string;
headers: Record<string, string>;
body: Record<string, unknown>;
};
type UpstreamResponse = Exclude<EndpointRecoverResult, null>['upstream'];
type CreateChatEndpointStrategyInput = {
downstreamFormat: DownstreamFormat;
endpointCandidates: CompatibilityEndpoint[];
modelName: string;
requestedModelHint: string;
sitePlatform?: string | null;
isStream: boolean;
buildRequest: (input: {
endpoint: CompatibilityEndpoint;
forceNormalizeClaudeBody?: boolean;
}) => CompatibilityRequest;
dispatchRequest: (
request: CompatibilityRequest,
targetUrl?: string,
) => Promise<UpstreamResponse>;
};
export function createChatEndpointStrategy(input: CreateChatEndpointStrategyInput) {
return {
async tryRecover(ctx: EndpointAttemptContext): Promise<EndpointRecoverResult> {
if (shouldRetryNormalizedMessagesBody({
downstreamFormat: input.downstreamFormat,
endpointPath: ctx.request.path,
status: ctx.response.status,
upstreamErrorText: ctx.rawErrText,
})) {
const normalizedClaudeRequest = input.buildRequest({
endpoint: ctx.request.endpoint,
forceNormalizeClaudeBody: true,
});
const normalizedResponse = await input.dispatchRequest(normalizedClaudeRequest);
if (normalizedResponse.ok) {
return {
upstream: normalizedResponse,
upstreamPath: normalizedClaudeRequest.path,
};
}
ctx.request = normalizedClaudeRequest;
ctx.response = normalizedResponse;
ctx.rawErrText = await normalizedResponse.text().catch(() => 'unknown error');
}
if (!isUnsupportedMediaTypeError(ctx.response.status, ctx.rawErrText)) {
return null;
}
const minimalHeaders = buildMinimalJsonHeadersForCompatibility({
headers: ctx.request.headers,
endpoint: ctx.request.endpoint,
stream: input.isStream,
});
const normalizedCurrentHeaders = Object.fromEntries(
Object.entries(ctx.request.headers).map(([key, value]) => [key.toLowerCase(), value]),
);
if (JSON.stringify(minimalHeaders) === JSON.stringify(normalizedCurrentHeaders)) {
return null;
}
const minimalRequest = {
...ctx.request,
headers: minimalHeaders,
};
const minimalResponse = await input.dispatchRequest(minimalRequest, ctx.targetUrl);
if (minimalResponse.ok) {
return {
upstream: minimalResponse,
upstreamPath: minimalRequest.path,
};
}
ctx.request = minimalRequest;
ctx.response = minimalResponse;
ctx.rawErrText = await minimalResponse.text().catch(() => 'unknown error');
return null;
},
shouldDowngrade(ctx: EndpointAttemptContext): boolean {
promoteResponsesCandidateAfterLegacyChatError(input.endpointCandidates, {
status: ctx.response.status,
upstreamErrorText: ctx.rawErrText,
downstreamFormat: input.downstreamFormat,
sitePlatform: input.sitePlatform,
modelName: input.modelName,
requestedModelHint: input.requestedModelHint,
currentEndpoint: ctx.request.endpoint,
});
return (
ctx.response.status >= 500
|| isEndpointDowngradeError(ctx.response.status, ctx.rawErrText)
|| isMessagesRequiredError(ctx.rawErrText)
|| isEndpointDispatchDeniedError(ctx.response.status, ctx.rawErrText)
);
},
};
}
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import { createStreamTransformContext, normalizeUpstreamStreamEvent } from './chatFormatsCore.js';
describe('chatFormatsCore inline think parsing', () => {
it('tracks split think tags across stream chunks', () => {
const context = createStreamTransformContext('gpt-test');
expect(normalizeUpstreamStreamEvent({
id: 'chatcmpl-split-think',
model: 'gpt-test',
choices: [{
index: 0,
delta: { role: 'assistant' },
finish_reason: null,
}],
}, context, 'gpt-test')).toMatchObject({
role: 'assistant',
});
const openingFragment = normalizeUpstreamStreamEvent({
id: 'chatcmpl-split-think',
model: 'gpt-test',
choices: [{
index: 0,
delta: { content: '<thin' },
finish_reason: null,
}],
}, context, 'gpt-test');
expect(openingFragment.contentDelta).toBeUndefined();
expect(openingFragment.reasoningDelta).toBeUndefined();
expect(normalizeUpstreamStreamEvent({
id: 'chatcmpl-split-think',
model: 'gpt-test',
choices: [{
index: 0,
delta: { content: 'k>plan ' },
finish_reason: null,
}],
}, context, 'gpt-test')).toMatchObject({
reasoningDelta: 'plan ',
});
expect(normalizeUpstreamStreamEvent({
id: 'chatcmpl-split-think',
model: 'gpt-test',
choices: [{
index: 0,
delta: { content: 'quietly</th' },
finish_reason: null,
}],
}, context, 'gpt-test')).toMatchObject({
reasoningDelta: 'quietly',
});
expect(normalizeUpstreamStreamEvent({
id: 'chatcmpl-split-think',
model: 'gpt-test',
choices: [{
index: 0,
delta: { content: 'ink>visible answer' },
finish_reason: null,
}],
}, context, 'gpt-test')).toMatchObject({
contentDelta: 'visible answer',
});
});
});
@@ -1,6 +1,12 @@
import { import {
decodeAnthropicReasoningSignature, decodeAnthropicReasoningSignature,
} from './reasoningTransport.js'; } from './reasoningTransport.js';
import {
consumeThinkTaggedText,
createThinkTagParserState,
extractInlineThinkTags,
type ThinkTagParserState,
} from './thinkTagParser.js';
export type DownstreamFormat = 'openai' | 'claude'; export type DownstreamFormat = 'openai' | 'claude';
@@ -16,6 +22,7 @@ export type StreamTransformContext = {
roleSent: boolean; roleSent: boolean;
doneSent: boolean; doneSent: boolean;
toolCalls: Record<number, { id?: string; name?: string; arguments?: string }>; toolCalls: Record<number, { id?: string; name?: string; arguments?: string }>;
thinkTagParser: ThinkTagParserState;
}; };
export type ClaudeDownstreamContext = { export type ClaudeDownstreamContext = {
@@ -102,8 +109,6 @@ function textFromPart(part: unknown): string {
if (typeof part.output_text === 'string') return part.output_text; if (typeof part.output_text === 'string') return part.output_text;
if (typeof part.completion === 'string') return part.completion; if (typeof part.completion === 'string') return part.completion;
if (typeof part.partial_json === 'string') return part.partial_json; if (typeof part.partial_json === 'string') return part.partial_json;
if (typeof part.reasoning_content === 'string') return part.reasoning_content;
if (typeof part.reasoning === 'string') return part.reasoning;
if (Array.isArray(part.content)) { if (Array.isArray(part.content)) {
return part.content.map((item) => textFromPart(item)).join(''); return part.content.map((item) => textFromPart(item)).join('');
@@ -118,13 +123,15 @@ function textFromPart(part: unknown): string {
} }
function extractTextAndReasoning(value: unknown): { content: string; reasoning: string } { function extractTextAndReasoning(value: unknown): { content: string; reasoning: string } {
if (typeof value === 'string') return { content: value, reasoning: '' }; if (typeof value === 'string') return extractInlineThinkTags(value);
if (Array.isArray(value)) { if (Array.isArray(value)) {
const contentParts: string[] = []; const contentParts: string[] = [];
const reasoningParts: string[] = []; const reasoningParts: string[] = [];
for (const item of value) { for (const item of value) {
if (typeof item === 'string') { if (typeof item === 'string') {
contentParts.push(item); const parsedString = extractInlineThinkTags(item);
if (parsedString.content) contentParts.push(parsedString.content);
if (parsedString.reasoning) reasoningParts.push(parsedString.reasoning);
continue; continue;
} }
if (!isRecord(item)) continue; if (!isRecord(item)) continue;
@@ -143,8 +150,9 @@ function extractTextAndReasoning(value: unknown): { content: string; reasoning:
continue; continue;
} }
const text = textFromPart(item); const parsedText = extractInlineThinkTags(textFromPart(item));
if (text) contentParts.push(text); if (parsedText.content) contentParts.push(parsedText.content);
if (parsedText.reasoning) reasoningParts.push(parsedText.reasoning);
} }
return { return {
@@ -159,9 +167,34 @@ function extractTextAndReasoning(value: unknown): { content: string; reasoning:
return extractTextAndReasoning(value.parts); return extractTextAndReasoning(value.parts);
} }
const directReasoning = joinNonEmpty([
typeof value.reasoning_content === 'string' ? value.reasoning_content : '',
typeof value.reasoning === 'string' ? value.reasoning : '',
typeof value.thinking === 'string' ? value.thinking : '',
]);
const parsedText = extractInlineThinkTags(textFromPart(value));
return { return {
content: textFromPart(value), content: parsedText.content,
reasoning: '', reasoning: joinNonEmpty([directReasoning, parsedText.reasoning]),
};
}
function extractStreamingTextAndReasoning(
value: unknown,
thinkTagParser: ThinkTagParserState,
): { content: string; reasoning: string } {
const parsed = extractTextAndReasoning(value);
if (!parsed.content) {
return parsed;
}
const streamed = consumeThinkTaggedText(thinkTagParser, parsed.content);
return {
content: streamed.content,
reasoning: [parsed.reasoning, streamed.reasoning]
.filter((part) => part.length > 0)
.join(''),
}; };
} }
@@ -218,6 +251,7 @@ export function createStreamTransformContext(modelName: string): StreamTransform
roleSent: false, roleSent: false,
doneSent: false, doneSent: false,
toolCalls: {}, toolCalls: {},
thinkTagParser: createThinkTagParserState(),
}; };
} }
@@ -254,10 +288,18 @@ function extractAssistantContent(choice: any): string {
const content = extractTextAndReasoning(choice?.content).content; const content = extractTextAndReasoning(choice?.content).content;
if (content) return content; if (content) return content;
if (typeof choice?.text === 'string' && choice.text.length > 0) return choice.text; if (typeof choice?.text === 'string' && choice.text.length > 0) {
if (typeof choice?.completion === 'string' && choice.completion.length > 0) return choice.completion; return extractInlineThinkTags(choice.text).content;
if (typeof choice?.output_text === 'string' && choice.output_text.length > 0) return choice.output_text; }
if (typeof choice?.delta?.content === 'string' && choice.delta.content.length > 0) return choice.delta.content; if (typeof choice?.completion === 'string' && choice.completion.length > 0) {
return extractInlineThinkTags(choice.completion).content;
}
if (typeof choice?.output_text === 'string' && choice.output_text.length > 0) {
return extractInlineThinkTags(choice.output_text).content;
}
if (typeof choice?.delta?.content === 'string' && choice.delta.content.length > 0) {
return extractInlineThinkTags(choice.delta.content).content;
}
return ''; return '';
} }
@@ -279,6 +321,11 @@ function extractAssistantReasoning(choice: any): string {
const nested = extractTextAndReasoning(choice?.content).reasoning; const nested = extractTextAndReasoning(choice?.content).reasoning;
if (nested) return nested; if (nested) return nested;
if (typeof choice?.delta?.content === 'string' && choice.delta.content.length > 0) {
const parsedDelta = extractInlineThinkTags(choice.delta.content);
if (parsedDelta.reasoning) return parsedDelta.reasoning;
}
return ''; return '';
} }
@@ -742,17 +789,19 @@ export function normalizeUpstreamStreamEvent(
const choice = payload.choices[0] ?? {}; const choice = payload.choices[0] ?? {};
const delta = isRecord(choice?.delta) ? choice.delta : {}; const delta = isRecord(choice?.delta) ? choice.delta : {};
const deltaParsed = extractTextAndReasoning(delta.content ?? delta); const deltaParsed = extractStreamingTextAndReasoning(delta.content ?? delta, context.thinkTagParser);
const messageParsed = extractStreamingTextAndReasoning(choice?.message?.content ?? '', context.thinkTagParser);
const rawContentDelta = const rawContentDelta =
deltaParsed.content deltaParsed.content
|| (typeof choice?.message?.content === 'string' ? choice.message.content : '') || messageParsed.content
|| ''; || '';
const reasoningDelta = const reasoningDelta =
(typeof (delta as any).reasoning_content === 'string' ? (delta as any).reasoning_content : '') (typeof (delta as any).reasoning_content === 'string' ? (delta as any).reasoning_content : '')
|| (typeof (delta as any).reasoning === 'string' ? (delta as any).reasoning : '') || (typeof (delta as any).reasoning === 'string' ? (delta as any).reasoning : '')
|| deltaParsed.reasoning || deltaParsed.reasoning
|| messageParsed.reasoning
|| ''; || '';
const reasoningSignature = isNonEmptyString((delta as any).reasoning_signature) const reasoningSignature = isNonEmptyString((delta as any).reasoning_signature)
? (delta as any).reasoning_signature ? (delta as any).reasoning_signature
@@ -810,11 +859,10 @@ export function normalizeUpstreamStreamEvent(
const type = typeof payload.type === 'string' ? payload.type : ''; const type = typeof payload.type === 'string' ? payload.type : '';
if (type.startsWith('response.output_text')) { if (type.startsWith('response.output_text')) {
const deltaText = typeof payload.delta === 'string' const parsed = extractStreamingTextAndReasoning(payload.delta, context.thinkTagParser);
? payload.delta
: extractTextAndReasoning(payload.delta).content;
return { return {
contentDelta: deltaText || undefined, contentDelta: parsed.content || undefined,
reasoningDelta: parsed.reasoning || undefined,
}; };
} }
@@ -975,7 +1023,7 @@ export function normalizeUpstreamStreamEvent(
}; };
} }
const parsed = extractTextAndReasoning(payload.content_block); const parsed = extractStreamingTextAndReasoning(payload.content_block, context.thinkTagParser);
return { return {
contentDelta: parsed.content || undefined, contentDelta: parsed.content || undefined,
reasoningDelta: parsed.reasoning || undefined, reasoningDelta: parsed.reasoning || undefined,
@@ -985,7 +1033,7 @@ export function normalizeUpstreamStreamEvent(
if (type === 'content_block_delta') { if (type === 'content_block_delta') {
const delta = isRecord(payload.delta) ? payload.delta : {}; const delta = isRecord(payload.delta) ? payload.delta : {};
const deltaType = typeof delta.type === 'string' ? delta.type : ''; const deltaType = typeof delta.type === 'string' ? delta.type : '';
const parsed = extractTextAndReasoning(delta); const parsed = extractStreamingTextAndReasoning(delta, context.thinkTagParser);
if (deltaType === 'input_json_delta') { if (deltaType === 'input_json_delta') {
const index = ( const index = (
@@ -1029,7 +1077,10 @@ export function normalizeUpstreamStreamEvent(
if (Array.isArray(payload.candidates)) { if (Array.isArray(payload.candidates)) {
const candidate = payload.candidates[0] || {}; const candidate = payload.candidates[0] || {};
const parsed = extractTextAndReasoning((candidate as any).content?.parts || (candidate as any).content); const parsed = extractStreamingTextAndReasoning(
(candidate as any).content?.parts || (candidate as any).content,
context.thinkTagParser,
);
if (isNonEmptyString((payload as any).modelVersion)) { if (isNonEmptyString((payload as any).modelVersion)) {
context.model = (payload as any).modelVersion; context.model = (payload as any).modelVersion;
@@ -1044,7 +1095,7 @@ export function normalizeUpstreamStreamEvent(
}; };
} }
const fallback = extractTextAndReasoning(payload); const fallback = extractStreamingTextAndReasoning(payload, context.thinkTagParser);
return { return {
contentDelta: fallback.content || undefined, contentDelta: fallback.content || undefined,
reasoningDelta: fallback.reasoning || undefined, reasoningDelta: fallback.reasoning || undefined,
@@ -1055,15 +1106,17 @@ function buildOpenAiStreamChunk(
context: StreamTransformContext, context: StreamTransformContext,
event: NormalizedStreamEvent, event: NormalizedStreamEvent,
): Record<string, unknown> | null { ): Record<string, unknown> | null {
const normalizedContentDelta = event.contentDelta || '';
const normalizedReasoningDelta = event.reasoningDelta || '';
const delta: Record<string, unknown> = {}; const delta: Record<string, unknown> = {};
const isInitialAssistantRoleOnlyEvent = ( const isInitialAssistantRoleOnlyEvent = (
!context.roleSent !context.roleSent
&& event.role === 'assistant' && event.role === 'assistant'
&& !event.contentDelta && !normalizedContentDelta
&& !event.reasoningDelta && !normalizedReasoningDelta
); );
if (!context.roleSent && (event.role === 'assistant' || event.contentDelta || event.reasoningDelta)) { if (!context.roleSent && (event.role === 'assistant' || normalizedContentDelta || normalizedReasoningDelta)) {
delta.role = 'assistant'; delta.role = 'assistant';
context.roleSent = true; context.roleSent = true;
} else if (event.role === 'assistant') { } else if (event.role === 'assistant') {
@@ -1071,12 +1124,12 @@ function buildOpenAiStreamChunk(
context.roleSent = true; context.roleSent = true;
} }
if (event.contentDelta) { if (normalizedContentDelta) {
delta.content = event.contentDelta; delta.content = normalizedContentDelta;
} }
if (event.reasoningDelta) { if (normalizedReasoningDelta) {
delta.reasoning_content = event.reasoningDelta; delta.reasoning_content = normalizedReasoningDelta;
} }
if (Array.isArray(event.toolCallDeltas) && event.toolCallDeltas.length > 0) { if (Array.isArray(event.toolCallDeltas) && event.toolCallDeltas.length > 0) {
@@ -0,0 +1,228 @@
import type { DownstreamFormat } from './normalized.js';
export type CompatibilityEndpoint = 'chat' | 'messages' | 'responses';
export type CompatibilityEndpointPreference = DownstreamFormat | 'responses';
type PreferResponsesAfterLegacyChatErrorInput = {
status: number;
upstreamErrorText?: string | null;
downstreamFormat: CompatibilityEndpointPreference;
sitePlatform?: string | null;
modelName?: string | null;
requestedModelHint?: string | null;
currentEndpoint?: CompatibilityEndpoint | null;
};
function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function normalizePlatformName(platform: unknown): string {
return asTrimmedString(platform).toLowerCase();
}
function isClaudeFamilyModel(modelName: string): boolean {
const normalized = asTrimmedString(modelName).toLowerCase();
if (!normalized) return false;
return normalized === 'claude' || normalized.startsWith('claude-') || normalized.includes('claude');
}
function headerValueToString(value: unknown): string | null {
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed || null;
}
if (Array.isArray(value)) {
for (const item of value) {
if (typeof item !== 'string') continue;
const trimmed = item.trim();
if (trimmed) return trimmed;
}
}
return null;
}
function normalizeHeaderMap(headers: Record<string, string>): Record<string, string> {
const normalized: Record<string, string> = {};
for (const [rawKey, rawValue] of Object.entries(headers)) {
const key = rawKey.trim().toLowerCase();
if (!key) continue;
const value = headerValueToString(rawValue);
if (!value) continue;
normalized[key] = value;
}
return normalized;
}
export function buildMinimalJsonHeadersForCompatibility(input: {
headers: Record<string, string>;
endpoint: CompatibilityEndpoint;
stream: boolean;
}): Record<string, string> {
const source = normalizeHeaderMap(input.headers);
const minimal: Record<string, string> = {};
if (source.authorization) minimal.authorization = source.authorization;
if (source['x-api-key']) minimal['x-api-key'] = source['x-api-key'];
if (input.endpoint === 'messages') {
for (const [key, value] of Object.entries(source)) {
if (!key.startsWith('anthropic-')) continue;
minimal[key] = value;
}
if (!minimal['anthropic-version']) {
minimal['anthropic-version'] = '2023-06-01';
}
}
minimal['content-type'] = 'application/json';
minimal.accept = input.stream ? 'text/event-stream' : 'application/json';
return minimal;
}
export function isUnsupportedMediaTypeError(status: number, upstreamErrorText?: string | null): boolean {
if (status < 400) return false;
if (status !== 400 && status !== 415) return false;
const text = (upstreamErrorText || '').toLowerCase();
if (!text) return status === 415;
return (
text.includes('unsupported media type')
|| text.includes("only 'application/json' is allowed")
|| text.includes('only "application/json" is allowed')
|| text.includes('application/json')
|| text.includes('content-type')
);
}
export function isEndpointDispatchDeniedError(status: number, upstreamErrorText?: string | null): boolean {
if (status !== 403) return false;
const text = (upstreamErrorText || '').toLowerCase();
if (!text) return false;
return (
/does\s+not\s+allow\s+\/v1\/[a-z0-9/_:-]+\s+dispatch/i.test(upstreamErrorText || '')
|| text.includes('dispatch denied')
);
}
export function shouldPreferResponsesAfterLegacyChatError(
input: PreferResponsesAfterLegacyChatErrorInput,
): boolean {
if (input.status < 400) return false;
if (input.downstreamFormat !== 'openai') return false;
if (input.currentEndpoint !== 'chat') return false;
const sitePlatform = normalizePlatformName(input.sitePlatform);
if (sitePlatform === 'openai' || sitePlatform === 'claude' || sitePlatform === 'gemini' || sitePlatform === 'anyrouter') {
return false;
}
const modelName = asTrimmedString(input.modelName);
const requestedModelHint = asTrimmedString(input.requestedModelHint);
if (isClaudeFamilyModel(modelName) || isClaudeFamilyModel(requestedModelHint)) {
return false;
}
const text = (input.upstreamErrorText || '').toLowerCase();
return (
text.includes('unsupported legacy protocol')
&& text.includes('/v1/chat/completions')
&& text.includes('/v1/responses')
);
}
export function promoteResponsesCandidateAfterLegacyChatError(
endpointCandidates: CompatibilityEndpoint[],
input: PreferResponsesAfterLegacyChatErrorInput,
): void {
if (!shouldPreferResponsesAfterLegacyChatError(input)) return;
const currentIndex = endpointCandidates.findIndex((endpoint) => endpoint === input.currentEndpoint);
const responsesIndex = endpointCandidates.indexOf('responses');
if (currentIndex < 0 || responsesIndex < 0 || responsesIndex <= currentIndex + 1) return;
endpointCandidates.splice(responsesIndex, 1);
endpointCandidates.splice(currentIndex + 1, 0, 'responses');
}
export function isEndpointDowngradeError(status: number, upstreamErrorText?: string | null): boolean {
if (status < 400) return false;
const text = (upstreamErrorText || '').toLowerCase();
if (status === 404 || status === 405 || status === 415 || status === 501) return true;
if (!text) return false;
let parsedCode = '';
let parsedType = '';
let parsedMessage = '';
try {
const parsed = JSON.parse(upstreamErrorText || '{}') as Record<string, unknown>;
const error = (parsed.error && typeof parsed.error === 'object')
? parsed.error as Record<string, unknown>
: parsed;
parsedCode = asTrimmedString(error.code).toLowerCase();
parsedType = asTrimmedString(error.type).toLowerCase();
parsedMessage = asTrimmedString(error.message).toLowerCase();
} catch {
parsedCode = '';
parsedType = '';
parsedMessage = '';
}
return (
isEndpointDispatchDeniedError(status, upstreamErrorText)
|| text.includes('convert_request_failed')
|| text.includes('not found')
|| text.includes('unknown endpoint')
|| text.includes('unsupported endpoint')
|| text.includes('unsupported path')
|| text.includes('unrecognized request url')
|| text.includes('no route matched')
|| text.includes('does not exist')
|| text.includes('openai_error')
|| text.includes('upstream_error')
|| text.includes('bad_response_status_code')
|| text.includes('unsupported media type')
|| text.includes("only 'application/json' is allowed")
|| text.includes('only "application/json" is allowed')
|| (status === 400 && text.includes('unsupported'))
|| text.includes('not implemented')
|| text.includes('api not implemented')
|| text.includes('unsupported legacy protocol')
|| parsedCode === 'convert_request_failed'
|| parsedCode === 'not_found'
|| parsedCode === 'endpoint_not_found'
|| parsedCode === 'unknown_endpoint'
|| parsedCode === 'unsupported_endpoint'
|| parsedCode === 'bad_response_status_code'
|| parsedCode === 'openai_error'
|| parsedCode === 'upstream_error'
|| parsedType === 'not_found_error'
|| parsedType === 'invalid_request_error'
|| parsedType === 'unsupported_endpoint'
|| parsedType === 'unsupported_path'
|| parsedType === 'bad_response_status_code'
|| parsedType === 'openai_error'
|| parsedType === 'upstream_error'
|| parsedMessage.includes('unknown endpoint')
|| parsedMessage.includes('unsupported endpoint')
|| parsedMessage.includes('unsupported path')
|| parsedMessage.includes('unrecognized request url')
|| parsedMessage.includes('no route matched')
|| parsedMessage.includes('does not exist')
|| parsedMessage.includes('bad_response_status_code')
|| parsedMessage === 'openai_error'
|| parsedMessage === 'upstream_error'
|| parsedMessage.includes('unsupported media type')
|| parsedMessage.includes("only 'application/json' is allowed")
|| parsedMessage.includes('only "application/json" is allowed')
|| (
status === 400
&& parsedCode === 'invalid_request'
&& parsedType === 'new_api_error'
&& (parsedMessage.includes('claude code cli') || text.includes('claude code cli'))
)
);
}
@@ -0,0 +1,122 @@
import { describe, expect, it } from 'vitest';
import {
ensureBase64DataUrl,
inferInputFileMimeType,
normalizeInputFileBlock,
toAnthropicDocumentBlock,
toOpenAiChatFileBlock,
toResponsesInputFileBlock,
} from './inputFile.js';
describe('shared input file helpers', () => {
it('preserves existing data URLs when building Responses input_file blocks', () => {
expect(toResponsesInputFileBlock({
fileData: 'data:application/pdf;base64,JVBERi0x',
filename: 'brief.pdf',
})).toEqual({
type: 'input_file',
file_data: 'data:application/pdf;base64,JVBERi0x',
filename: 'brief.pdf',
});
});
it('infers mime type from filename when wrapping raw base64 for Responses blocks', () => {
expect(toResponsesInputFileBlock({
fileData: 'JVBERi0x',
filename: 'brief.pdf',
})).toEqual({
type: 'input_file',
file_data: 'data:application/pdf;base64,JVBERi0x',
filename: 'brief.pdf',
});
});
it('prefers file_data over file_url and file_id when serializing Responses blocks', () => {
expect(toResponsesInputFileBlock({
fileData: 'JVBERi0x',
fileUrl: 'https://example.com/brief.pdf',
fileId: 'file_123',
filename: 'brief.pdf',
})).toEqual({
type: 'input_file',
file_data: 'data:application/pdf;base64,JVBERi0x',
filename: 'brief.pdf',
});
});
it('strips data URL wrappers for OpenAI chat file blocks while preserving mime type', () => {
expect(toOpenAiChatFileBlock({
fileData: 'data:application/pdf;base64,JVBERi0x',
filename: 'brief.pdf',
})).toEqual({
type: 'file',
file: {
file_data: 'JVBERi0x',
filename: 'brief.pdf',
mime_type: 'application/pdf',
},
});
});
it('creates anthropic document blocks from inline file data', () => {
expect(toAnthropicDocumentBlock({
fileData: 'data:text/plain;base64,SGVsbG8=',
filename: 'notes.txt',
hadDataUrl: true,
})).toEqual({
type: 'document',
cache_control: { type: 'ephemeral' },
source: {
type: 'base64',
media_type: 'text/plain',
data: 'SGVsbG8=',
},
title: 'notes.txt',
});
});
it('infers common mime types from filenames', () => {
expect(inferInputFileMimeType({ filename: 'notes.md', mimeType: null })).toBe('text/markdown');
expect(inferInputFileMimeType({ filename: 'photo.jpeg', mimeType: null })).toBe('image/jpeg');
expect(inferInputFileMimeType({ filename: 'voice.mp3', mimeType: null })).toBe('audio/mpeg');
});
it('leaves raw base64 unchanged when mime type is unknown', () => {
expect(ensureBase64DataUrl('YWJj', null)).toBe('YWJj');
});
it('prefers inline data when input_file blocks also carry file_url or file_id', () => {
expect(normalizeInputFileBlock({
type: 'input_file',
file_data: 'data:application/pdf;base64,JVBERi0x',
file_url: 'https://example.com/brief.pdf',
file_id: 'file_123',
filename: 'brief.pdf',
})).toEqual({
sourceType: 'input_file',
fileData: 'data:application/pdf;base64,JVBERi0x',
filename: 'brief.pdf',
mimeType: 'application/pdf',
hadDataUrl: true,
});
});
it('prefers inline data when file wrapper blocks also carry file_url or file_id', () => {
expect(normalizeInputFileBlock({
type: 'file',
file: {
file_data: 'JVBERi0x',
file_url: 'https://example.com/brief.pdf',
file_id: 'file_123',
filename: 'brief.pdf',
},
})).toEqual({
sourceType: 'file',
fileData: 'JVBERi0x',
filename: 'brief.pdf',
mimeType: null,
hadDataUrl: false,
});
});
});
+30 -6
View File
@@ -15,6 +15,20 @@ function splitBase64DataUrl(value: string): { mimeType: string; data: string } |
}; };
} }
function normalizeFileSourceSelection(
fileData: string,
fileUrl: string,
fileId: string,
): { fileData: string; fileUrl: string; fileId: string } {
if (fileData) {
return { fileData, fileUrl: '', fileId: '' };
}
if (fileUrl) {
return { fileData: '', fileUrl, fileId: '' };
}
return { fileData: '', fileUrl: '', fileId };
}
export function ensureBase64DataUrl(fileData: string, mimeType?: string | null): string { export function ensureBase64DataUrl(fileData: string, mimeType?: string | null): string {
const trimmedData = asTrimmedString(fileData); const trimmedData = asTrimmedString(fileData);
if (!trimmedData) return trimmedData; if (!trimmedData) return trimmedData;
@@ -58,9 +72,14 @@ export function normalizeInputFileBlock(item: Record<string, unknown>): Normaliz
const type = asTrimmedString(item.type).toLowerCase(); const type = asTrimmedString(item.type).toLowerCase();
if (type === 'input_file') { if (type === 'input_file') {
const fileId = asTrimmedString(item.file_id); const selectedSource = normalizeFileSourceSelection(
const fileData = asTrimmedString(item.file_data); asTrimmedString(item.file_data),
const fileUrl = asTrimmedString(item.file_url); asTrimmedString(item.file_url),
asTrimmedString(item.file_id),
);
const fileId = selectedSource.fileId;
const fileData = selectedSource.fileData;
const fileUrl = selectedSource.fileUrl;
const filename = asTrimmedString(item.filename); const filename = asTrimmedString(item.filename);
let mimeType = asTrimmedString(item.mime_type ?? item.mimeType) || null; let mimeType = asTrimmedString(item.mime_type ?? item.mimeType) || null;
if (!fileId && !fileData && !fileUrl) return null; if (!fileId && !fileData && !fileUrl) return null;
@@ -81,9 +100,14 @@ export function normalizeInputFileBlock(item: Record<string, unknown>): Normaliz
if (type === 'file') { if (type === 'file') {
const file = isRecord(item.file) ? item.file : item; const file = isRecord(item.file) ? item.file : item;
const fileId = asTrimmedString(file.file_id ?? item.file_id); const selectedSource = normalizeFileSourceSelection(
const fileData = asTrimmedString(file.file_data ?? item.file_data); asTrimmedString(file.file_data ?? item.file_data),
const fileUrl = asTrimmedString(file.file_url ?? item.file_url); asTrimmedString(file.file_url ?? item.file_url),
asTrimmedString(file.file_id ?? item.file_id),
);
const fileId = selectedSource.fileId;
const fileData = selectedSource.fileData;
const fileUrl = selectedSource.fileUrl;
const filename = asTrimmedString(file.filename ?? item.filename); const filename = asTrimmedString(file.filename ?? item.filename);
let mimeType = asTrimmedString(file.mime_type ?? file.mimeType ?? item.mime_type ?? item.mimeType) || null; let mimeType = asTrimmedString(file.mime_type ?? file.mimeType ?? item.mime_type ?? item.mimeType) || null;
if (!fileId && !fileData && !fileUrl) return null; if (!fileId && !fileData && !fileUrl) return null;
@@ -0,0 +1,88 @@
type PulledEventBatch<TEvent> = {
events: TEvent[];
rest: string;
};
type ProxyStreamReader = {
read(): Promise<{ done: boolean; value?: Uint8Array }>;
cancel(reason?: unknown): Promise<unknown>;
releaseLock(): void;
};
type ProxyStreamLifecycleInput<TEvent> = {
reader: ProxyStreamReader | null | undefined;
response: { end(): void };
pullEvents(buffer: string): PulledEventBatch<TEvent>;
handleEvent(event: TEvent): Promise<boolean | void> | boolean | void;
onEof?: () => Promise<void> | void;
};
export function createProxyStreamLifecycle<TEvent>(input: ProxyStreamLifecycleInput<TEvent>) {
const flushBuffer = async (buffer: string): Promise<{ rest: string; stop: boolean }> => {
const pulled = input.pullEvents(buffer);
for (const event of pulled.events) {
if (await input.handleEvent(event)) {
return {
rest: pulled.rest,
stop: true,
};
}
}
return {
rest: pulled.rest,
stop: false,
};
};
return {
async run(): Promise<void> {
const reader = input.reader;
if (!reader) {
try {
await input.onEof?.();
} finally {
input.response.end();
}
return;
}
const decoder = new TextDecoder();
let sseBuffer = '';
let shouldStop = false;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
sseBuffer += decoder.decode(value, { stream: true });
const flushed = await flushBuffer(sseBuffer);
sseBuffer = flushed.rest;
if (!flushed.stop) continue;
shouldStop = true;
await reader.cancel().catch(() => {});
break;
}
if (!shouldStop) {
sseBuffer += decoder.decode();
if (sseBuffer.trim().length > 0) {
const flushed = await flushBuffer(`${sseBuffer}\n\n`);
sseBuffer = flushed.rest;
shouldStop = flushed.stop;
}
}
if (!shouldStop) {
await input.onEof?.();
}
} finally {
reader.releaseLock();
input.response.end();
}
},
};
}
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import {
createProtocolRequestEnvelope,
createProtocolResponseEnvelope,
createProtocolStreamEnvelope,
} from './protocolModel.js';
describe('protocolModel', () => {
it('creates protocol request envelopes with shared top-level fields', () => {
const envelope = createProtocolRequestEnvelope({
protocol: 'openai/chat',
model: 'gpt-5',
stream: true,
rawBody: { model: 'gpt-5', stream: true },
parsed: { requestedModel: 'gpt-5', isStream: true },
metadata: { serviceTier: 'priority' },
});
expect(envelope).toEqual({
protocol: 'openai/chat',
model: 'gpt-5',
stream: true,
rawBody: { model: 'gpt-5', stream: true },
parsed: { requestedModel: 'gpt-5', isStream: true },
metadata: { serviceTier: 'priority' },
});
});
it('creates protocol response and stream envelopes without losing metadata', () => {
const response = createProtocolResponseEnvelope({
protocol: 'openai/responses',
model: 'gpt-5',
final: {
id: 'resp_1',
model: 'gpt-5',
created: 123,
content: 'done',
reasoningContent: 'think',
finishReason: 'stop',
toolCalls: [],
},
metadata: { citations: ['https://example.com'] },
});
const stream = createProtocolStreamEnvelope({
protocol: 'openai/responses',
model: 'gpt-5',
event: {
contentDelta: 'done',
finishReason: 'stop',
},
metadata: { citations: ['https://example.com'] },
});
expect(response.metadata).toEqual({ citations: ['https://example.com'] });
expect(stream.metadata).toEqual({ citations: ['https://example.com'] });
});
});
@@ -0,0 +1,70 @@
import type {
NormalizedFinalResponse,
NormalizedStreamEvent,
} from './normalized.js';
export type TransformerProtocol =
| 'openai/chat'
| 'anthropic/messages'
| 'openai/responses'
| 'gemini/generate-content';
export type ProtocolRequestEnvelope<
TProtocol extends TransformerProtocol = TransformerProtocol,
TParsed = unknown,
TMetadata = unknown,
> = {
protocol: TProtocol;
model: string;
stream: boolean;
rawBody: unknown;
parsed: TParsed;
metadata?: TMetadata;
};
export type ProtocolResponseEnvelope<
TProtocol extends TransformerProtocol = TransformerProtocol,
TFinal extends NormalizedFinalResponse = NormalizedFinalResponse,
TMetadata = unknown,
> = {
protocol: TProtocol;
model: string;
final: TFinal;
usage?: unknown;
metadata?: TMetadata;
};
export type ProtocolStreamEnvelope<
TProtocol extends TransformerProtocol = TransformerProtocol,
TEvent extends NormalizedStreamEvent = NormalizedStreamEvent,
TMetadata = unknown,
> = {
protocol: TProtocol;
model: string;
event: TEvent;
metadata?: TMetadata;
};
export function createProtocolRequestEnvelope<
TProtocol extends TransformerProtocol,
TParsed,
TMetadata = unknown,
>(envelope: ProtocolRequestEnvelope<TProtocol, TParsed, TMetadata>): ProtocolRequestEnvelope<TProtocol, TParsed, TMetadata> {
return envelope;
}
export function createProtocolResponseEnvelope<
TProtocol extends TransformerProtocol,
TFinal extends NormalizedFinalResponse,
TMetadata = unknown,
>(envelope: ProtocolResponseEnvelope<TProtocol, TFinal, TMetadata>): ProtocolResponseEnvelope<TProtocol, TFinal, TMetadata> {
return envelope;
}
export function createProtocolStreamEnvelope<
TProtocol extends TransformerProtocol,
TEvent extends NormalizedStreamEvent,
TMetadata = unknown,
>(envelope: ProtocolStreamEnvelope<TProtocol, TEvent, TMetadata>): ProtocolStreamEnvelope<TProtocol, TEvent, TMetadata> {
return envelope;
}
@@ -0,0 +1,107 @@
export type ThinkTagParserState = {
mode: 'content' | 'reasoning';
pending: string;
};
const OPEN_TAG = '<think>';
const CLOSE_TAG = '</think>';
function trailingPrefixLength(value: string, prefix: string): number {
const maxLength = Math.min(value.length, prefix.length - 1);
for (let length = maxLength; length > 0; length -= 1) {
if (prefix.startsWith(value.slice(-length))) {
return length;
}
}
return 0;
}
function consumeChunkAgainstTag(
source: string,
tag: string,
target: 'content' | 'reasoning',
): { emitted: string; rest: string; matched: boolean } {
const sourceLower = source.toLowerCase();
const tagIndex = sourceLower.indexOf(tag);
if (tagIndex >= 0) {
return {
emitted: source.slice(0, tagIndex),
rest: source.slice(tagIndex + tag.length),
matched: true,
};
}
const pendingLength = trailingPrefixLength(sourceLower, tag);
return {
emitted: source.slice(0, source.length - pendingLength),
rest: source.slice(source.length - pendingLength),
matched: false,
};
}
export function createThinkTagParserState(): ThinkTagParserState {
return {
mode: 'content',
pending: '',
};
}
export function consumeThinkTaggedText(
state: ThinkTagParserState,
chunk: string,
): { content: string; reasoning: string } {
if (!chunk) {
return { content: '', reasoning: '' };
}
let content = '';
let reasoning = '';
let rest = `${state.pending}${chunk}`;
state.pending = '';
while (rest.length > 0) {
const currentTag = state.mode === 'content' ? OPEN_TAG : CLOSE_TAG;
const consumed = consumeChunkAgainstTag(rest, currentTag, state.mode);
if (state.mode === 'content') {
content += consumed.emitted;
} else {
reasoning += consumed.emitted;
}
if (!consumed.matched) {
state.pending = consumed.rest;
break;
}
rest = consumed.rest;
state.mode = state.mode === 'content' ? 'reasoning' : 'content';
}
return { content, reasoning };
}
export function flushThinkTaggedText(state: ThinkTagParserState): { content: string; reasoning: string } {
if (!state.pending) {
return { content: '', reasoning: '' };
}
const remainder = state.pending;
state.pending = '';
return state.mode === 'content'
? { content: remainder, reasoning: '' }
: { content: '', reasoning: remainder };
}
export function extractInlineThinkTags(text: string): { content: string; reasoning: string } {
if (!text) {
return { content: '', reasoning: '' };
}
const state = createThinkTagParserState();
const consumed = consumeThinkTaggedText(state, text);
const flushed = flushThinkTaggedText(state);
return {
content: `${consumed.content}${flushed.content}`,
reasoning: `${consumed.reasoning}${flushed.reasoning}`,
};
}
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('App topbar tooltips', () => {
it('removes topbar hover tooltips while preserving sidebar collapsed tooltips', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
const topbarStart = source.indexOf('<div className="topbar-right">');
const topbarEnd = source.indexOf('</header>');
expect(topbarStart).toBeGreaterThanOrEqual(0);
expect(topbarEnd).toBeGreaterThan(topbarStart);
const topbarSection = source.slice(topbarStart, topbarEnd);
expect(topbarSection).not.toContain('data-tooltip=');
expect(source).toContain("data-tooltip={sidebarCollapsed ? t(item.label) : undefined}");
});
});
+4 -7
View File
@@ -3,6 +3,7 @@ import { Routes, Route, NavLink, Navigate, useLocation } from 'react-router-dom'
import { ToastProvider, useToast } from './components/Toast.js'; import { ToastProvider, useToast } from './components/Toast.js';
import SearchModal from './components/SearchModal.js'; import SearchModal from './components/SearchModal.js';
import NotificationPanel from './components/NotificationPanel.js'; import NotificationPanel from './components/NotificationPanel.js';
import TooltipLayer from './components/TooltipLayer.js';
import { api } from './api.js'; import { api } from './api.js';
import { clearAuthSession, hasValidAuthSession, persistAuthSession } from './authSession.js'; import { clearAuthSession, hasValidAuthSession, persistAuthSession } from './authSession.js';
import { import {
@@ -558,20 +559,19 @@ function AppShell() {
<div className="topbar-right"> <div className="topbar-right">
<button <button
className="topbar-icon-btn" className="topbar-icon-btn"
data-tooltip={language === 'zh' ? 'Switch to English' : '切换到中文'}
aria-label={language === 'zh' ? 'Switch to English' : '切换到中文'} aria-label={language === 'zh' ? 'Switch to English' : '切换到中文'}
onClick={toggleLanguage} onClick={toggleLanguage}
style={{ minWidth: 36, fontSize: 12, fontWeight: 700 }} style={{ minWidth: 36, fontSize: 12, fontWeight: 700 }}
> >
{language === 'zh' ? 'EN' : '中'} {language === 'zh' ? 'EN' : '中'}
</button> </button>
<button className="topbar-search-trigger" data-tooltip={t('搜索 (Ctrl+K)')} aria-label={t('搜索 (Ctrl+K)')} onClick={() => setShowSearch(true)}> <button className="topbar-search-trigger" aria-label={t('搜索 (Ctrl+K)')} onClick={() => setShowSearch(true)}>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg> <svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
<span className="topbar-search-label">{t('搜索')}</span> <span className="topbar-search-label">{t('搜索')}</span>
<kbd className="topbar-search-kbd">Ctrl K</kbd> <kbd className="topbar-search-kbd">Ctrl K</kbd>
</button> </button>
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
<button ref={notifBtnRef} className="topbar-icon-btn" data-tooltip={t('通知')} aria-label={t('通知')} onClick={() => setShowNotifications(!showNotifications)}> <button ref={notifBtnRef} className="topbar-icon-btn" aria-label={t('通知')} onClick={() => setShowNotifications(!showNotifications)}>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /></svg> <svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /></svg>
{unreadCount > 0 && ( {unreadCount > 0 && (
<span className="topbar-badge"> <span className="topbar-badge">
@@ -584,9 +584,6 @@ function AppShell() {
<div ref={themeMenuRef} style={{ position: 'relative' }}> <div ref={themeMenuRef} style={{ position: 'relative' }}>
<button <button
className="topbar-icon-btn" className="topbar-icon-btn"
data-tooltip={themeMode === 'system'
? `${t('跟随系统')} (${resolvedThemeLabel})`
: (themeMode === 'light' ? t('浅色模式') : t('深色模式'))}
aria-label={themeMode === 'system' aria-label={themeMode === 'system'
? `${t('跟随系统')} (${resolvedThemeLabel})` ? `${t('跟随系统')} (${resolvedThemeLabel})`
: (themeMode === 'light' ? t('浅色模式') : t('深色模式'))} : (themeMode === 'light' ? t('浅色模式') : t('深色模式'))}
@@ -632,7 +629,6 @@ function AppShell() {
<div ref={userMenuRef} style={{ position: 'relative' }}> <div ref={userMenuRef} style={{ position: 'relative' }}>
<div <div
className="topbar-avatar" className="topbar-avatar"
data-tooltip={displayName}
aria-label={displayName} aria-label={displayName}
onClick={() => { onClick={() => {
setShowUserMenu(!showUserMenu); setShowUserMenu(!showUserMenu);
@@ -741,6 +737,7 @@ export default function App() {
<I18nProvider> <I18nProvider>
<ToastProvider> <ToastProvider>
<AppShell /> <AppShell />
<TooltipLayer />
</ToastProvider> </ToastProvider>
</I18nProvider> </I18nProvider>
); );
+229
View File
@@ -0,0 +1,229 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
type TooltipSide = 'top' | 'bottom';
type TooltipAlign = 'start' | 'center' | 'end';
type ActiveTooltip = {
target: HTMLElement;
text: string;
side: TooltipSide;
align: TooltipAlign;
};
type TooltipPosition = {
left: number;
top: number;
arrowLeft: number;
side: TooltipSide;
};
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function readTooltipSide(target: HTMLElement): TooltipSide {
return target.getAttribute('data-tooltip-side') === 'bottom' ? 'bottom' : 'top';
}
function readTooltipAlign(target: HTMLElement): TooltipAlign {
const align = target.getAttribute('data-tooltip-align');
if (align === 'start' || align === 'end') return align;
return 'center';
}
function resolveTooltipTarget(eventTarget: EventTarget | null): HTMLElement | null {
if (!(eventTarget instanceof Element)) return null;
const target = eventTarget.closest<HTMLElement>('[data-tooltip]');
if (!target) return null;
const text = target.getAttribute('data-tooltip');
return text && text.trim() ? target : null;
}
export default function TooltipLayer() {
const [activeTooltip, setActiveTooltip] = useState<ActiveTooltip | null>(null);
const [position, setPosition] = useState<TooltipPosition | null>(null);
const bubbleRef = useRef<HTMLDivElement | null>(null);
const rafRef = useRef<number | null>(null);
const cancelFrame = useCallback(() => {
if (rafRef.current === null || typeof window === 'undefined') return;
window.cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}, []);
const hideTooltip = useCallback(() => {
cancelFrame();
setActiveTooltip(null);
setPosition(null);
}, [cancelFrame]);
const showTooltipForTarget = useCallback((target: HTMLElement | null) => {
if (!target) {
hideTooltip();
return;
}
const text = target.getAttribute('data-tooltip')?.trim();
if (!text) {
hideTooltip();
return;
}
setActiveTooltip({
target,
text,
side: readTooltipSide(target),
align: readTooltipAlign(target),
});
}, [hideTooltip]);
const refreshPosition = useCallback(() => {
if (!activeTooltip || !bubbleRef.current || typeof window === 'undefined') return;
if (!activeTooltip.target.isConnected) {
hideTooltip();
return;
}
const targetRect = activeTooltip.target.getBoundingClientRect();
const bubbleRect = bubbleRef.current.getBoundingClientRect();
const viewportPadding = 12;
const gap = 10;
let left = targetRect.left;
if (activeTooltip.align === 'center') {
left = targetRect.left + targetRect.width / 2 - bubbleRect.width / 2;
} else if (activeTooltip.align === 'end') {
left = targetRect.right - bubbleRect.width;
}
let top = activeTooltip.side === 'bottom'
? targetRect.bottom + gap
: targetRect.top - gap - bubbleRect.height;
left = clamp(left, viewportPadding, window.innerWidth - viewportPadding - bubbleRect.width);
top = clamp(top, viewportPadding, window.innerHeight - viewportPadding - bubbleRect.height);
const targetCenter = targetRect.left + targetRect.width / 2;
const arrowLeft = clamp(targetCenter - left, 14, bubbleRect.width - 14);
setPosition({
left,
top,
arrowLeft,
side: activeTooltip.side,
});
}, [activeTooltip, hideTooltip]);
const scheduleRefresh = useCallback(() => {
cancelFrame();
if (typeof window === 'undefined') return;
rafRef.current = window.requestAnimationFrame(() => {
rafRef.current = null;
refreshPosition();
});
}, [cancelFrame, refreshPosition]);
useEffect(() => {
if (typeof document === 'undefined') return;
document.body.dataset.tooltipPortal = 'true';
return () => {
delete document.body.dataset.tooltipPortal;
};
}, []);
useEffect(() => {
if (typeof document === 'undefined') return;
const handleMouseOver = (event: MouseEvent) => {
showTooltipForTarget(resolveTooltipTarget(event.target));
};
const handleFocusIn = (event: FocusEvent) => {
showTooltipForTarget(resolveTooltipTarget(event.target));
};
const handleMouseOut = (event: MouseEvent) => {
if (!activeTooltip) return;
const nextTarget = resolveTooltipTarget(event.relatedTarget);
if (nextTarget === activeTooltip.target) return;
if (event.relatedTarget instanceof Node && activeTooltip.target.contains(event.relatedTarget)) return;
hideTooltip();
};
const handleFocusOut = (event: FocusEvent) => {
if (!activeTooltip) return;
const nextTarget = resolveTooltipTarget(event.relatedTarget);
if (nextTarget === activeTooltip.target) return;
if (event.relatedTarget instanceof Node && activeTooltip.target.contains(event.relatedTarget)) return;
hideTooltip();
};
const handlePointerDown = (event: Event) => {
if (!activeTooltip) return;
if (event.target instanceof Node && activeTooltip.target.contains(event.target)) return;
hideTooltip();
};
document.addEventListener('mouseover', handleMouseOver);
document.addEventListener('focusin', handleFocusIn);
document.addEventListener('mouseout', handleMouseOut);
document.addEventListener('focusout', handleFocusOut);
document.addEventListener('pointerdown', handlePointerDown);
return () => {
document.removeEventListener('mouseover', handleMouseOver);
document.removeEventListener('focusin', handleFocusIn);
document.removeEventListener('mouseout', handleMouseOut);
document.removeEventListener('focusout', handleFocusOut);
document.removeEventListener('pointerdown', handlePointerDown);
};
}, [activeTooltip, hideTooltip, showTooltipForTarget]);
useLayoutEffect(() => {
if (!activeTooltip) return;
setPosition(null);
scheduleRefresh();
}, [activeTooltip, scheduleRefresh]);
useEffect(() => {
if (!activeTooltip || typeof window === 'undefined') return;
const handleViewportChange = () => scheduleRefresh();
window.addEventListener('resize', handleViewportChange);
window.addEventListener('scroll', handleViewportChange, true);
return () => {
window.removeEventListener('resize', handleViewportChange);
window.removeEventListener('scroll', handleViewportChange, true);
};
}, [activeTooltip, scheduleRefresh]);
useEffect(() => () => cancelFrame(), [cancelFrame]);
if (!activeTooltip || typeof document === 'undefined') return null;
const tooltip = (
<div className="tooltip-layer" aria-hidden="true">
<div
ref={bubbleRef}
className={`tooltip-bubble tooltip-bubble-${position?.side ?? activeTooltip.side} ${position ? 'is-visible' : ''}`.trim()}
style={position ? {
position: 'fixed',
left: position.left,
top: position.top,
} : {
position: 'fixed',
left: 0,
top: 0,
visibility: 'hidden',
}}
>
{activeTooltip.text}
<span
className={`tooltip-bubble-arrow tooltip-bubble-arrow-${position?.side ?? activeTooltip.side}`}
style={position ? { left: position.arrowLeft } : undefined}
/>
</div>
</div>
);
return createPortal(tooltip, document.body);
}
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('TooltipLayer component', () => {
it('uses a portal-based fixed tooltip layer and is mounted by App', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/components/TooltipLayer.tsx'), 'utf8');
const appSource = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
expect(source).toContain('createPortal');
expect(source).toContain('[data-tooltip]');
expect(source).toContain("position: 'fixed'");
expect(source).toContain('document.body.dataset.tooltipPortal');
expect(appSource).toContain("import TooltipLayer from './components/TooltipLayer.js'");
expect(appSource).toContain('<TooltipLayer />');
});
});
+2
View File
@@ -69,6 +69,8 @@ const zhToEn: Record<string, string> = {
'任务状态已更新': 'Task status updated', '任务状态已更新': 'Task status updated',
'会话已过期,请重新登录': 'Session expired, please sign in again', '会话已过期,请重新登录': 'Session expired, please sign in again',
'首次使用建议先阅读站点文档:': 'For first-time setup, read site docs: ', '首次使用建议先阅读站点文档:': 'For first-time setup, read site docs: ',
'首次使用建议先阅读快速上手:': 'For first-time setup, start with Quick Start: ',
'首次使用建议先阅读快速开始:': 'For first-time setup, start with Quick Start: ',
'个人信息已保存': 'Profile saved', '个人信息已保存': 'Profile saved',
'搜索': 'Search', '搜索': 'Search',
'搜索 (Ctrl+K)': 'Search (Ctrl+K)', '搜索 (Ctrl+K)': 'Search (Ctrl+K)',
+131 -2
View File
@@ -1608,6 +1608,11 @@ body {
position: relative; position: relative;
} }
body[data-tooltip-portal="true"] [data-tooltip]::after,
body[data-tooltip-portal="true"] [data-tooltip]::before {
display: none !important;
}
[data-tooltip]::after { [data-tooltip]::after {
content: attr(data-tooltip); content: attr(data-tooltip);
position: absolute; position: absolute;
@@ -1625,7 +1630,7 @@ body {
text-align: left; text-align: left;
white-space: normal; white-space: normal;
pointer-events: none; pointer-events: none;
z-index: 40; z-index: 20000;
opacity: 0; opacity: 0;
color: color-mix(in srgb, var(--color-text-primary) 90%, #0f172a); color: color-mix(in srgb, var(--color-text-primary) 90%, #0f172a);
background: color-mix(in srgb, #ffffff 92%, var(--color-primary) 8%); background: color-mix(in srgb, #ffffff 92%, var(--color-primary) 8%);
@@ -1643,7 +1648,7 @@ body {
height: 8px; height: 8px;
transform: translateX(-50%) rotate(45deg); transform: translateX(-50%) rotate(45deg);
pointer-events: none; pointer-events: none;
z-index: 39; z-index: 19999;
opacity: 0; opacity: 0;
background: color-mix(in srgb, #ffffff 92%, var(--color-primary) 8%); background: color-mix(in srgb, #ffffff 92%, var(--color-primary) 8%);
border-right: 1px solid color-mix(in srgb, var(--color-primary) 22%, transparent); border-right: 1px solid color-mix(in srgb, var(--color-primary) 22%, transparent);
@@ -1651,6 +1656,45 @@ body {
transition: opacity 0.18s ease; transition: opacity 0.18s ease;
} }
[data-tooltip][data-tooltip-side="bottom"]::after {
top: calc(100% + 10px);
bottom: auto;
transform: translate(-50%, -6px) scale(0.98);
}
[data-tooltip][data-tooltip-side="bottom"]::before {
top: calc(100% + 4px);
bottom: auto;
transform: translateX(-50%) rotate(225deg);
border-right: none;
border-bottom: none;
border-left: 1px solid color-mix(in srgb, var(--color-primary) 22%, transparent);
border-top: 1px solid color-mix(in srgb, var(--color-primary) 22%, transparent);
}
[data-tooltip][data-tooltip-align="start"]::after {
left: 0;
right: auto;
transform: translate(0, 6px) scale(0.98);
}
[data-tooltip][data-tooltip-align="start"]::before {
left: 24px;
right: auto;
}
[data-tooltip][data-tooltip-align="end"]::after {
left: auto;
right: 0;
transform: translate(0, 6px) scale(0.98);
}
[data-tooltip][data-tooltip-align="end"]::before {
left: auto;
right: 24px;
transform: translateX(50%) rotate(225deg);
}
[data-tooltip]:hover::after, [data-tooltip]:hover::after,
[data-tooltip]:hover::before, [data-tooltip]:hover::before,
[data-tooltip]:focus-visible::after, [data-tooltip]:focus-visible::after,
@@ -1663,6 +1707,18 @@ body {
transform: translate(-50%, 0) scale(1); transform: translate(-50%, 0) scale(1);
} }
[data-tooltip][data-tooltip-side="bottom"]:hover::after,
[data-tooltip][data-tooltip-side="bottom"]:focus-visible::after {
transform: translate(-50%, 0) scale(1);
}
[data-tooltip][data-tooltip-align="start"]:hover::after,
[data-tooltip][data-tooltip-align="start"]:focus-visible::after,
[data-tooltip][data-tooltip-align="end"]:hover::after,
[data-tooltip][data-tooltip-align="end"]:focus-visible::after {
transform: translate(0, 0) scale(1);
}
.missing-token-site-tag { .missing-token-site-tag {
border: 1px solid color-mix(in srgb, var(--color-info) 35%, transparent); border: 1px solid color-mix(in srgb, var(--color-info) 35%, transparent);
background: color-mix(in srgb, var(--color-info) 12%, var(--color-bg)); background: color-mix(in srgb, var(--color-info) 12%, var(--color-bg));
@@ -1753,6 +1809,79 @@ body {
border-bottom-color: color-mix(in srgb, var(--color-primary) 35%, transparent); border-bottom-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
} }
.tooltip-layer {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 20000;
}
.tooltip-bubble {
width: max-content;
min-width: 220px;
max-width: min(520px, calc(100vw - 40px));
padding: 6px 10px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
line-height: 1.45;
text-align: left;
white-space: normal;
color: color-mix(in srgb, var(--color-text-primary) 90%, #0f172a);
background: color-mix(in srgb, #ffffff 92%, var(--color-primary) 8%);
border: 1px solid color-mix(in srgb, var(--color-primary) 22%, transparent);
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.14);
opacity: 0;
transform: scale(0.98);
transition: opacity 0.18s ease, transform 0.18s ease;
}
.tooltip-bubble.is-visible {
opacity: 1;
transform: scale(1);
}
.tooltip-bubble-arrow {
position: absolute;
width: 8px;
height: 8px;
background: color-mix(in srgb, #ffffff 92%, var(--color-primary) 8%);
transform: translateX(-50%) rotate(45deg);
}
.tooltip-bubble-arrow-top {
top: calc(100% - 5px);
border-right: 1px solid color-mix(in srgb, var(--color-primary) 22%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--color-primary) 22%, transparent);
}
.tooltip-bubble-arrow-bottom {
top: -5px;
border-left: 1px solid color-mix(in srgb, var(--color-primary) 22%, transparent);
border-top: 1px solid color-mix(in srgb, var(--color-primary) 22%, transparent);
}
[data-theme="dark"] .tooltip-bubble {
color: color-mix(in srgb, #e2e8f0 94%, white);
background: color-mix(in srgb, #0f172a 88%, var(--color-primary) 12%);
border-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
box-shadow: 0 12px 30px rgba(2, 6, 23, 0.48);
}
[data-theme="dark"] .tooltip-bubble-arrow {
background: color-mix(in srgb, #0f172a 88%, var(--color-primary) 12%);
}
[data-theme="dark"] .tooltip-bubble-arrow-top {
border-right-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
border-bottom-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
}
[data-theme="dark"] .tooltip-bubble-arrow-bottom {
border-left-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
border-top-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
}
[data-theme="dark"] .route-enable-toggle.is-enabled { [data-theme="dark"] .route-enable-toggle.is-enabled {
background: #052e16; background: #052e16;
color: #4ade80; color: #4ade80;
+16 -5
View File
@@ -18,6 +18,18 @@ import { SITE_DOCS_URL } from '../docsLink.js';
type ConnectionsSegment = 'session' | 'apikey' | 'tokens'; type ConnectionsSegment = 'session' | 'apikey' | 'tokens';
const ACCOUNT_SEGMENTS: Array<{
value: ConnectionsSegment;
label: string;
tooltip: string;
tooltipSide: 'top' | 'bottom';
tooltipAlign: 'start' | 'center' | 'end';
}> = [
{ value: 'session', label: '账号管理', tooltip: '用于签到、余额、状态维护', tooltipSide: 'bottom', tooltipAlign: 'start' },
{ value: 'apikey', label: 'API Key管理', tooltip: '只有 Base URL + Key 时使用,只负责代理调用', tooltipSide: 'bottom', tooltipAlign: 'center' },
{ value: 'tokens', label: '账号令牌管理', tooltip: '从账号同步或手动维护,供路由实际调用', tooltipSide: 'bottom', tooltipAlign: 'end' },
];
function createLoginForm() { function createLoginForm() {
return { siteId: 0, username: '', password: '' }; return { siteId: 0, username: '', password: '' };
} }
@@ -745,15 +757,14 @@ export default function Accounts() {
borderRadius: 'var(--radius-md)', borderRadius: 'var(--radius-md)',
}} }}
> >
{[ {ACCOUNT_SEGMENTS.map((segment) => (
{ value: 'session' as ConnectionsSegment, label: 'Session 连接' },
{ value: 'apikey' as ConnectionsSegment, label: 'API Key 连接' },
{ value: 'tokens' as ConnectionsSegment, label: '账号令牌' },
].map((segment) => (
<button <button
key={segment.value} key={segment.value}
type="button" type="button"
onClick={() => setSegment(segment.value)} onClick={() => setSegment(segment.value)}
data-tooltip={segment.tooltip}
data-tooltip-side={segment.tooltipSide}
data-tooltip-align={segment.tooltipAlign}
style={{ style={{
padding: '8px 12px', padding: '8px 12px',
borderRadius: 8, borderRadius: 8,
+2
View File
@@ -1252,6 +1252,7 @@ export default function TokenRoutes() {
}; };
const handleCreateTokenForMissingAccount = (accountId: number, modelName: string) => { const handleCreateTokenForMissingAccount = (accountId: number, modelName: string) => {
if (!Number.isFinite(accountId) || accountId <= 0) return;
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set('create', '1'); params.set('create', '1');
params.set('accountId', String(accountId)); params.set('accountId', String(accountId));
@@ -1897,6 +1898,7 @@ export default function TokenRoutes() {
const siteMap = new Map<string, MissingTokenRouteSiteActionItem>(); const siteMap = new Map<string, MissingTokenRouteSiteActionItem>();
for (const hint of missingTokenHints) { for (const hint of missingTokenHints) {
for (const account of hint.accounts) { 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 siteName = (account.siteName || '').trim() || `site-${account.siteId || 'unknown'}`;
const key = `${account.siteId || 0}::${siteName.toLowerCase()}`; const key = `${account.siteId || 0}::${siteName.toLowerCase()}`;
const accountLabel = account.username || `account-${account.accountId}`; const accountLabel = account.username || `account-${account.accountId}`;
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create } from 'react-test-renderer'; import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js'; import { ToastProvider } from '../components/Toast.js';
import Accounts from './Accounts.js'; import Accounts from './Accounts.js';
@@ -23,6 +23,12 @@ async function flushMicrotasks() {
}); });
} }
function collectText(node: ReactTestInstance): string {
return (node.children || [])
.map((child) => (typeof child === 'string' ? child : collectText(child)))
.join('');
}
describe('Accounts segmented connections view', () => { describe('Accounts segmented connections view', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -76,10 +82,28 @@ describe('Accounts segmented connections view', () => {
const rendered = JSON.stringify(root.toJSON()); const rendered = JSON.stringify(root.toJSON());
expect(rendered).toContain('连接管理'); expect(rendered).toContain('连接管理');
expect(rendered).toContain('API Key 连接'); expect(rendered).toContain('账号管理');
expect(rendered).toContain('API Key管理');
expect(rendered).toContain('账号令牌管理');
expect(rendered).toContain('用于签到、余额、状态维护');
expect(rendered).toContain('只有 Base URL + Key 时使用,只负责代理调用');
expect(rendered).toContain('从账号同步或手动维护,供路由实际调用');
expect(rendered).toContain('Key Site'); expect(rendered).toContain('Key Site');
expect(rendered).not.toContain('仅代理'); expect(rendered).not.toContain('仅代理');
expect(rendered).not.toContain('session-user'); expect(rendered).not.toContain('session-user');
const segmentButtons = root.root.findAll((node) => {
if (node.type !== 'button') return false;
const text = collectText(node);
return text === '账号管理' || text === 'API Key管理' || text === '账号令牌管理';
});
expect(segmentButtons).toHaveLength(3);
expect(segmentButtons[0]?.props['data-tooltip-side']).toBe('bottom');
expect(segmentButtons[0]?.props['data-tooltip-align']).toBe('start');
expect(segmentButtons[1]?.props['data-tooltip-side']).toBe('bottom');
expect(segmentButtons[1]?.props['data-tooltip-align']).toBe('center');
expect(segmentButtons[2]?.props['data-tooltip-side']).toBe('bottom');
expect(segmentButtons[2]?.props['data-tooltip-align']).toBe('end');
} finally { } finally {
root?.unmount(); root?.unmount();
} }
@@ -240,6 +240,51 @@ describe('TokenRoutes grouped source models', () => {
} }
}); });
it('does not render missing-token site tags when the hint lacks a valid account id', async () => {
apiMock.getRoutes.mockResolvedValue([
{
id: 1,
modelPattern: 'gpt-5.2-codex',
displayName: 'gpt-5.2-codex',
enabled: true,
channels: [],
},
]);
apiMock.getModelTokenCandidates.mockResolvedValue({
models: {},
modelsWithoutToken: {
'gpt-5.2-codex': [
{
accountId: 0,
username: 'shenmo-direct',
siteId: 12,
siteName: '神墨',
},
],
},
});
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const text = collectText(root.root);
expect(text).not.toContain('待注册站点');
expect(text).not.toContain('神墨');
} finally {
root?.unmount();
}
});
it('maps endpoint types to expected brand icons in filter panel', async () => { it('maps endpoint types to expected brand icons in filter panel', async () => {
apiMock.getRoutes.mockResolvedValue([ apiMock.getRoutes.mockResolvedValue([
{ {