fix: address review feedback for models and routing

This commit is contained in:
bnvnvnv
2026-03-12 05:51:53 +00:00
parent 5a7531f635
commit 1da3608d42
10 changed files with 444 additions and 44 deletions
+2 -2
View File
@@ -428,11 +428,11 @@ function ensureRouteGroupingSchema() {
}
if (!tableColumnExists('route_channels', 'consecutive_fail_count')) {
sqlite.exec(`ALTER TABLE route_channels ADD COLUMN consecutive_fail_count integer DEFAULT 0;`);
sqlite.exec(`ALTER TABLE route_channels ADD COLUMN consecutive_fail_count integer NOT NULL DEFAULT 0;`);
}
if (!tableColumnExists('route_channels', 'cooldown_level')) {
sqlite.exec(`ALTER TABLE route_channels ADD COLUMN cooldown_level integer DEFAULT 0;`);
sqlite.exec(`ALTER TABLE route_channels ADD COLUMN cooldown_level integer NOT NULL DEFAULT 0;`);
}
}
@@ -37,6 +37,20 @@ function createInspector(
describe('ensureRouteGroupingSchemaCompatibility', () => {
it.each([
{
dialect: 'sqlite' as const,
expectedSql: [
'ALTER TABLE token_routes ADD COLUMN display_name text;',
'ALTER TABLE token_routes ADD COLUMN display_icon text;',
'ALTER TABLE token_routes ADD COLUMN decision_snapshot text;',
'ALTER TABLE token_routes ADD COLUMN decision_refreshed_at text;',
'ALTER TABLE token_routes ADD COLUMN routing_strategy text DEFAULT \'weighted\';',
'ALTER TABLE route_channels ADD COLUMN source_model text;',
'ALTER TABLE route_channels ADD COLUMN last_selected_at text;',
'ALTER TABLE route_channels ADD COLUMN consecutive_fail_count integer NOT NULL DEFAULT 0;',
'ALTER TABLE route_channels ADD COLUMN cooldown_level integer NOT NULL DEFAULT 0;',
],
},
{
dialect: 'postgres' as const,
expectedSql: [
@@ -47,8 +61,8 @@ describe('ensureRouteGroupingSchemaCompatibility', () => {
'ALTER TABLE "token_routes" ADD COLUMN "routing_strategy" TEXT DEFAULT \'weighted\'',
'ALTER TABLE "route_channels" ADD COLUMN "source_model" TEXT',
'ALTER TABLE "route_channels" ADD COLUMN "last_selected_at" TEXT',
'ALTER TABLE "route_channels" ADD COLUMN "consecutive_fail_count" INTEGER DEFAULT 0',
'ALTER TABLE "route_channels" ADD COLUMN "cooldown_level" INTEGER DEFAULT 0',
'ALTER TABLE "route_channels" ADD COLUMN "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0',
'ALTER TABLE "route_channels" ADD COLUMN "cooldown_level" INTEGER NOT NULL DEFAULT 0',
],
},
{
@@ -81,18 +81,18 @@ const ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS: RouteGroupingColumnCompatibilit
table: 'route_channels',
column: 'consecutive_fail_count',
addSql: {
sqlite: 'ALTER TABLE route_channels ADD COLUMN consecutive_fail_count integer DEFAULT 0;',
sqlite: 'ALTER TABLE route_channels ADD COLUMN consecutive_fail_count integer NOT NULL DEFAULT 0;',
mysql: 'ALTER TABLE `route_channels` ADD COLUMN `consecutive_fail_count` INT NOT NULL DEFAULT 0',
postgres: 'ALTER TABLE "route_channels" ADD COLUMN "consecutive_fail_count" INTEGER DEFAULT 0',
postgres: 'ALTER TABLE "route_channels" ADD COLUMN "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0',
},
},
{
table: 'route_channels',
column: 'cooldown_level',
addSql: {
sqlite: 'ALTER TABLE route_channels ADD COLUMN cooldown_level integer DEFAULT 0;',
sqlite: 'ALTER TABLE route_channels ADD COLUMN cooldown_level integer NOT NULL DEFAULT 0;',
mysql: 'ALTER TABLE `route_channels` ADD COLUMN `cooldown_level` INT NOT NULL DEFAULT 0',
postgres: 'ALTER TABLE "route_channels" ADD COLUMN "cooldown_level" INTEGER DEFAULT 0',
postgres: 'ALTER TABLE "route_channels" ADD COLUMN "cooldown_level" INTEGER NOT NULL DEFAULT 0',
},
},
];
+3 -3
View File
@@ -193,7 +193,7 @@ export async function ensureRuntimeDatabaseSchema(client: RuntimeSchemaClient):
`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, "routing_strategy" TEXT DEFAULT 'weighted', "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_selected_at" TEXT, "last_fail_at" TEXT, "consecutive_fail_count" INTEGER DEFAULT 0, "cooldown_level" INTEGER DEFAULT 0, "cooldown_until" 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_selected_at" TEXT, "last_fail_at" TEXT, "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0, "cooldown_level" INTEGER NOT NULL DEFAULT 0, "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)`,
@@ -210,7 +210,7 @@ export async function ensureRuntimeDatabaseSchema(client: RuntimeSchemaClient):
`CREATE TABLE IF NOT EXISTS \`model_availability\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`model_name\` VARCHAR(191) 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\` VARCHAR(191) 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, \`routing_strategy\` VARCHAR(32) NULL DEFAULT 'weighted', \`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_selected_at\` TEXT NULL, \`last_fail_at\` TEXT NULL, \`consecutive_fail_count\` INT DEFAULT 0, \`cooldown_level\` INT DEFAULT 0, \`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 \`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_selected_at\` TEXT NULL, \`last_fail_at\` TEXT NULL, \`consecutive_fail_count\` INT NOT NULL DEFAULT 0, \`cooldown_level\` INT NOT NULL DEFAULT 0, \`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)`,
@@ -226,7 +226,7 @@ export async function ensureRuntimeDatabaseSchema(client: RuntimeSchemaClient):
`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, "routing_strategy" TEXT DEFAULT 'weighted', "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_selected_at" TEXT, "last_fail_at" TEXT, "consecutive_fail_count" INTEGER DEFAULT 0, "cooldown_level" INTEGER DEFAULT 0, "cooldown_until" 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_selected_at" TEXT, "last_fail_at" TEXT, "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0, "cooldown_level" INTEGER NOT NULL DEFAULT 0, "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)`,
+2 -2
View File
@@ -137,8 +137,8 @@ export const routeChannels = sqliteTable('route_channels', {
lastUsedAt: text('last_used_at'),
lastSelectedAt: text('last_selected_at'),
lastFailAt: text('last_fail_at'),
consecutiveFailCount: integer('consecutive_fail_count').default(0),
cooldownLevel: integer('cooldown_level').default(0),
consecutiveFailCount: integer('consecutive_fail_count').notNull().default(0),
cooldownLevel: integer('cooldown_level').notNull().default(0),
cooldownUntil: text('cooldown_until'),
}, (table) => ({
routeIdIdx: index('route_channels_route_id_idx').on(table.routeId),
@@ -320,4 +320,202 @@ describe('Models marketplace text', () => {
root?.unmount();
}
});
it('re-sorts models using site-scoped counts after selecting a site filter', async () => {
apiMock.getModelsMarketplace.mockResolvedValue({
models: [
{
name: 'gpt-4o',
accountCount: 4,
tokenCount: 4,
avgLatency: 300,
successRate: 98,
description: null,
tags: [],
supportedEndpointTypes: [],
pricingSources: [],
accounts: [
{
id: 1,
site: '站点 A',
username: 'user-a-1',
latency: 300,
balance: 8,
tokens: [{ id: 1, name: 'token-a-1', isDefault: true }],
},
{
id: 2,
site: '站点 B',
username: 'user-b-1',
latency: 200,
balance: 8,
tokens: [{ id: 2, name: 'token-b-1', isDefault: true }],
},
{
id: 3,
site: '站点 B',
username: 'user-b-2',
latency: 250,
balance: 8,
tokens: [{ id: 3, name: 'token-b-2', isDefault: true }],
},
{
id: 4,
site: '站点 B',
username: 'user-b-3',
latency: 260,
balance: 8,
tokens: [{ id: 4, name: 'token-b-3', isDefault: true }],
},
],
},
{
name: 'claude-3-5-sonnet',
accountCount: 2,
tokenCount: 2,
avgLatency: 420,
successRate: 95,
description: null,
tags: [],
supportedEndpointTypes: [],
pricingSources: [],
accounts: [
{
id: 5,
site: '站点 A',
username: 'user-a-2',
latency: 410,
balance: 9,
tokens: [{ id: 5, name: 'token-a-2', isDefault: true }],
},
{
id: 6,
site: '站点 A',
username: 'user-a-3',
latency: 430,
balance: 9,
tokens: [{ id: 6, name: 'token-a-3', isDefault: true }],
},
],
},
],
});
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/models']}>
<ToastProvider>
<Models />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const siteFilterItem = root!.root.find((node) => (
node.type === 'div'
&& typeof node.props.className === 'string'
&& node.props.className.includes('filter-item')
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('站点 A')
));
await act(async () => {
siteFilterItem.props.onClick();
});
await flushMicrotasks();
const cards = root!.root.findAll((node) => (
node.type === 'div'
&& typeof node.props.className === 'string'
&& node.props.className.split(' ').includes('model-card')
&& typeof node.props.onClick === 'function'
));
expect(cards.length).toBe(2);
expect(collectText(cards[0]!)).toContain('claude-3-5-sonnet');
expect(collectText(cards[1]!)).toContain('gpt-4o');
} finally {
root?.unmount();
}
});
it('renders unknown latency instead of falling back to another site latency', async () => {
apiMock.getModelsMarketplace.mockResolvedValue({
models: [
{
name: 'gpt-4o',
accountCount: 2,
tokenCount: 2,
avgLatency: 680,
successRate: 93,
description: null,
tags: [],
supportedEndpointTypes: [],
pricingSources: [],
accounts: [
{
id: 1,
site: '站点 A',
username: 'user-a',
latency: null,
balance: 12,
tokens: [{ id: 1, name: 'token-a', isDefault: true }],
},
{
id: 2,
site: '站点 B',
username: 'user-b',
latency: 680,
balance: 12,
tokens: [{ id: 2, name: 'token-b', isDefault: true }],
},
],
},
],
});
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/models']}>
<ToastProvider>
<Models />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const siteFilterItem = root!.root.find((node) => (
node.type === 'div'
&& typeof node.props.className === 'string'
&& node.props.className.includes('filter-item')
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('站点 A')
));
await act(async () => {
siteFilterItem.props.onClick();
});
await flushMicrotasks();
const latencyBadge = root!.root.find((node) => (
node.type === 'span'
&& node.props['data-tooltip'] === '平均延迟'
));
expect(String(latencyBadge.props.className || '')).toContain('badge-muted');
expect(collectText(latencyBadge)).toContain('延迟');
expect(collectText(latencyBadge)).toContain('—');
expect(collectText(root!.root)).not.toContain('680ms');
} finally {
root?.unmount();
}
});
});
+59 -30
View File
@@ -49,7 +49,7 @@ interface ModelRow {
name: string;
accountCount: number;
tokenCount: number;
avgLatency: number;
avgLatency: number | null;
successRate: number | null;
description: string | null;
tags: string[];
@@ -68,7 +68,13 @@ interface ModelsMarketplaceResponse {
refreshJobId?: string | null;
};
}
function getMetricColor(latency: number) {
function isKnownLatency(latency: number | null | undefined): latency is number {
return typeof latency === 'number' && Number.isFinite(latency);
}
function getMetricColor(latency: number | null) {
if (!isKnownLatency(latency)) return 'var(--color-text-muted)';
if (latency >= 3000) return 'var(--color-danger)';
if (latency >= 2000) return 'color-mix(in srgb, var(--color-warning) 30%, var(--color-danger))';
if (latency >= 1500) return 'color-mix(in srgb, var(--color-warning) 60%, var(--color-danger))';
@@ -77,12 +83,17 @@ function getMetricColor(latency: number) {
return 'var(--color-success)';
}
function getLatencyBadgeClass(latency: number) {
function getLatencyBadgeClass(latency: number | null) {
if (!isKnownLatency(latency)) return 'badge-muted';
if (latency >= 3000) return 'badge-error';
if (latency >= 1000) return 'badge-warning';
return 'badge-success';
}
function formatLatency(latency: number | null): string {
return isKnownLatency(latency) ? `${latency}ms` : '—';
}
function getSuccessBadgeClass(rate: number | null) {
if (rate == null) return 'badge-muted';
if (rate >= 90) return 'badge-success';
@@ -113,6 +124,29 @@ function renderGroupPricingValue(pricing: ModelGroupPricing): string {
const PAGE_SIZES = [10, 20, 50];
function compareModels(a: ModelRow, b: ModelRow, sortBy: SortColumn, sortDir: 'asc' | 'desc'): number {
if (sortBy === 'name') {
const cmp = a.name.localeCompare(b.name);
return sortDir === 'asc' ? cmp : -cmp;
}
const resolveNumericSortValue = (model: ModelRow) => {
if (sortBy === 'successRate') return model.successRate ?? -1;
if (sortBy === 'avgLatency') {
if (!isKnownLatency(model.avgLatency)) {
return sortDir === 'asc' ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY;
}
return model.avgLatency;
}
return model[sortBy] ?? 0;
};
const va = resolveNumericSortValue(a);
const vb = resolveNumericSortValue(b);
if (va === vb) return a.name.localeCompare(b.name);
return sortDir === 'desc' ? vb - va : va - vb;
}
/* ---- component ---- */
export default function Models() {
const toast = useToast();
@@ -259,7 +293,7 @@ export default function Models() {
return [...m.entries()].sort((a, b) => b[1] - a[1]);
}, [data.models]);
/* ---- filtered + sorted ---- */
/* ---- filtered ---- */
const filteredModels = useMemo(() => {
let list = data.models;
@@ -280,29 +314,19 @@ export default function Models() {
list = list.filter(m => m.name.toLowerCase().includes(q));
}
return [...list].sort((a, b) => {
if (sortBy === 'name') {
const cmp = a.name.localeCompare(b.name);
return sortDir === 'asc' ? cmp : -cmp;
}
const va = sortBy === 'successRate' ? (a.successRate ?? -1) : (a[sortBy] ?? 0);
const vb = sortBy === 'successRate' ? (b.successRate ?? -1) : (b[sortBy] ?? 0);
if (va === vb) return a.name.localeCompare(b.name);
return sortDir === 'desc' ? vb - va : va - vb;
});
}, [data.models, search, activeSite, activeBrand, sortBy, sortDir]);
return list;
}, [data.models, search, activeSite, activeBrand]);
// Keep expanded detail consistent with filters (especially site filter).
// The list-level filter uses "model has at least one account on this site" semantics;
// once a model is shown, its detail should honor the active site as well.
const detailModels = useMemo(() => {
if (!activeSite) return filteredModels;
return filteredModels.map((model) => {
const scopedModels = activeSite ? filteredModels.map((model) => {
const accounts = model.accounts.filter((account) => account.site === activeSite);
const pricingSources = model.pricingSources.filter((source) => source.siteName === activeSite);
const latencyValues = accounts
.map((account) => account.latency)
.filter((latency): latency is number => typeof latency === 'number' && Number.isFinite(latency));
.filter(isKnownLatency);
return {
...model,
accounts,
@@ -311,13 +335,15 @@ export default function Models() {
tokenCount: accounts.reduce((sum, account) => sum + account.tokens.length, 0),
avgLatency: latencyValues.length > 0
? Math.round(latencyValues.reduce((sum, latency) => sum + latency, 0) / latencyValues.length)
: model.avgLatency,
: null,
};
});
}, [filteredModels, activeSite]);
}) : filteredModels;
return [...scopedModels].sort((a, b) => compareModels(a, b, sortBy, sortDir));
}, [filteredModels, activeSite, sortBy, sortDir]);
/* ---- pagination ---- */
const totalPages = Math.max(1, Math.ceil(filteredModels.length / pageSize));
const totalPages = Math.max(1, Math.ceil(detailModels.length / pageSize));
const safePageVal = Math.min(page, totalPages);
const paged = detailModels.slice((safePageVal - 1) * pageSize, safePageVal * pageSize);
@@ -334,9 +360,12 @@ export default function Models() {
}
return ids.size;
})();
const avgLatency = detailModels.length
? Math.round(detailModels.reduce((s, m) => s + m.avgLatency, 0) / detailModels.length)
: 0;
const latencyMetrics = detailModels
.map((model) => model.avgLatency)
.filter(isKnownLatency);
const avgLatency = latencyMetrics.length > 0
? Math.round(latencyMetrics.reduce((sum, latency) => sum + latency, 0) / latencyMetrics.length)
: null;
/* ---- copy ---- */
const copyName = (name: string) => {
@@ -532,12 +561,12 @@ export default function Models() {
<span data-tooltip={tr('当前筛选范围内去重后的唯一账号数')}>
{tr('去重账号')} <b style={{ color: 'var(--color-text-primary)' }}>{uniqueAccountCount}</b>
</span>
<span>{tr('平均延迟')} <b style={{ color: getMetricColor(avgLatency) }}>{avgLatency}ms</b></span>
<span>{tr('平均延迟')} <b style={{ color: getMetricColor(avgLatency) }}>{formatLatency(avgLatency)}</b></span>
</div>
</div>
{/* Empty */}
{filteredModels.length === 0 ? (
{detailModels.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon">
<svg width="40" height="40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -572,7 +601,7 @@ export default function Models() {
style={{ fontVariantNumeric: 'tabular-nums' }}
data-tooltip={tr('平均延迟')}
>
{tr('延迟')} {m.avgLatency}ms
{tr('延迟')} {formatLatency(m.avgLatency)}
</span>
<span
className={`badge ${getSuccessBadgeClass(m.successRate)}`}
@@ -617,7 +646,7 @@ export default function Models() {
{m.successRate != null && m.successRate < 60 && (
<span className="model-tag model-tag-orange">{tr('风险')}</span>
)}
{m.avgLatency <= 500 && (
{isKnownLatency(m.avgLatency) && m.avgLatency <= 500 && (
<span className="model-tag model-tag-purple">{tr('低延迟')}</span>
)}
</div>
@@ -761,7 +790,7 @@ export default function Models() {
className={`badge ${getLatencyBadgeClass(m.avgLatency)}`}
style={{ fontSize: 12, fontVariantNumeric: 'tabular-nums' }}
>
{m.avgLatency}ms
{formatLatency(m.avgLatency)}
</span>
</td>
<td>
+7 -1
View File
@@ -364,7 +364,6 @@ export default function TokenRoutes() {
try {
await api.updateRoute(route.id, { routingStrategy });
toast.success(routingStrategy === 'round_robin' ? '已切换为轮询策略' : '已切换为权重随机策略');
await load();
} catch (e: any) {
setRouteSummaries((prev) => prev.map((item) => (
item.id === route.id
@@ -372,9 +371,16 @@ export default function TokenRoutes() {
: item
)));
toast.error(e.message || '更新路由策略失败');
return;
} finally {
setUpdatingRoutingStrategyByRoute((prev) => ({ ...prev, [route.id]: false }));
}
try {
await load();
} catch (e: any) {
toast.error(e?.message || '路由策略已保存,但刷新列表失败');
}
};
// Stable derived value: only changes when route patterns change (not on enabled toggle)
@@ -30,6 +30,29 @@ describe('tokenBindingPresentation', () => {
expect(result.helperText).toContain('不会跟着变');
});
it('describes fixed mode when the selected token is not the account default', () => {
const result = describeTokenBinding([
{ id: 1, name: 'default', isDefault: true },
{ id: 2, name: 'backup', isDefault: false },
], 2);
expect(result.bindingModeLabel).toBe('固定令牌');
expect(result.effectiveTokenName).toBe('backup');
expect(result.helperText).toContain('不会随账号默认变化');
});
it('describes follow-account-default mode when no default token exists yet', () => {
const result = describeTokenBinding([
{ id: 2, name: 'backup', isDefault: false },
{ id: 3, name: 'spare', isDefault: false },
], 0);
expect(result.bindingModeLabel).toBe('跟随账号默认');
expect(result.effectiveTokenName).toBe('未设置默认令牌');
expect(result.helperText).toContain('当前账号还没有默认令牌');
expect(result.followOptionDescription).toContain('以后账号默认变化时会自动切换');
});
it('formats fixed token options with clearer labels and descriptions', () => {
const token = {
id: 3,
@@ -0,0 +1,130 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import TokenRoutes from './TokenRoutes.js';
const { apiMock, getBrandMock } = vi.hoisted(() => ({
apiMock: {
getRoutesSummary: vi.fn(),
getRouteChannels: vi.fn(),
getModelTokenCandidates: vi.fn(),
getRouteDecisionsBatch: vi.fn(),
getRouteWideDecisionsBatch: vi.fn(),
updateRoute: vi.fn(),
},
getBrandMock: vi.fn(),
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
vi.mock('../components/BrandIcon.js', () => ({
BrandGlyph: ({ brand, icon, model }: { brand?: { name?: string } | null; icon?: string | null; model?: string | null }) => (
<span>{brand?.name || icon || model || ''}</span>
),
InlineBrandIcon: ({ model }: { model: string }) => model ? <span>{model}</span> : null,
getBrand: (...args: unknown[]) => getBrandMock(...args),
hashColor: () => 'linear-gradient(135deg,#4f46e5,#818cf8)',
normalizeBrandIconKey: (icon: string) => icon,
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('TokenRoutes routing strategy updates', () => {
beforeEach(() => {
vi.clearAllMocks();
getBrandMock.mockReset();
getBrandMock.mockReturnValue(null);
apiMock.getRoutesSummary
.mockResolvedValueOnce([
{
id: 1,
modelPattern: 'gpt-4o-mini',
displayName: 'gpt-4o-mini',
displayIcon: null,
modelMapping: null,
routingStrategy: 'weighted',
enabled: true,
channelCount: 0,
enabledChannelCount: 0,
siteNames: [],
decisionSnapshot: null,
decisionRefreshedAt: null,
},
])
.mockRejectedValueOnce(new Error('refresh failed'));
apiMock.getRouteChannels.mockResolvedValue([]);
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.updateRoute.mockResolvedValue({});
});
afterEach(() => {
vi.clearAllMocks();
});
it('keeps the optimistic routing strategy when refresh fails after a successful save', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const expandButton = root.root.find((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-card-collapsed')
));
await act(async () => {
expandButton.props.onClick();
});
await flushMicrotasks();
const roundRobinOption = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('modern-select-option')
&& collectText(node).includes('轮询')
));
await act(async () => {
roundRobinOption.props.onClick();
});
await flushMicrotasks();
expect(apiMock.updateRoute).toHaveBeenCalledWith(1, { routingStrategy: 'round_robin' });
expect(apiMock.getRoutesSummary).toHaveBeenCalledTimes(2);
const strategyTrigger = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('modern-select-trigger')
&& collectText(node).includes('轮询')
));
expect(collectText(strategyTrigger)).toContain('轮询');
} finally {
root?.unmount();
}
});
});