forked from boos/BoosAPI
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:
+13
-35
@@ -5,7 +5,7 @@ export default withMermaid(
|
||||
defineConfig({
|
||||
lang: 'zh-CN',
|
||||
title: 'Metapi 文档',
|
||||
description: '面向普通用户的 Metapi 使用文档',
|
||||
description: 'Metapi 使用文档、FAQ 与维护协作指南',
|
||||
cleanUrls: true,
|
||||
lastUpdated: true,
|
||||
ignoreDeadLinks: true,
|
||||
@@ -14,51 +14,29 @@ export default withMermaid(
|
||||
logo: '/logos/logo-icon-512.png',
|
||||
nav: [
|
||||
{ text: '首页', link: '/' },
|
||||
{ text: '快速开始', link: '/getting-started' },
|
||||
{ text: '快速上手', link: '/getting-started' },
|
||||
{ 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' },
|
||||
],
|
||||
sidebar: [
|
||||
{
|
||||
text: '开始使用',
|
||||
text: '开始',
|
||||
items: [
|
||||
{ text: '文档首页', link: '/' },
|
||||
{ text: '快速开始', link: '/getting-started' },
|
||||
{ text: '快速上手', link: '/getting-started' },
|
||||
{ text: '部署指南', link: '/deployment' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: '上游接入',
|
||||
text: '使用与运维',
|
||||
items: [
|
||||
{ text: '上游接入总览', link: '/upstream-integration' },
|
||||
{ text: 'New API', link: '/upstream/new-api' },
|
||||
{ text: 'Sub2API', link: '/upstream/sub2api' },
|
||||
{ text: 'AnyRouter', link: '/upstream/anyrouter' },
|
||||
{ text: 'One API', link: '/upstream/one-api' },
|
||||
{ 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' },
|
||||
{ text: '上游接入', link: '/upstream-integration' },
|
||||
{ text: '配置说明', link: '/configuration' },
|
||||
{ text: '客户端接入', link: '/client-integration' },
|
||||
{ text: '运维手册', link: '/operations' },
|
||||
{ text: '常见问题 FAQ', link: '/faq' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -28,6 +28,7 @@ npm run docs:build
|
||||
|------|--------|------------|
|
||||
| 对外第一印象、产品定位、核心入口 | [文档首页](/) | 需要调整公开落地页信息架构、首页 CTA 或首屏导航时 |
|
||||
| 新用户部署与首条请求 | [快速上手](./getting-started.md) | 新安装流程、默认端口、首次调用步骤变化时 |
|
||||
| 上游平台选择与接法 | [上游接入](./upstream-integration.md) | 平台支持范围、默认连接分段、自动识别规则变化时 |
|
||||
| 生产部署与回滚 | [部署指南](./deployment.md) | Docker Compose、反向代理、升级回滚策略变更时 |
|
||||
| 环境变量、参数和配置项 | [配置说明](./configuration.md) | 新增配置、默认值变化、兼容行为变化时 |
|
||||
| 客户端与工具接入 | [客户端接入](./client-integration.md) | Open WebUI、Cherry Studio、Cursor 等接入方式变化时 |
|
||||
|
||||
@@ -118,9 +118,11 @@ npm run dev
|
||||
进入 **站点管理**,添加你使用的上游中转站:
|
||||
|
||||
- 填写站点名称(自己想怎么取就怎么取)和 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(可选,部分功能需要)
|
||||
|
||||
如果你不确定该选哪个平台,先看 [上游接入](./upstream-integration.md)。
|
||||
|
||||

|
||||
|
||||
### 步骤 2:添加账号
|
||||
@@ -206,6 +208,7 @@ curl -sS http://127.0.0.1:4312/v1/models \
|
||||
|
||||
## 下一步
|
||||
|
||||
- [上游接入](./upstream-integration.md) — 当前代码支持哪些上游、默认该走哪个连接分段
|
||||
- [部署指南](./deployment.md) — 反向代理、HTTPS、升级策略
|
||||
- [配置说明](./configuration.md) — 详细环境变量与路由参数
|
||||
- [客户端接入](./client-integration.md) — 对接 Open WebUI、Cherry Studio 等
|
||||
|
||||
+7
-6
@@ -12,23 +12,23 @@ hero:
|
||||
- theme: brand
|
||||
text: 10 分钟快速上手
|
||||
link: /getting-started
|
||||
- theme: alt
|
||||
text: 上游接入
|
||||
link: /upstream-integration
|
||||
- theme: alt
|
||||
text: 常见问题 FAQ
|
||||
link: /faq
|
||||
- theme: alt
|
||||
text: 文档维护与贡献
|
||||
link: /README
|
||||
|
||||
features:
|
||||
- title: 快速上手
|
||||
details: 从部署到第一条请求,按步骤完成最小可用环境搭建。
|
||||
link: /getting-started
|
||||
- title: 上游接入
|
||||
details: 按当前代码支持的平台类型,快速判断该选什么平台、先走哪个连接分段。
|
||||
link: /upstream-integration
|
||||
- title: 问题排查
|
||||
details: 汇总高频报错、根因定位和标准修复路径,降低重复沟通成本。
|
||||
link: /faq
|
||||
- title: 运维与配置
|
||||
details: 覆盖部署、配置、监控、备份、升级回滚等生产场景。
|
||||
link: /operations
|
||||
---
|
||||
|
||||
## 项目架构
|
||||
@@ -289,6 +289,7 @@ onBeforeUnmount(() => {
|
||||
## 从这里开始
|
||||
|
||||
- 初次部署或首次接入:从 [快速上手](/getting-started) 开始,先跑通最小可用链路。
|
||||
- 不确定上游平台该怎么选:先看 [上游接入](/upstream-integration),再决定走 `账号管理` 还是 `API Key管理`。
|
||||
- 准备上线或升级回滚:查看 [部署指南](/deployment) 与 [运维手册](/operations)。
|
||||
- 需要补齐环境变量或路由参数:直接查 [配置说明](/configuration)。
|
||||
- 正在处理客户端或第三方工具接入:优先看 [客户端接入](/client-integration)。
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ensureSiteSchemaCompatibility, type SiteSchemaInspector } from './siteS
|
||||
import { ensureRouteGroupingSchemaCompatibility } from './routeGroupingSchemaCompatibility.js';
|
||||
import { ensureProxyFileSchemaCompatibility } from './proxyFileSchemaCompatibility.js';
|
||||
import { config } from '../config.js';
|
||||
import { ensureRuntimeDatabaseReady } from '../runtimeDatabaseBootstrap.js';
|
||||
import { mkdirSync } from 'fs';
|
||||
import { dirname, resolve } from 'path';
|
||||
|
||||
@@ -938,6 +939,11 @@ export async function switchRuntimeDatabase(nextDialect: RuntimeDbDialect, nextD
|
||||
|
||||
try {
|
||||
activeDb = initDb();
|
||||
await ensureRuntimeDatabaseReady({
|
||||
dialect: nextDialect,
|
||||
connectionString: nextDbUrl,
|
||||
ssl: config.dbSsl,
|
||||
});
|
||||
} catch (error) {
|
||||
await closeDbConnections();
|
||||
runtimeDbDialect = previousDialect;
|
||||
|
||||
@@ -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
@@ -24,6 +24,7 @@ import { buildStartupSummaryLines } from './services/startupInfo.js';
|
||||
import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js';
|
||||
import { migrateSiteApiKeysToAccounts } from './services/siteApiKeyMigrationService.js';
|
||||
import { ensureDefaultSitesSeeded } from './services/defaultSiteSeedService.js';
|
||||
import { ensureRuntimeDatabaseReady } from './runtimeDatabaseBootstrap.js';
|
||||
import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js';
|
||||
import { existsSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
@@ -40,18 +41,6 @@ import {
|
||||
type RuntimeDbDialect,
|
||||
} 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 }>) {
|
||||
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.
|
||||
await ensureSqliteRuntimeMigrations();
|
||||
// Ensure the current runtime database is bootstrapped before reading settings.
|
||||
await ensureRuntimeDatabaseReady({
|
||||
dialect: runtimeDbDialect,
|
||||
connectionString: config.dbUrl,
|
||||
ssl: config.dbSsl,
|
||||
});
|
||||
|
||||
// Load runtime config overrides from settings
|
||||
try {
|
||||
@@ -208,12 +201,27 @@ try {
|
||||
const initialMap = toSettingsMap(initialRows);
|
||||
const savedDbConfig = extractSavedRuntimeDatabaseConfig(initialMap);
|
||||
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)) {
|
||||
try {
|
||||
await switchRuntimeDatabase(savedDbConfig.dialect, savedDbConfig.dbUrl, savedDbConfig.ssl);
|
||||
await ensureSqliteRuntimeMigrations();
|
||||
console.log(`Loaded runtime DB config from settings: ${savedDbConfig.dialect}`);
|
||||
} 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'}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,44 @@ describe('/api/models/token-candidates', () => {
|
||||
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 () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
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({
|
||||
name: 'site-e',
|
||||
url: 'https://site-e.example.com',
|
||||
@@ -425,19 +463,11 @@ describe('/api/models/token-candidates', () => {
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(fetchModelPricingCatalogMock).toHaveBeenCalled();
|
||||
expect(fetchModelPricingCatalogMock.mock.calls[0]?.[0]).toMatchObject({
|
||||
site: {
|
||||
id: site.id,
|
||||
url: site.url,
|
||||
platform: site.platform,
|
||||
},
|
||||
account: {
|
||||
id: account.id,
|
||||
accessToken: '',
|
||||
apiToken: null,
|
||||
},
|
||||
});
|
||||
expect(fetchModelPricingCatalogMock.mock.calls[0]?.[0]?.site?.apiKey).toBeUndefined();
|
||||
const body = response.json() as {
|
||||
modelsMissingTokenGroups: Record<string, Array<{ accountId: number }>>;
|
||||
};
|
||||
|
||||
expect(fetchModelPricingCatalogMock).not.toHaveBeenCalled();
|
||||
expect(body.modelsMissingTokenGroups['claude-opus-4-6']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
parseProxyLogBillingDetails,
|
||||
withProxyLogSelectFields,
|
||||
} from '../../services/proxyLogStore.js';
|
||||
import { getCredentialModeFromExtraConfig } from '../../services/accountExtraConfig.js';
|
||||
import {
|
||||
formatUtcSqlDateTime,
|
||||
getLocalDayRangeUtc,
|
||||
@@ -30,6 +31,12 @@ function parseBooleanFlag(raw?: string): boolean {
|
||||
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_PRICING_TTL_MS = 90_000;
|
||||
|
||||
@@ -711,6 +718,8 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
username: schema.accounts.username,
|
||||
siteId: schema.sites.id,
|
||||
siteName: schema.sites.name,
|
||||
accessToken: schema.accounts.accessToken,
|
||||
extraConfig: schema.accounts.extraConfig,
|
||||
})
|
||||
.from(schema.modelAvailability)
|
||||
.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) {
|
||||
if (isApiKeyConnection(row)) continue;
|
||||
const modelName = (row.modelName || '').trim();
|
||||
if (!modelName) continue;
|
||||
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 hasPotentialGroupHints = hasAnyTokenGroupSignals || unknownGroupCoverageByAccountModel.size > 0;
|
||||
|
||||
@@ -862,6 +876,7 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
for (const row of availableModelRows) {
|
||||
if (isApiKeyConnection(row)) continue;
|
||||
const modelName = (row.modelName || '').trim();
|
||||
if (!modelName) continue;
|
||||
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('shouldRetryClaudeMessagesWithNormalizedBody(');
|
||||
expect(source).not.toContain('buildOpenAiSyntheticFinalStream(');
|
||||
expect(source).toContain('anthropicMessagesTransformer.consumeSseEventBlock(');
|
||||
expect(source).toContain('anthropicMessagesTransformer.serializeUpstreamFinalAsStream(');
|
||||
expect(source).toContain('openAiChatTransformer.serializeUpstreamFinalAsStream(');
|
||||
expect(source).not.toContain('anthropicMessagesTransformer.consumeSseEventBlock(');
|
||||
expect(source).not.toContain('anthropicMessagesTransformer.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', () => {
|
||||
@@ -44,16 +58,27 @@ describe('proxy route architecture boundaries', () => {
|
||||
expect(source).not.toContain('function shouldDowngradeFromChatToMessagesForResponses(');
|
||||
expect(source).not.toContain('function normalizeText(');
|
||||
expect(source).toContain('openAiResponsesTransformer.inbound.toOpenAiBody(');
|
||||
expect(source).toContain('openAiResponsesTransformer.compatibility.buildRetryBodies(');
|
||||
expect(source).toContain('openAiResponsesTransformer.compatibility.buildRetryHeaders(');
|
||||
expect(source).toContain('openAiResponsesTransformer.compatibility.shouldRetry(');
|
||||
expect(source).toContain('openAiResponsesTransformer.compatibility.shouldDowngradeChatToMessages(');
|
||||
expect(source).toContain('openAiResponsesTransformer.aggregator.createState(');
|
||||
expect(source).toContain('openAiResponsesTransformer.aggregator.serialize(');
|
||||
expect(source).toContain('openAiResponsesTransformer.aggregator.complete(');
|
||||
expect(source).toContain('openAiResponsesTransformer.compatibility.createEndpointStrategy(');
|
||||
expect(source).not.toContain('openAiResponsesTransformer.aggregator.createState(');
|
||||
expect(source).not.toContain('openAiResponsesTransformer.aggregator.serialize(');
|
||||
expect(source).not.toContain('openAiResponsesTransformer.aggregator.complete(');
|
||||
expect(source).toContain('openAiResponsesTransformer.proxyStream.createSession(');
|
||||
expect(source).toContain('streamSession.run(');
|
||||
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', () => {
|
||||
const source = readSource('./upstreamEndpoint.ts');
|
||||
expect(source).not.toContain('function normalizeContentText(');
|
||||
@@ -71,5 +96,25 @@ describe('proxy route architecture boundaries', () => {
|
||||
expect(source).toContain('stream.consumeUpstreamSseBuffer(');
|
||||
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\'');
|
||||
});
|
||||
});
|
||||
@@ -201,6 +201,117 @@ describe('chat proxy stream behavior', () => {
|
||||
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 () => {
|
||||
const encoder = new TextEncoder();
|
||||
const upstreamBody = new ReadableStream<Uint8Array>({
|
||||
@@ -486,6 +597,40 @@ describe('chat proxy stream behavior', () => {
|
||||
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 () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
id: 'msg_headers_adaptive',
|
||||
@@ -839,7 +984,7 @@ describe('chat proxy stream behavior', () => {
|
||||
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 upstreamBody = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
@@ -867,6 +1012,9 @@ describe('chat proxy stream behavior', () => {
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers['content-type']).toContain('text/event-stream');
|
||||
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]');
|
||||
});
|
||||
|
||||
@@ -1524,7 +1672,7 @@ describe('chat proxy stream behavior', () => {
|
||||
expect(deltaMatches.length).toBe(1);
|
||||
const textMatches = response.body.match(/I'm Claude, an AI assistant made by Anthropic\./g) || [];
|
||||
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 () => {
|
||||
|
||||
+112
-256
@@ -11,12 +11,7 @@ import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParse
|
||||
import { resolveProxyUrlForSite, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
|
||||
import { type DownstreamFormat } from '../../transformers/shared/normalized.js';
|
||||
import {
|
||||
buildMinimalJsonHeadersForCompatibility,
|
||||
buildUpstreamEndpointRequest,
|
||||
isEndpointDispatchDeniedError,
|
||||
isEndpointDowngradeError,
|
||||
isUnsupportedMediaTypeError,
|
||||
promoteResponsesCandidateAfterLegacyChatError,
|
||||
resolveUpstreamEndpointCandidates,
|
||||
} from './upstreamEndpoint.js';
|
||||
import {
|
||||
@@ -25,11 +20,12 @@ import {
|
||||
recordDownstreamCostUsage,
|
||||
} from './downstreamPolicy.js';
|
||||
import { composeProxyLogMessage } from './logPathMeta.js';
|
||||
import { executeEndpointFlow, withUpstreamPath } from './endpointFlow.js';
|
||||
import { executeEndpointFlow } from './endpointFlow.js';
|
||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
import { resolveProxyLogBilling } from './proxyBilling.js';
|
||||
import { openAiChatTransformer } from '../../transformers/openai/chat/index.js';
|
||||
import { anthropicMessagesTransformer } from '../../transformers/anthropic/messages/index.js';
|
||||
import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js';
|
||||
import { getProxyResourceOwner } from '../../middleware/auth.js';
|
||||
import {
|
||||
ProxyInputFileResolutionError,
|
||||
@@ -39,10 +35,6 @@ import {
|
||||
|
||||
const MAX_RETRIES = 2;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
|
||||
export async function chatProxyRoute(app: FastifyInstance) {
|
||||
app.post('/v1/chat/completions', async (request: FastifyRequest, reply: FastifyReply) =>
|
||||
handleChatProxyRequest(request, reply, 'openai'));
|
||||
@@ -61,13 +53,24 @@ async function handleChatProxyRequest(
|
||||
const downstreamTransformer = downstreamFormat === 'claude'
|
||||
? anthropicMessagesTransformer
|
||||
: openAiChatTransformer;
|
||||
const parsedRequest = downstreamTransformer.transformRequest(request.body);
|
||||
if (parsedRequest.error) {
|
||||
return reply.code(parsedRequest.error.statusCode).send(parsedRequest.error.payload);
|
||||
const downstreamPath = downstreamFormat === 'claude' ? '/v1/messages' : '/v1/chat/completions';
|
||||
const clientContext = detectDownstreamClientContext({
|
||||
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 downstreamPath = downstreamFormat === 'claude' ? '/v1/messages' : '/v1/chat/completions';
|
||||
const requestEnvelope = parsedRequestEnvelope.value!;
|
||||
const {
|
||||
requestedModel,
|
||||
isStream,
|
||||
upstreamBody,
|
||||
claudeOriginalBody,
|
||||
} = requestEnvelope.parsed;
|
||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||
const owner = getProxyResourceOwner(request);
|
||||
@@ -124,14 +127,10 @@ async function handleChatProxyRequest(
|
||||
},
|
||||
),
|
||||
];
|
||||
let startTime = Date.now();
|
||||
|
||||
try {
|
||||
const endpointResult = await executeEndpointFlow({
|
||||
siteUrl: selected.site.url,
|
||||
proxyUrl: resolveProxyUrlForSite(selected.site),
|
||||
endpointCandidates,
|
||||
buildRequest: (endpoint) => {
|
||||
const buildEndpointRequest = (
|
||||
endpoint: 'chat' | 'messages' | 'responses',
|
||||
options: { forceNormalizeClaudeBody?: boolean } = {},
|
||||
) => {
|
||||
const endpointRequest = buildUpstreamEndpointRequest({
|
||||
endpoint,
|
||||
modelName,
|
||||
@@ -142,6 +141,7 @@ async function handleChatProxyRequest(
|
||||
openaiBody: resolvedOpenAiBody,
|
||||
downstreamFormat,
|
||||
claudeOriginalBody,
|
||||
forceNormalizeClaudeBody: options.forceNormalizeClaudeBody,
|
||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||
});
|
||||
return {
|
||||
@@ -150,107 +150,37 @@ async function handleChatProxyRequest(
|
||||
headers: endpointRequest.headers,
|
||||
body: endpointRequest.body as Record<string, unknown>,
|
||||
};
|
||||
},
|
||||
tryRecover: async (ctx) => {
|
||||
if (anthropicMessagesTransformer.compatibility.shouldRetryNormalizedBody({
|
||||
};
|
||||
const endpointStrategy = downstreamTransformer.compatibility.createEndpointStrategy({
|
||||
downstreamFormat,
|
||||
endpointPath: ctx.request.path,
|
||||
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,
|
||||
endpointCandidates,
|
||||
modelName,
|
||||
requestedModelHint: requestedModel,
|
||||
currentEndpoint: ctx.request.endpoint,
|
||||
});
|
||||
return (
|
||||
ctx.response.status >= 500
|
||||
|| isEndpointDowngradeError(ctx.response.status, ctx.rawErrText)
|
||||
|| anthropicMessagesTransformer.compatibility.isMessagesRequiredError(ctx.rawErrText)
|
||||
|| isEndpointDispatchDeniedError(ctx.response.status, ctx.rawErrText)
|
||||
);
|
||||
})()
|
||||
sitePlatform: selected.site.platform,
|
||||
isStream,
|
||||
buildRequest: ({ endpoint, forceNormalizeClaudeBody }) => buildEndpointRequest(
|
||||
endpoint,
|
||||
{ forceNormalizeClaudeBody },
|
||||
),
|
||||
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) => {
|
||||
logProxy(
|
||||
selected,
|
||||
@@ -261,6 +191,13 @@ async function handleChatProxyRequest(
|
||||
ctx.errText,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
clientContext,
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -269,7 +206,23 @@ async function handleChatProxyRequest(
|
||||
const status = endpointResult.status || 502;
|
||||
const errText = endpointResult.errText || 'unknown error';
|
||||
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 })) {
|
||||
await reportTokenExpired({
|
||||
@@ -306,8 +259,6 @@ async function handleChatProxyRequest(
|
||||
reply.raw.setHeader('Connection', 'keep-alive');
|
||||
reply.raw.setHeader('X-Accel-Buffering', 'no');
|
||||
|
||||
const streamContext = downstreamTransformer.createStreamContext(modelName);
|
||||
const claudeContext = anthropicMessagesTransformer.createDownstreamContext();
|
||||
let parsedUsage: ReturnType<typeof parseProxyUsage> = {
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
@@ -322,10 +273,19 @@ async function handleChatProxyRequest(
|
||||
reply.raw.write(line);
|
||||
}
|
||||
};
|
||||
|
||||
const writeDone = () => {
|
||||
writeLines(downstreamTransformer.serializeDone(streamContext, claudeContext));
|
||||
};
|
||||
const streamSession = openAiChatTransformer.proxyStream.createSession({
|
||||
downstreamFormat,
|
||||
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();
|
||||
if (!upstreamContentType.includes('text/event-stream')) {
|
||||
@@ -336,29 +296,7 @@ async function handleChatProxyRequest(
|
||||
} catch {
|
||||
fallbackData = fallbackText;
|
||||
}
|
||||
|
||||
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();
|
||||
streamSession.consumeUpstreamFinalPayload(fallbackData, fallbackText, reply.raw);
|
||||
|
||||
const latency = Date.now() - startTime;
|
||||
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
|
||||
@@ -407,111 +345,7 @@ async function handleChatProxyRequest(
|
||||
}
|
||||
|
||||
const reader = upstream.body?.getReader();
|
||||
if (!reader) {
|
||||
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();
|
||||
}
|
||||
await streamSession.run(reader, reply.raw);
|
||||
|
||||
const latency = Date.now() - startTime;
|
||||
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
|
||||
@@ -618,7 +452,23 @@ async function handleChatProxyRequest(
|
||||
return reply.send(downstreamResponse);
|
||||
} catch (err: any) {
|
||||
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) {
|
||||
retryCount += 1;
|
||||
@@ -655,10 +505,16 @@ async function logProxy(
|
||||
estimatedCost = 0,
|
||||
billingDetails: unknown = null,
|
||||
upstreamPath: string | null = null,
|
||||
clientContext: DownstreamClientContext | null = null,
|
||||
) {
|
||||
try {
|
||||
const createdAt = formatUtcSqlDateTime(new Date());
|
||||
const normalizedErrorMessage = composeProxyLogMessage({
|
||||
clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic'
|
||||
? clientContext.clientKind
|
||||
: null,
|
||||
sessionId: clientContext?.sessionId || null,
|
||||
traceHint: clientContext?.traceHint || null,
|
||||
downstreamPath,
|
||||
upstreamPath,
|
||||
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: '',
|
||||
})).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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
type ComposeProxyLogMessageArgs = {
|
||||
clientKind?: string | null;
|
||||
sessionId?: string | null;
|
||||
traceHint?: string | null;
|
||||
downstreamPath?: string | null;
|
||||
upstreamPath?: string | null;
|
||||
errorMessage?: string | null;
|
||||
};
|
||||
|
||||
type ParsedPathMeta = {
|
||||
clientKind: string | null;
|
||||
sessionId: string | null;
|
||||
downstreamPath: string | null;
|
||||
upstreamPath: string | null;
|
||||
messageText: string;
|
||||
};
|
||||
|
||||
function parseExistingPathMeta(rawMessage: string): ParsedPathMeta {
|
||||
const clientMatch = rawMessage.match(/\[client:([^\]]+)\]/i);
|
||||
const sessionMatch = rawMessage.match(/\[session:([^\]]+)\]/i);
|
||||
const downstreamMatch = rawMessage.match(/\[downstream:([^\]]+)\]/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 {
|
||||
clientKind: clientMatch?.[1]?.trim() || null,
|
||||
sessionId: sessionMatch?.[1]?.trim() || null,
|
||||
downstreamPath: downstreamMatch?.[1]?.trim() || null,
|
||||
upstreamPath: upstreamMatch?.[1]?.trim() || null,
|
||||
messageText,
|
||||
@@ -22,17 +34,24 @@ function parseExistingPathMeta(rawMessage: string): ParsedPathMeta {
|
||||
}
|
||||
|
||||
export function composeProxyLogMessage({
|
||||
clientKind,
|
||||
sessionId,
|
||||
traceHint,
|
||||
downstreamPath,
|
||||
upstreamPath,
|
||||
errorMessage,
|
||||
}: ComposeProxyLogMessageArgs): string | null {
|
||||
const rawMessage = typeof errorMessage === 'string' ? errorMessage.trim() : '';
|
||||
const parsed = parseExistingPathMeta(rawMessage);
|
||||
const finalClientKind = (clientKind || parsed.clientKind || '').trim();
|
||||
const finalSessionId = (sessionId || traceHint || parsed.sessionId || '').trim();
|
||||
const finalDownstreamPath = (downstreamPath || parsed.downstreamPath || '').trim();
|
||||
const finalUpstreamPath = (upstreamPath || parsed.upstreamPath || '').trim();
|
||||
const finalMessageText = parsed.messageText.trim();
|
||||
|
||||
const prefixParts: string[] = [];
|
||||
if (finalClientKind) prefixParts.push(`[client:${finalClientKind}]`);
|
||||
if (finalSessionId) prefixParts.push(`[session:${finalSessionId}]`);
|
||||
if (finalDownstreamPath) prefixParts.push(`[downstream:${finalDownstreamPath}]`);
|
||||
if (finalUpstreamPath) prefixParts.push(`[upstream:${finalUpstreamPath}]`);
|
||||
|
||||
|
||||
@@ -11,10 +11,7 @@ import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParse
|
||||
import { resolveProxyUrlForSite, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
|
||||
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
|
||||
import {
|
||||
buildMinimalJsonHeadersForCompatibility,
|
||||
buildUpstreamEndpointRequest,
|
||||
isEndpointDowngradeError,
|
||||
isUnsupportedMediaTypeError,
|
||||
resolveUpstreamEndpointCandidates,
|
||||
} from './upstreamEndpoint.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 { resolveProxyLogBilling } from './proxyBilling.js';
|
||||
import { getProxyResourceOwner } from '../../middleware/auth.js';
|
||||
import { detectDownstreamClientContext, isCodexResponsesSurface, type DownstreamClientContext } from './downstreamClientContext.js';
|
||||
import { normalizeInputFileBlock } from '../../transformers/shared/inputFile.js';
|
||||
import {
|
||||
ProxyInputFileResolutionError,
|
||||
@@ -85,42 +83,6 @@ function carriesResponsesReasoningContinuity(value: unknown): boolean {
|
||||
|| 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 {
|
||||
if (!isRecord(body)) return false;
|
||||
const include = normalizeIncludeList(body.include);
|
||||
@@ -156,16 +118,27 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
reply: FastifyReply,
|
||||
downstreamPath: string,
|
||||
) => {
|
||||
const body = request.body as any;
|
||||
const requestedModel = typeof body?.model === 'string' ? body.model.trim() : '';
|
||||
if (!requestedModel) {
|
||||
return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } });
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const clientContext = detectDownstreamClientContext({
|
||||
downstreamPath,
|
||||
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;
|
||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||
const isCompactRequest = downstreamPath === '/v1/responses/compact';
|
||||
|
||||
const isStream = body.stream === true;
|
||||
const excludeChannelIds: number[] = [];
|
||||
let retryCount = 0;
|
||||
|
||||
@@ -193,15 +166,11 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
|
||||
const modelName = selected.actualModel || requestedModel;
|
||||
const owner = getProxyResourceOwner(request);
|
||||
const defaultEncryptedReasoningInclude = isCodexResponsesSurface(
|
||||
request.headers as Record<string, unknown>,
|
||||
);
|
||||
let normalizedResponsesBody = openAiResponsesTransformer.inbound.sanitizeProxyBody(
|
||||
body,
|
||||
modelName,
|
||||
isStream,
|
||||
{ defaultEncryptedReasoningInclude },
|
||||
);
|
||||
let normalizedResponsesBody: Record<string, unknown> = {
|
||||
...requestEnvelope.parsed.normalizedBody,
|
||||
model: modelName,
|
||||
stream: isStream,
|
||||
};
|
||||
if (owner) {
|
||||
try {
|
||||
normalizedResponsesBody = await resolveResponsesBodyInputFiles(normalizedResponsesBody, owner);
|
||||
@@ -248,15 +217,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
if (endpointCandidates.length === 0) {
|
||||
endpointCandidates.push('responses', 'chat', 'messages');
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const endpointResult = await executeEndpointFlow({
|
||||
siteUrl: selected.site.url,
|
||||
proxyUrl: resolveProxyUrlForSite(selected.site),
|
||||
endpointCandidates,
|
||||
buildRequest: (endpoint) => {
|
||||
const buildEndpointRequest = (endpoint: 'chat' | 'messages' | 'responses') => {
|
||||
const endpointRequest = buildUpstreamEndpointRequest({
|
||||
endpoint,
|
||||
modelName,
|
||||
@@ -280,90 +241,30 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
headers: endpointRequest.headers,
|
||||
body: endpointRequest.body as Record<string, unknown>,
|
||||
};
|
||||
},
|
||||
tryRecover: async (ctx) => {
|
||||
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,
|
||||
};
|
||||
const endpointStrategy = openAiResponsesTransformer.compatibility.createEndpointStrategy({
|
||||
isStream,
|
||||
);
|
||||
|
||||
for (const compatibilityHeadersCandidate of compatibilityHeaders) {
|
||||
for (const compatibilityBody of compatibilityBodies) {
|
||||
const compatibilityResponse = await fetch(
|
||||
ctx.targetUrl,
|
||||
requiresNativeResponsesFileUrl,
|
||||
dispatchRequest: (compatibilityRequest, targetUrl) => fetch(
|
||||
targetUrl ?? `${selected.site.url}${compatibilityRequest.path}`,
|
||||
withSiteRecordProxyRequestInit(selected.site, {
|
||||
method: 'POST',
|
||||
headers: compatibilityHeadersCandidate,
|
||||
body: JSON.stringify(compatibilityBody),
|
||||
headers: compatibilityRequest.headers,
|
||||
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) => {
|
||||
logProxy(
|
||||
selected,
|
||||
@@ -374,6 +275,13 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
ctx.errText,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
clientContext,
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -382,7 +290,23 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
const status = endpointResult.status || 502;
|
||||
const errText = endpointResult.errText || 'unknown error';
|
||||
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 })) {
|
||||
await reportTokenExpired({
|
||||
@@ -416,12 +340,6 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
reply.raw.setHeader('X-Accel-Buffering', 'no');
|
||||
|
||||
const reader = upstream.body?.getReader();
|
||||
if (!reader) {
|
||||
reply.raw.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let parsedUsage: UsageSummary = {
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
@@ -430,102 +348,24 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
cacheCreationTokens: 0,
|
||||
promptTokensIncludeCache: null,
|
||||
};
|
||||
let sseBuffer = '';
|
||||
|
||||
const passthroughResponsesStream = successfulUpstreamPath === '/v1/responses';
|
||||
const streamContext = openAiResponsesTransformer.createStreamContext(modelName);
|
||||
const responsesState = openAiResponsesTransformer.aggregator.createState(modelName);
|
||||
|
||||
const writeLines = (lines: string[]) => {
|
||||
for (const line of lines) reply.raw.write(line);
|
||||
};
|
||||
|
||||
const consumeSseBuffer = (incoming: string): string => {
|
||||
const pulled = openAiResponsesTransformer.pullSseEvents(incoming);
|
||||
for (const eventBlock of pulled.events) {
|
||||
if (eventBlock.data === '[DONE]') {
|
||||
if (passthroughResponsesStream) {
|
||||
reply.raw.write('data: [DONE]\n\n');
|
||||
} 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();
|
||||
const streamSession = openAiResponsesTransformer.proxyStream.createSession({
|
||||
modelName,
|
||||
successfulUpstreamPath,
|
||||
getUsage: () => parsedUsage,
|
||||
onParsedPayload: (payload) => {
|
||||
if (payload && typeof payload === 'object') {
|
||||
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(payload));
|
||||
}
|
||||
},
|
||||
writeLines,
|
||||
writeRaw: (chunk) => {
|
||||
reply.raw.write(chunk);
|
||||
},
|
||||
});
|
||||
await streamSession.run(reader, reply.raw);
|
||||
|
||||
const latency = Date.now() - startTime;
|
||||
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
|
||||
@@ -612,7 +452,23 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
return reply.send(downstreamData);
|
||||
} catch (err: any) {
|
||||
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) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
@@ -649,10 +505,16 @@ async function logProxy(
|
||||
estimatedCost = 0,
|
||||
billingDetails: unknown = null,
|
||||
upstreamPath: string | null = null,
|
||||
clientContext: DownstreamClientContext | null = null,
|
||||
) {
|
||||
try {
|
||||
const createdAt = formatUtcSqlDateTime(new Date());
|
||||
const normalizedErrorMessage = composeProxyLogMessage({
|
||||
clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic'
|
||||
? clientContext.clientKind
|
||||
: null,
|
||||
sessionId: clientContext?.sessionId || null,
|
||||
traceHint: clientContext?.traceHint || null,
|
||||
downstreamPath,
|
||||
upstreamPath,
|
||||
errorMessage,
|
||||
|
||||
@@ -8,6 +8,14 @@ import {
|
||||
convertOpenAiBodyToAnthropicMessagesBody,
|
||||
sanitizeAnthropicMessagesBody,
|
||||
} 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 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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -10,7 +10,8 @@ describe('alertRules', () => {
|
||||
|
||||
it('detects token expiration by status or message', () => {
|
||||
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: 'token invalid' })).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);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
expect(appendSessionTokenRebindHint('无权进行此操作,access token 无效'))
|
||||
.toContain('请在中转站重新生成系统访问令牌后重新绑定账号');
|
||||
|
||||
@@ -6,9 +6,25 @@ export function isCloudflareChallenge(message?: string | null): boolean {
|
||||
|
||||
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 {
|
||||
if (input.status === 401 || input.status === 403) return true;
|
||||
const rawMessage = input.message || '';
|
||||
const text = (input.message || '').toLowerCase();
|
||||
if (isEndpointDispatchDeniedMessage(rawMessage)) return false;
|
||||
if (input.status === 401 || containsHttpStatus(rawMessage, 401)) return true;
|
||||
if (!text) return false;
|
||||
|
||||
// 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') ||
|
||||
(tokenPhrase && (hasInvalid || hasExpired)) ||
|
||||
/invalid\s+access\s+token/.test(text) ||
|
||||
/access\s+token\s+is\s+invalid/.test(text) ||
|
||||
text.includes('unauthorized') ||
|
||||
text.includes('forbidden')
|
||||
/access\s+token\s+is\s+invalid/.test(text)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -172,6 +172,60 @@ describe('balanceService auto relogin', () => {
|
||||
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 () => {
|
||||
selectAllMock.mockReturnValue([
|
||||
{
|
||||
|
||||
@@ -43,15 +43,7 @@ function shouldAttemptAutoRelogin(message?: string | null): boolean {
|
||||
|
||||
function shouldReportExpired(message?: string | null): boolean {
|
||||
if (!message) return false;
|
||||
if (isTokenExpiredError({ message })) return true;
|
||||
|
||||
const text = message.toLowerCase();
|
||||
return (
|
||||
text.includes('access token') ||
|
||||
text.includes('new-api-user') ||
|
||||
text.includes('unauthorized') ||
|
||||
text.includes('forbidden')
|
||||
);
|
||||
return isTokenExpiredError({ message });
|
||||
}
|
||||
|
||||
function isUnsupportedCheckinRuntimeHealth(health: ReturnType<typeof extractRuntimeHealth>): boolean {
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
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 { ensureSiteSchemaCompatibility, type SiteSchemaInspector } from '../db/siteSchemaCompatibility.js';
|
||||
import { ensureRouteGroupingSchemaCompatibility } from '../db/routeGroupingSchemaCompatibility.js';
|
||||
import { ensureProxyFileSchemaCompatibility } from '../db/proxyFileSchemaCompatibility.js';
|
||||
import {
|
||||
createRuntimeSchemaClient,
|
||||
ensureRuntimeDatabaseSchema,
|
||||
type RuntimeSchemaClient,
|
||||
type RuntimeSchemaDialect,
|
||||
} from '../db/runtimeSchemaBootstrap.js';
|
||||
|
||||
export type MigrationDialect = 'sqlite' | 'mysql' | 'postgres';
|
||||
export type MigrationDialect = RuntimeSchemaDialect;
|
||||
|
||||
export interface DatabaseMigrationInput {
|
||||
dialect?: unknown;
|
||||
@@ -67,15 +66,7 @@ export interface DatabaseMigrationSummary {
|
||||
};
|
||||
}
|
||||
|
||||
interface SqlClient {
|
||||
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>;
|
||||
}
|
||||
type SqlClient = RuntimeSchemaClient;
|
||||
|
||||
interface InsertStatement {
|
||||
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> {
|
||||
if (input.dialect === 'postgres') return createPostgresClient(input.connectionString, input.ssl);
|
||||
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));
|
||||
return createRuntimeSchemaClient(input);
|
||||
}
|
||||
|
||||
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> {
|
||||
const normalized = normalizeMigrationInput(input);
|
||||
const snapshot = await toBackupSnapshot();
|
||||
const client = await createClient(normalized);
|
||||
|
||||
try {
|
||||
await ensureSchema(client);
|
||||
await ensureRuntimeDatabaseSchema(client);
|
||||
await ensureTargetState(client, normalized.overwrite);
|
||||
|
||||
await client.begin();
|
||||
@@ -819,6 +616,6 @@ export async function testDatabaseConnection(input: DatabaseMigrationInput): Pro
|
||||
}
|
||||
|
||||
export const __databaseMigrationServiceTestUtils = {
|
||||
ensureSchema,
|
||||
ensureSchema: ensureRuntimeDatabaseSchema,
|
||||
buildStatements,
|
||||
};
|
||||
|
||||
@@ -100,4 +100,72 @@ describe('TokenRouter runtime cache', () => {
|
||||
const refreshedSelection = await router.selectChannel('gpt-4o-mini');
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { filterRecentlyFailedCandidates } from './tokenRouter.js';
|
||||
import { filterRecentlyFailedCandidates, isChannelRecentlyFailed } from './tokenRouter.js';
|
||||
|
||||
type Candidate = {
|
||||
channel: {
|
||||
@@ -10,6 +10,26 @@ type Candidate = {
|
||||
};
|
||||
|
||||
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', () => {
|
||||
const nowMs = Date.now();
|
||||
const candidates: Candidate[] = [
|
||||
|
||||
@@ -32,9 +32,26 @@ type FailureAwareChannel = {
|
||||
lastFailAt?: string | null;
|
||||
};
|
||||
|
||||
const RECENT_FAILURE_AVOID_SEC = 10 * 60;
|
||||
const FAILURE_BACKOFF_BASE_SEC = 15;
|
||||
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 ChannelRow = typeof schema.routeChannels.$inferSelect;
|
||||
|
||||
@@ -133,7 +150,7 @@ function isSiteDisabled(status?: string | null): boolean {
|
||||
export function isChannelRecentlyFailed(
|
||||
channel: FailureAwareChannel,
|
||||
nowMs = Date.now(),
|
||||
avoidSec = RECENT_FAILURE_AVOID_SEC,
|
||||
avoidSec = resolveFailureBackoffSec(channel.failCount),
|
||||
): boolean {
|
||||
if (avoidSec <= 0) return false;
|
||||
if ((channel.failCount ?? 0) <= 0) return false;
|
||||
@@ -148,7 +165,7 @@ export function isChannelRecentlyFailed(
|
||||
export function filterRecentlyFailedCandidates<T extends { channel: FailureAwareChannel }>(
|
||||
candidates: T[],
|
||||
nowMs = Date.now(),
|
||||
avoidSec = RECENT_FAILURE_AVOID_SEC,
|
||||
avoidSec?: number,
|
||||
): T[] {
|
||||
if (candidates.length <= 1) return candidates;
|
||||
if (avoidSec <= 0) return candidates;
|
||||
@@ -424,7 +441,6 @@ export class TokenRouter {
|
||||
const candidates = filterRecentlyFailedCandidates(
|
||||
layers.get(priority)!,
|
||||
nowMs,
|
||||
RECENT_FAILURE_AVOID_SEC,
|
||||
);
|
||||
const selected = this.weightedRandomSelect(
|
||||
candidates,
|
||||
@@ -497,7 +513,6 @@ export class TokenRouter {
|
||||
const candidates = filterRecentlyFailedCandidates(
|
||||
layers.get(priority)!,
|
||||
nowMs,
|
||||
RECENT_FAILURE_AVOID_SEC,
|
||||
);
|
||||
const selected = this.weightedRandomSelect(
|
||||
candidates,
|
||||
@@ -631,7 +646,7 @@ export class TokenRouter {
|
||||
nowIso,
|
||||
});
|
||||
|
||||
const recentlyFailed = isChannelRecentlyFailed(row.channel, nowMs, RECENT_FAILURE_AVOID_SEC);
|
||||
const recentlyFailed = isChannelRecentlyFailed(row.channel, nowMs);
|
||||
const eligible = reasonParts.length === 0;
|
||||
const candidate: RouteDecisionCandidate = {
|
||||
channelId: row.channel.id,
|
||||
@@ -678,14 +693,14 @@ export class TokenRouter {
|
||||
const rawLayer = availableByPriority.get(priority) ?? [];
|
||||
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));
|
||||
if (avoided.length > 0) {
|
||||
for (const row of avoided) {
|
||||
const target = candidateMap.get(row.channel.id);
|
||||
if (!target) continue;
|
||||
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();
|
||||
if (!ch) return;
|
||||
const failCount = (ch.failCount ?? 0) + 1;
|
||||
// Exponential backoff cooldown: 30s, 60s, 120s, 240s, max 5min
|
||||
const cooldownSec = Math.min(30 * Math.pow(2, failCount - 1), 300);
|
||||
const cooldownSec = resolveFailureBackoffSec(failCount);
|
||||
const cooldownUntil = new Date(Date.now() + cooldownSec * 1000).toISOString();
|
||||
const nowIso = new Date().toISOString();
|
||||
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.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' },
|
||||
output_config: { effort: 'high', preserve: true },
|
||||
tool_choice: { type: 'tool', name: 'lookup' },
|
||||
@@ -89,7 +94,7 @@ describe('anthropicMessagesInbound', () => {
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value?.claudeOriginalBody).toEqual({
|
||||
expect(result.value?.parsed.claudeOriginalBody).toEqual({
|
||||
model: 'claude-opus-4-6',
|
||||
max_tokens: 512,
|
||||
system: [
|
||||
@@ -151,6 +156,6 @@ describe('anthropicMessagesInbound', () => {
|
||||
const result = anthropicMessagesInbound.parse(nativeBody);
|
||||
|
||||
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 { createProtocolRequestEnvelope, type ProtocolRequestEnvelope } from '../../shared/protocolModel.js';
|
||||
import { validateAnthropicMessagesBody } from './conversion.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -136,7 +137,10 @@ function sanitizeAnthropicInboundBody(
|
||||
}
|
||||
|
||||
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 inboundValidation = rawBody ? sanitizeAnthropicInboundBody(rawBody) : null;
|
||||
if (inboundValidation?.error) {
|
||||
@@ -145,12 +149,25 @@ export const anthropicMessagesInbound = {
|
||||
|
||||
const effectiveBody = inboundValidation?.sanitizedBody ?? body;
|
||||
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) {
|
||||
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 { createChatEndpointStrategy } from '../../shared/chatEndpointStrategy.js';
|
||||
import { anthropicMessagesInbound } from './inbound.js';
|
||||
import { anthropicMessagesOutbound } from './outbound.js';
|
||||
import { anthropicMessagesStream, consumeAnthropicSseEvent } from './stream.js';
|
||||
@@ -22,6 +23,7 @@ export const anthropicMessagesTransformer = {
|
||||
stream: anthropicMessagesStream,
|
||||
usage: anthropicMessagesUsage,
|
||||
compatibility: {
|
||||
createEndpointStrategy: createChatEndpointStrategy,
|
||||
shouldRetryNormalizedBody: shouldRetryNormalizedMessagesBody,
|
||||
isMessagesRequiredError,
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
OpenAiChatUsageDetails,
|
||||
} from './model.js';
|
||||
import { fromTransformerMetadataRecord } from '../../shared/normalized.js';
|
||||
import { extractInlineThinkTags } from '../../shared/thinkTagParser.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
@@ -98,15 +99,23 @@ function extractTextPart(value: unknown): string {
|
||||
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 } {
|
||||
if (typeof value === 'string') return { content: value, reasoning: '' };
|
||||
if (typeof value === 'string') return extractInlineThinkTags(value);
|
||||
if (Array.isArray(value)) {
|
||||
const contentParts: string[] = [];
|
||||
const reasoningParts: string[] = [];
|
||||
|
||||
for (const item of value) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -119,8 +128,9 @@ function extractTextAndReasoning(value: unknown): { content: string; reasoning:
|
||||
reasoningParts.push(item.reasoning);
|
||||
continue;
|
||||
}
|
||||
const text = extractTextPart(item);
|
||||
if (text) contentParts.push(text);
|
||||
const parsed = extractInlineThinkTags(extractTextPart(item));
|
||||
if (parsed.content) contentParts.push(parsed.content);
|
||||
if (parsed.reasoning) reasoningParts.push(parsed.reasoning);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -130,13 +140,14 @@ function extractTextAndReasoning(value: unknown): { content: string; reasoning:
|
||||
}
|
||||
|
||||
if (!isRecord(value)) return { content: '', reasoning: '' };
|
||||
const parsed = extractInlineThinkTags(extractTextPart(value.content ?? value));
|
||||
return {
|
||||
content: extractTextPart(value.content ?? value),
|
||||
reasoning: typeof value.reasoning_content === 'string'
|
||||
? value.reasoning_content
|
||||
: typeof value.reasoning === 'string'
|
||||
? value.reasoning
|
||||
: '',
|
||||
content: parsed.content,
|
||||
reasoning: joinNonEmpty([
|
||||
typeof value.reasoning_content === 'string' ? value.reasoning_content : '',
|
||||
typeof value.reasoning === 'string' ? value.reasoning : '',
|
||||
parsed.reasoning,
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,41 @@
|
||||
import { parseDownstreamChatRequest } from '../../shared/normalized.js';
|
||||
import { createProtocolRequestEnvelope } from '../../shared/protocolModel.js';
|
||||
import { extractChatRequestMetadata } from './helpers.js';
|
||||
import type { OpenAiChatParsedRequest } from './model.js';
|
||||
import type { OpenAiChatParsedRequest, OpenAiChatRequestEnvelope } from './model.js';
|
||||
|
||||
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 {
|
||||
value?: OpenAiChatParsedRequest;
|
||||
error?: { statusCode: number; payload: unknown };
|
||||
};
|
||||
if (!parsed.value) return parsed;
|
||||
|
||||
parsed.value = {
|
||||
...parsed.value,
|
||||
requestMetadata: extractChatRequestMetadata(body),
|
||||
if (parsed.error) {
|
||||
return { error: parsed.error };
|
||||
}
|
||||
if (!parsed.value) {
|
||||
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.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'],
|
||||
audio: { voice: 'alloy', format: 'mp3' },
|
||||
reasoning_effort: 'high',
|
||||
@@ -46,7 +55,7 @@ describe('openAiChatTransformer.inbound', () => {
|
||||
response_format: { type: 'json_object' },
|
||||
stream_options: { include_usage: true },
|
||||
});
|
||||
expect((result.value as any)?.requestMetadata).toEqual({
|
||||
expect((result.value as any)?.metadata).toEqual({
|
||||
modalities: ['text', 'audio'],
|
||||
audio: { voice: 'alloy', format: 'mp3' },
|
||||
reasoningEffort: 'high',
|
||||
@@ -82,14 +91,19 @@ describe('openAiChatTransformer.inbound', () => {
|
||||
});
|
||||
|
||||
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],
|
||||
reasoning_budget: '2048',
|
||||
top_logprobs: '5',
|
||||
logit_bias: { '42': '7', invalid: 'oops' },
|
||||
stream_options: { include_usage: 1 },
|
||||
});
|
||||
expect((result.value as any)?.requestMetadata).toEqual({
|
||||
expect((result.value as any)?.metadata).toEqual({
|
||||
modalities: ['text', 'audio'],
|
||||
audio: { voice: 'alloy', format: 'wav' },
|
||||
reasoningEffort: 'medium',
|
||||
@@ -107,6 +121,28 @@ describe('openAiChatTransformer.inbound', () => {
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const normalized = openAiChatTransformer.transformFinalResponse({
|
||||
id: 'chatcmpl-1',
|
||||
@@ -402,6 +438,53 @@ describe('openAiChatTransformer.stream', () => {
|
||||
'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', () => {
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { type NormalizedFinalResponse, type NormalizedStreamEvent, type StreamTransformContext } from '../../shared/normalized.js';
|
||||
import { createChatEndpointStrategy } from '../../shared/chatEndpointStrategy.js';
|
||||
import { openAiChatInbound } from './inbound.js';
|
||||
import { openAiChatOutbound } from './outbound.js';
|
||||
import { createChatProxyStreamSession } from './proxyStream.js';
|
||||
import { openAiChatStream } from './stream.js';
|
||||
import { openAiChatUsage } from './usage.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 = {
|
||||
protocol: 'openai/chat' as const,
|
||||
@@ -12,11 +17,17 @@ export const openAiChatTransformer = {
|
||||
outbound: openAiChatOutbound,
|
||||
stream: openAiChatStream,
|
||||
usage: openAiChatUsage,
|
||||
compatibility: {
|
||||
createEndpointStrategy: createChatEndpointStrategy,
|
||||
},
|
||||
aggregator: {
|
||||
createState: createOpenAiChatAggregateState,
|
||||
applyEvent: applyOpenAiChatStreamEvent,
|
||||
finalize: finalizeOpenAiChatAggregate,
|
||||
},
|
||||
proxyStream: {
|
||||
createSession: createChatProxyStreamSession,
|
||||
},
|
||||
transformRequest(body: unknown): ReturnType<typeof openAiChatInbound.parse> {
|
||||
return openAiChatInbound.parse(body);
|
||||
},
|
||||
@@ -72,3 +83,4 @@ export const openAiChatTransformer = {
|
||||
|
||||
export type OpenAiChatTransformer = typeof openAiChatTransformer;
|
||||
export type OpenAiChatParsedRequest = OpenAiChatParsedRequestModel;
|
||||
export type OpenAiChatRequestEnvelope = OpenAiChatRequestEnvelopeModel;
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
NormalizedStreamEvent,
|
||||
ParsedDownstreamChatRequest,
|
||||
} from '../../shared/normalized.js';
|
||||
import type { ProtocolRequestEnvelope } from '../../shared/protocolModel.js';
|
||||
|
||||
export type OpenAiChatAudioRequest = {
|
||||
format?: string;
|
||||
@@ -31,9 +32,12 @@ export type OpenAiChatUsageDetails = {
|
||||
completion_tokens_details?: Record<string, number>;
|
||||
};
|
||||
|
||||
export type OpenAiChatParsedRequest = ParsedDownstreamChatRequest & {
|
||||
requestMetadata?: OpenAiChatRequestMetadata;
|
||||
};
|
||||
export type OpenAiChatParsedRequest = ParsedDownstreamChatRequest;
|
||||
export type OpenAiChatRequestEnvelope = ProtocolRequestEnvelope<
|
||||
'openai/chat',
|
||||
OpenAiChatParsedRequest,
|
||||
OpenAiChatRequestMetadata
|
||||
>;
|
||||
|
||||
export type OpenAiChatToolCall = {
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -36,23 +36,35 @@ export const openAiChatStream = {
|
||||
return createStreamTransformContext(modelName);
|
||||
},
|
||||
normalizeEvent(payload: unknown, context: StreamTransformContext, modelName: string): OpenAiChatNormalizedStreamEvent {
|
||||
const normalized = normalizeUpstreamStreamEvent(payload, context, modelName);
|
||||
const choiceEvents = extractChatChoiceEvents(payload);
|
||||
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 {
|
||||
...normalizeUpstreamStreamEvent(payload, context, modelName),
|
||||
...normalized,
|
||||
...(primaryChoice
|
||||
? {
|
||||
choiceIndex: primaryChoice.index,
|
||||
role: primaryChoice.role,
|
||||
contentDelta: primaryChoice.contentDelta,
|
||||
reasoningDelta: primaryChoice.reasoningDelta,
|
||||
toolCallDeltas: primaryChoice.toolCallDeltas,
|
||||
finishReason: primaryChoice.finishReason,
|
||||
annotations: primaryChoice.annotations,
|
||||
citations: primaryChoice.citations,
|
||||
}
|
||||
: {}),
|
||||
...(choiceEvents.length > 0 ? { choiceEvents } : {}),
|
||||
...(normalizedChoiceEvents.length > 0 ? { choiceEvents: normalizedChoiceEvents } : {}),
|
||||
...extractChatResponseExtras(payload),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createStreamTransformContext } from '../../shared/normalized.js';
|
||||
import {
|
||||
completeResponsesStream,
|
||||
createOpenAiResponsesAggregateState,
|
||||
failResponsesStream,
|
||||
serializeConvertedResponsesEvents,
|
||||
} from './aggregator.js';
|
||||
|
||||
@@ -23,6 +25,35 @@ function parseSsePayloads(lines: string[]): Array<Record<string, unknown>> {
|
||||
.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', () => {
|
||||
it('aggregates reasoning summary events into the completed response payload', () => {
|
||||
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;
|
||||
}
|
||||
|
||||
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: {
|
||||
state: OpenAiResponsesAggregateState;
|
||||
streamContext: StreamTransformContext;
|
||||
@@ -806,8 +899,10 @@ export function completeResponsesStream(
|
||||
if (state.failed || state.completed) {
|
||||
return [serializeDone()];
|
||||
}
|
||||
const lines = buildSyntheticTerminalItemDoneEvents(state, 'completed');
|
||||
state.completed = true;
|
||||
return [
|
||||
...lines,
|
||||
serializeSse('response.completed', {
|
||||
type: 'response.completed',
|
||||
response: materializeResponse(state, streamContext, usage, null, 'completed'),
|
||||
@@ -825,6 +920,7 @@ export function failResponsesStream(
|
||||
if (state.failed) {
|
||||
return [serializeDone()];
|
||||
}
|
||||
const lines = buildSyntheticTerminalItemDoneEvents(state, 'failed');
|
||||
state.failed = true;
|
||||
const errorPayload = cloneRecord(payload);
|
||||
const message = (
|
||||
@@ -833,6 +929,7 @@ export function failResponsesStream(
|
||||
: (typeof errorPayload?.message === 'string' ? errorPayload.message : 'upstream stream failed')
|
||||
);
|
||||
return [
|
||||
...lines,
|
||||
serializeSse('response.failed', {
|
||||
type: '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 = {
|
||||
normalizeInput: normalizeResponsesInputForCompatibility,
|
||||
normalizeMessage: normalizeResponsesMessageItem,
|
||||
parse(
|
||||
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,
|
||||
} from './aggregator.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 { openAiResponsesUsage } from './usage.js';
|
||||
import type {
|
||||
OpenAiResponsesParsedRequest as OpenAiResponsesParsedRequestModel,
|
||||
OpenAiResponsesRequestEnvelope as OpenAiResponsesRequestEnvelopeModel,
|
||||
} from './model.js';
|
||||
|
||||
export const openAiResponsesTransformer = {
|
||||
protocol: 'openai/responses' as const,
|
||||
inbound: {
|
||||
parse: openAiResponsesInbound.parse,
|
||||
normalizeInput: normalizeResponsesInputForCompatibility,
|
||||
normalizeMessage: normalizeResponsesMessageItem,
|
||||
normalizeContent: normalizeResponsesMessageContent,
|
||||
@@ -38,6 +46,7 @@ export const openAiResponsesTransformer = {
|
||||
stream: openAiResponsesStream,
|
||||
usage: openAiResponsesUsage,
|
||||
compatibility: {
|
||||
createEndpointStrategy: createResponsesEndpointStrategy,
|
||||
buildRetryBodies,
|
||||
buildRetryHeaders,
|
||||
shouldRetry,
|
||||
@@ -49,8 +58,14 @@ export const openAiResponsesTransformer = {
|
||||
complete: completeResponsesStream,
|
||||
fail: failResponsesStream,
|
||||
},
|
||||
transformRequest(body: unknown) {
|
||||
return body;
|
||||
proxyStream: {
|
||||
createSession: createResponsesProxyStreamSession,
|
||||
},
|
||||
transformRequest(
|
||||
body: unknown,
|
||||
options?: { defaultEncryptedReasoningInclude?: boolean },
|
||||
): { value?: OpenAiResponsesRequestEnvelopeModel; error?: { statusCode: number; payload: unknown } } {
|
||||
return openAiResponsesInbound.parse(body, options);
|
||||
},
|
||||
createStreamContext(modelName: string): StreamTransformContext {
|
||||
return openAiResponsesStream.createContext(modelName);
|
||||
@@ -68,6 +83,8 @@ export const openAiResponsesTransformer = {
|
||||
|
||||
export type OpenAiResponsesTransformer = typeof openAiResponsesTransformer;
|
||||
export type OpenAiResponsesAggregate = OpenAiResponsesAggregateState;
|
||||
export type OpenAiResponsesParsedRequest = OpenAiResponsesParsedRequestModel;
|
||||
export type OpenAiResponsesRequestEnvelope = OpenAiResponsesRequestEnvelopeModel;
|
||||
export {
|
||||
convertOpenAiBodyToResponsesBody,
|
||||
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 {
|
||||
decodeAnthropicReasoningSignature,
|
||||
} from './reasoningTransport.js';
|
||||
import {
|
||||
consumeThinkTaggedText,
|
||||
createThinkTagParserState,
|
||||
extractInlineThinkTags,
|
||||
type ThinkTagParserState,
|
||||
} from './thinkTagParser.js';
|
||||
|
||||
export type DownstreamFormat = 'openai' | 'claude';
|
||||
|
||||
@@ -16,6 +22,7 @@ export type StreamTransformContext = {
|
||||
roleSent: boolean;
|
||||
doneSent: boolean;
|
||||
toolCalls: Record<number, { id?: string; name?: string; arguments?: string }>;
|
||||
thinkTagParser: ThinkTagParserState;
|
||||
};
|
||||
|
||||
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.completion === 'string') return part.completion;
|
||||
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)) {
|
||||
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 } {
|
||||
if (typeof value === 'string') return { content: value, reasoning: '' };
|
||||
if (typeof value === 'string') return extractInlineThinkTags(value);
|
||||
if (Array.isArray(value)) {
|
||||
const contentParts: string[] = [];
|
||||
const reasoningParts: string[] = [];
|
||||
for (const item of value) {
|
||||
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;
|
||||
}
|
||||
if (!isRecord(item)) continue;
|
||||
@@ -143,8 +150,9 @@ function extractTextAndReasoning(value: unknown): { content: string; reasoning:
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = textFromPart(item);
|
||||
if (text) contentParts.push(text);
|
||||
const parsedText = extractInlineThinkTags(textFromPart(item));
|
||||
if (parsedText.content) contentParts.push(parsedText.content);
|
||||
if (parsedText.reasoning) reasoningParts.push(parsedText.reasoning);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -159,9 +167,34 @@ function extractTextAndReasoning(value: unknown): { content: string; reasoning:
|
||||
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 {
|
||||
content: textFromPart(value),
|
||||
reasoning: '',
|
||||
content: parsedText.content,
|
||||
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,
|
||||
doneSent: false,
|
||||
toolCalls: {},
|
||||
thinkTagParser: createThinkTagParserState(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -254,10 +288,18 @@ function extractAssistantContent(choice: any): string {
|
||||
const content = extractTextAndReasoning(choice?.content).content;
|
||||
if (content) return content;
|
||||
|
||||
if (typeof choice?.text === 'string' && choice.text.length > 0) return choice.text;
|
||||
if (typeof choice?.completion === 'string' && choice.completion.length > 0) return choice.completion;
|
||||
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?.text === 'string' && choice.text.length > 0) {
|
||||
return extractInlineThinkTags(choice.text).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 '';
|
||||
}
|
||||
@@ -279,6 +321,11 @@ function extractAssistantReasoning(choice: any): string {
|
||||
const nested = extractTextAndReasoning(choice?.content).reasoning;
|
||||
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 '';
|
||||
}
|
||||
|
||||
@@ -742,17 +789,19 @@ export function normalizeUpstreamStreamEvent(
|
||||
|
||||
const choice = payload.choices[0] ?? {};
|
||||
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 =
|
||||
deltaParsed.content
|
||||
|| (typeof choice?.message?.content === 'string' ? choice.message.content : '')
|
||||
|| messageParsed.content
|
||||
|| '';
|
||||
|
||||
const reasoningDelta =
|
||||
(typeof (delta as any).reasoning_content === 'string' ? (delta as any).reasoning_content : '')
|
||||
|| (typeof (delta as any).reasoning === 'string' ? (delta as any).reasoning : '')
|
||||
|| deltaParsed.reasoning
|
||||
|| messageParsed.reasoning
|
||||
|| '';
|
||||
const reasoningSignature = isNonEmptyString((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 : '';
|
||||
if (type.startsWith('response.output_text')) {
|
||||
const deltaText = typeof payload.delta === 'string'
|
||||
? payload.delta
|
||||
: extractTextAndReasoning(payload.delta).content;
|
||||
const parsed = extractStreamingTextAndReasoning(payload.delta, context.thinkTagParser);
|
||||
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 {
|
||||
contentDelta: parsed.content || undefined,
|
||||
reasoningDelta: parsed.reasoning || undefined,
|
||||
@@ -985,7 +1033,7 @@ export function normalizeUpstreamStreamEvent(
|
||||
if (type === 'content_block_delta') {
|
||||
const delta = isRecord(payload.delta) ? payload.delta : {};
|
||||
const deltaType = typeof delta.type === 'string' ? delta.type : '';
|
||||
const parsed = extractTextAndReasoning(delta);
|
||||
const parsed = extractStreamingTextAndReasoning(delta, context.thinkTagParser);
|
||||
|
||||
if (deltaType === 'input_json_delta') {
|
||||
const index = (
|
||||
@@ -1029,7 +1077,10 @@ export function normalizeUpstreamStreamEvent(
|
||||
|
||||
if (Array.isArray(payload.candidates)) {
|
||||
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)) {
|
||||
context.model = (payload as any).modelVersion;
|
||||
@@ -1044,7 +1095,7 @@ export function normalizeUpstreamStreamEvent(
|
||||
};
|
||||
}
|
||||
|
||||
const fallback = extractTextAndReasoning(payload);
|
||||
const fallback = extractStreamingTextAndReasoning(payload, context.thinkTagParser);
|
||||
return {
|
||||
contentDelta: fallback.content || undefined,
|
||||
reasoningDelta: fallback.reasoning || undefined,
|
||||
@@ -1055,15 +1106,17 @@ function buildOpenAiStreamChunk(
|
||||
context: StreamTransformContext,
|
||||
event: NormalizedStreamEvent,
|
||||
): Record<string, unknown> | null {
|
||||
const normalizedContentDelta = event.contentDelta || '';
|
||||
const normalizedReasoningDelta = event.reasoningDelta || '';
|
||||
const delta: Record<string, unknown> = {};
|
||||
const isInitialAssistantRoleOnlyEvent = (
|
||||
!context.roleSent
|
||||
&& event.role === 'assistant'
|
||||
&& !event.contentDelta
|
||||
&& !event.reasoningDelta
|
||||
&& !normalizedContentDelta
|
||||
&& !normalizedReasoningDelta
|
||||
);
|
||||
|
||||
if (!context.roleSent && (event.role === 'assistant' || event.contentDelta || event.reasoningDelta)) {
|
||||
if (!context.roleSent && (event.role === 'assistant' || normalizedContentDelta || normalizedReasoningDelta)) {
|
||||
delta.role = 'assistant';
|
||||
context.roleSent = true;
|
||||
} else if (event.role === 'assistant') {
|
||||
@@ -1071,12 +1124,12 @@ function buildOpenAiStreamChunk(
|
||||
context.roleSent = true;
|
||||
}
|
||||
|
||||
if (event.contentDelta) {
|
||||
delta.content = event.contentDelta;
|
||||
if (normalizedContentDelta) {
|
||||
delta.content = normalizedContentDelta;
|
||||
}
|
||||
|
||||
if (event.reasoningDelta) {
|
||||
delta.reasoning_content = event.reasoningDelta;
|
||||
if (normalizedReasoningDelta) {
|
||||
delta.reasoning_content = normalizedReasoningDelta;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
const trimmedData = asTrimmedString(fileData);
|
||||
if (!trimmedData) return trimmedData;
|
||||
@@ -58,9 +72,14 @@ export function normalizeInputFileBlock(item: Record<string, unknown>): Normaliz
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
|
||||
if (type === 'input_file') {
|
||||
const fileId = asTrimmedString(item.file_id);
|
||||
const fileData = asTrimmedString(item.file_data);
|
||||
const fileUrl = asTrimmedString(item.file_url);
|
||||
const selectedSource = normalizeFileSourceSelection(
|
||||
asTrimmedString(item.file_data),
|
||||
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);
|
||||
let mimeType = asTrimmedString(item.mime_type ?? item.mimeType) || null;
|
||||
if (!fileId && !fileData && !fileUrl) return null;
|
||||
@@ -81,9 +100,14 @@ export function normalizeInputFileBlock(item: Record<string, unknown>): Normaliz
|
||||
|
||||
if (type === 'file') {
|
||||
const file = isRecord(item.file) ? item.file : item;
|
||||
const fileId = asTrimmedString(file.file_id ?? item.file_id);
|
||||
const fileData = asTrimmedString(file.file_data ?? item.file_data);
|
||||
const fileUrl = asTrimmedString(file.file_url ?? item.file_url);
|
||||
const selectedSource = normalizeFileSourceSelection(
|
||||
asTrimmedString(file.file_data ?? item.file_data),
|
||||
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);
|
||||
let mimeType = asTrimmedString(file.mime_type ?? file.mimeType ?? item.mime_type ?? item.mimeType) || 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}`,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -3,6 +3,7 @@ import { Routes, Route, NavLink, Navigate, useLocation } from 'react-router-dom'
|
||||
import { ToastProvider, useToast } from './components/Toast.js';
|
||||
import SearchModal from './components/SearchModal.js';
|
||||
import NotificationPanel from './components/NotificationPanel.js';
|
||||
import TooltipLayer from './components/TooltipLayer.js';
|
||||
import { api } from './api.js';
|
||||
import { clearAuthSession, hasValidAuthSession, persistAuthSession } from './authSession.js';
|
||||
import {
|
||||
@@ -558,20 +559,19 @@ function AppShell() {
|
||||
<div className="topbar-right">
|
||||
<button
|
||||
className="topbar-icon-btn"
|
||||
data-tooltip={language === 'zh' ? 'Switch to English' : '切换到中文'}
|
||||
aria-label={language === 'zh' ? 'Switch to English' : '切换到中文'}
|
||||
onClick={toggleLanguage}
|
||||
style={{ minWidth: 36, fontSize: 12, fontWeight: 700 }}
|
||||
>
|
||||
{language === 'zh' ? 'EN' : '中'}
|
||||
</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>
|
||||
<span className="topbar-search-label">{t('搜索')}</span>
|
||||
<kbd className="topbar-search-kbd">Ctrl K</kbd>
|
||||
</button>
|
||||
<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>
|
||||
{unreadCount > 0 && (
|
||||
<span className="topbar-badge">
|
||||
@@ -584,9 +584,6 @@ function AppShell() {
|
||||
<div ref={themeMenuRef} style={{ position: 'relative' }}>
|
||||
<button
|
||||
className="topbar-icon-btn"
|
||||
data-tooltip={themeMode === 'system'
|
||||
? `${t('跟随系统')} (${resolvedThemeLabel})`
|
||||
: (themeMode === 'light' ? t('浅色模式') : t('深色模式'))}
|
||||
aria-label={themeMode === 'system'
|
||||
? `${t('跟随系统')} (${resolvedThemeLabel})`
|
||||
: (themeMode === 'light' ? t('浅色模式') : t('深色模式'))}
|
||||
@@ -632,7 +629,6 @@ function AppShell() {
|
||||
<div ref={userMenuRef} style={{ position: 'relative' }}>
|
||||
<div
|
||||
className="topbar-avatar"
|
||||
data-tooltip={displayName}
|
||||
aria-label={displayName}
|
||||
onClick={() => {
|
||||
setShowUserMenu(!showUserMenu);
|
||||
@@ -741,6 +737,7 @@ export default function App() {
|
||||
<I18nProvider>
|
||||
<ToastProvider>
|
||||
<AppShell />
|
||||
<TooltipLayer />
|
||||
</ToastProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 />');
|
||||
});
|
||||
});
|
||||
@@ -69,6 +69,8 @@ const zhToEn: Record<string, string> = {
|
||||
'任务状态已更新': 'Task status updated',
|
||||
'会话已过期,请重新登录': 'Session expired, please sign in again',
|
||||
'首次使用建议先阅读站点文档:': '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',
|
||||
'搜索': 'Search',
|
||||
'搜索 (Ctrl+K)': 'Search (Ctrl+K)',
|
||||
|
||||
+131
-2
@@ -1608,6 +1608,11 @@ body {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
body[data-tooltip-portal="true"] [data-tooltip]::after,
|
||||
body[data-tooltip-portal="true"] [data-tooltip]::before {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
[data-tooltip]::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
@@ -1625,7 +1630,7 @@ body {
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
pointer-events: none;
|
||||
z-index: 40;
|
||||
z-index: 20000;
|
||||
opacity: 0;
|
||||
color: color-mix(in srgb, var(--color-text-primary) 90%, #0f172a);
|
||||
background: color-mix(in srgb, #ffffff 92%, var(--color-primary) 8%);
|
||||
@@ -1643,7 +1648,7 @@ body {
|
||||
height: 8px;
|
||||
transform: translateX(-50%) rotate(45deg);
|
||||
pointer-events: none;
|
||||
z-index: 39;
|
||||
z-index: 19999;
|
||||
opacity: 0;
|
||||
background: color-mix(in srgb, #ffffff 92%, var(--color-primary) 8%);
|
||||
border-right: 1px solid color-mix(in srgb, var(--color-primary) 22%, transparent);
|
||||
@@ -1651,6 +1656,45 @@ body {
|
||||
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::before,
|
||||
[data-tooltip]:focus-visible::after,
|
||||
@@ -1663,6 +1707,18 @@ body {
|
||||
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 {
|
||||
border: 1px solid color-mix(in srgb, var(--color-info) 35%, transparent);
|
||||
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);
|
||||
}
|
||||
|
||||
.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 {
|
||||
background: #052e16;
|
||||
color: #4ade80;
|
||||
|
||||
@@ -18,6 +18,18 @@ import { SITE_DOCS_URL } from '../docsLink.js';
|
||||
|
||||
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() {
|
||||
return { siteId: 0, username: '', password: '' };
|
||||
}
|
||||
@@ -745,15 +757,14 @@ export default function Accounts() {
|
||||
borderRadius: 'var(--radius-md)',
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ value: 'session' as ConnectionsSegment, label: 'Session 连接' },
|
||||
{ value: 'apikey' as ConnectionsSegment, label: 'API Key 连接' },
|
||||
{ value: 'tokens' as ConnectionsSegment, label: '账号令牌' },
|
||||
].map((segment) => (
|
||||
{ACCOUNT_SEGMENTS.map((segment) => (
|
||||
<button
|
||||
key={segment.value}
|
||||
type="button"
|
||||
onClick={() => setSegment(segment.value)}
|
||||
data-tooltip={segment.tooltip}
|
||||
data-tooltip-side={segment.tooltipSide}
|
||||
data-tooltip-align={segment.tooltipAlign}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 8,
|
||||
|
||||
@@ -1252,6 +1252,7 @@ export default function TokenRoutes() {
|
||||
};
|
||||
|
||||
const handleCreateTokenForMissingAccount = (accountId: number, modelName: string) => {
|
||||
if (!Number.isFinite(accountId) || accountId <= 0) return;
|
||||
const params = new URLSearchParams();
|
||||
params.set('create', '1');
|
||||
params.set('accountId', String(accountId));
|
||||
@@ -1897,6 +1898,7 @@ export default function TokenRoutes() {
|
||||
const siteMap = new Map<string, MissingTokenRouteSiteActionItem>();
|
||||
for (const hint of missingTokenHints) {
|
||||
for (const account of hint.accounts) {
|
||||
if (!Number.isFinite(account.accountId) || account.accountId <= 0) continue;
|
||||
const siteName = (account.siteName || '').trim() || `site-${account.siteId || 'unknown'}`;
|
||||
const key = `${account.siteId || 0}::${siteName.toLowerCase()}`;
|
||||
const accountLabel = account.username || `account-${account.accountId}`;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { ToastProvider } from '../components/Toast.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', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -76,10 +82,28 @@ describe('Accounts segmented connections view', () => {
|
||||
|
||||
const rendered = JSON.stringify(root.toJSON());
|
||||
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).not.toContain('仅代理');
|
||||
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 {
|
||||
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 () => {
|
||||
apiMock.getRoutes.mockResolvedValue([
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user