forked from boos/BoosAPI
Merge pull request #124 from cita-777/codex/zero-channel-routes
[codex] add zero-channel placeholders and remaining workspace changes
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
[
|
||||
{
|
||||
"id": 58,
|
||||
"account_id": 18,
|
||||
"name": "metapi",
|
||||
"token": "MoKuoMzqsfLbPsZ4RtCJ3uLm2TZALUVwSivGatJQ8UlClPxz",
|
||||
"source": "sync",
|
||||
"enabled": 1,
|
||||
"is_default": 1,
|
||||
"created_at": "2026-03-04T13:00:10.239Z",
|
||||
"updated_at": "2026-03-08T12:45:05.380Z",
|
||||
"token_group": "gemini"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,44 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
|
||||
const dbPath = './data/hub.db';
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
|
||||
async function run() {
|
||||
const sites = db.prepare(`SELECT * FROM sites WHERE url LIKE '%openai.api-test.us.ci%'`).all() as any[];
|
||||
if (sites.length === 0) {
|
||||
console.log("No sites found");
|
||||
return;
|
||||
}
|
||||
|
||||
const site = sites[0];
|
||||
const accounts = db.prepare(`SELECT * FROM accounts WHERE site_id = ?`).all(site.id) as any[];
|
||||
|
||||
if (accounts.length === 0) {
|
||||
console.log("No accounts found");
|
||||
return;
|
||||
}
|
||||
|
||||
const account = accounts[0];
|
||||
const accessToken = account.access_token;
|
||||
|
||||
console.log(`Site: ${site.url}, Platform: ${site.platform}`);
|
||||
console.log(`Access Token: ${accessToken.substring(0, 10)}...`);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${site.url}/api/token/?p=0`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
const data = await res.json();
|
||||
fs.writeFileSync('tokens_result.json', JSON.stringify({ site, accounts, apiData: data }, null, 2));
|
||||
console.log('Saved result to tokens_result.json');
|
||||
} catch (err: any) {
|
||||
console.error('Fetch error:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -0,0 +1,9 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
|
||||
const dbPath = './data/hub.db';
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
|
||||
const tokens = db.prepare(`SELECT * FROM account_tokens WHERE account_id = 18`).all();
|
||||
fs.writeFileSync('account_tokens_18.json', JSON.stringify(tokens, null, 2));
|
||||
console.log('Saved tokens to account_tokens_18.json', tokens.length);
|
||||
@@ -4,6 +4,7 @@ import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { PgDialect } from 'drizzle-orm/pg-core';
|
||||
|
||||
type DbModule = typeof import('../../db/index.js');
|
||||
|
||||
@@ -39,6 +40,19 @@ describe('downstream api keys routes', () => {
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
it('builds postgres trend buckets by casting text timestamps before date_trunc', async () => {
|
||||
const routesModule = await import('./downstreamApiKeys.js') as Record<string, any>;
|
||||
|
||||
expect(typeof routesModule.buildBucketTsExpressionForDialect).toBe('function');
|
||||
|
||||
const expression = routesModule.buildBucketTsExpressionForDialect('postgres', schema.proxyLogs.createdAt, 3600);
|
||||
const rendered = new PgDialect().sqlToQuery(expression).sql;
|
||||
|
||||
expect(rendered).toContain('date_trunc');
|
||||
expect(rendered).toContain('cast');
|
||||
expect(rendered).toContain('timestamp');
|
||||
});
|
||||
|
||||
it('creates, updates, resets and deletes downstream api keys', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'portal-site',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { and, eq, inArray, sql, type SQL } from 'drizzle-orm';
|
||||
import { and, eq, inArray, sql, type SQL, type SQLWrapper } from 'drizzle-orm';
|
||||
import { db, hasProxyLogDownstreamApiKeyIdColumn, runtimeDbDialect, schema } from '../../db/index.js';
|
||||
import {
|
||||
getDownstreamApiKeyById,
|
||||
@@ -95,18 +95,27 @@ function resolveBucketSeconds(range: DownstreamKeyRange): number {
|
||||
return range === 'all' ? 86400 : 3600;
|
||||
}
|
||||
|
||||
function resolveBucketTsExpression(bucketSeconds: number) {
|
||||
if (runtimeDbDialect === 'mysql') {
|
||||
return sql<number>`floor(unix_timestamp(${schema.proxyLogs.createdAt}) / ${bucketSeconds}) * ${bucketSeconds}`;
|
||||
export function buildBucketTsExpressionForDialect(
|
||||
dialect: 'sqlite' | 'mysql' | 'postgres',
|
||||
createdAtColumn: SQLWrapper,
|
||||
bucketSeconds: number,
|
||||
) {
|
||||
if (dialect === 'mysql') {
|
||||
return sql<number>`floor(unix_timestamp(${createdAtColumn}) / ${bucketSeconds}) * ${bucketSeconds}`;
|
||||
}
|
||||
if (runtimeDbDialect === 'postgres') {
|
||||
if (dialect === 'postgres') {
|
||||
const createdAtTimestamp = sql`cast(${createdAtColumn} as timestamp)`;
|
||||
if (bucketSeconds === 86400) {
|
||||
return sql<number>`extract(epoch from date_trunc('day', ${schema.proxyLogs.createdAt}))::bigint`;
|
||||
return sql<number>`extract(epoch from date_trunc('day', ${createdAtTimestamp}))::bigint`;
|
||||
}
|
||||
return sql<number>`extract(epoch from date_trunc('hour', ${schema.proxyLogs.createdAt}))::bigint`;
|
||||
return sql<number>`extract(epoch from date_trunc('hour', ${createdAtTimestamp}))::bigint`;
|
||||
}
|
||||
// sqlite
|
||||
return sql<number>`cast(cast(strftime('%s', ${schema.proxyLogs.createdAt}) as integer) / ${bucketSeconds} as integer) * ${bucketSeconds}`;
|
||||
return sql<number>`cast(cast(strftime('%s', ${createdAtColumn}) as integer) / ${bucketSeconds} as integer) * ${bucketSeconds}`;
|
||||
}
|
||||
|
||||
function resolveBucketTsExpression(bucketSeconds: number) {
|
||||
return buildBucketTsExpressionForDialect(runtimeDbDialect, schema.proxyLogs.createdAt, bucketSeconds);
|
||||
}
|
||||
|
||||
async function validatePolicyReferences(input: {
|
||||
|
||||
@@ -52,6 +52,7 @@ vi.mock('../../db/index.js', () => ({
|
||||
|
||||
function parseSsePayloads(body: string): Array<Record<string, unknown>> {
|
||||
return body
|
||||
.replace(/\r\n/g, '\n')
|
||||
.split('\n\n')
|
||||
.map((block) => block.trim())
|
||||
.filter(Boolean)
|
||||
@@ -520,21 +521,32 @@ describe('gemini native proxy routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps non-sse json-array streaming payloads on the wire as chunk responses', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify([
|
||||
it('keeps non-sse json-array streaming payloads on the wire as raw chunk responses', async () => {
|
||||
const upstreamPayload = [
|
||||
{
|
||||
promptFeedback: { blockReason: 'BLOCK_REASON_UNSPECIFIED' },
|
||||
candidates: [
|
||||
{
|
||||
content: { role: 'model', parts: [{ text: 'first' }] },
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: { id: 'tool-1', name: 'lookup_weather', args: { city: 'Shanghai' } },
|
||||
thoughtSignature: 'sig-tool-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
groundingMetadata: { source: 'web' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
serverContent: { modelTurn: { parts: [{ text: 'tool result received' }] } },
|
||||
candidates: [
|
||||
{
|
||||
content: { role: 'model', parts: [{ text: 'second', thoughtSignature: 'sig-1' }] },
|
||||
citationMetadata: { citations: [{ startIndex: 0, endIndex: 5 }] },
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
@@ -545,7 +557,9 @@ describe('gemini native proxy routes', () => {
|
||||
thoughtsTokenCount: 3,
|
||||
},
|
||||
},
|
||||
]), {
|
||||
];
|
||||
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify(upstreamPayload), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
@@ -567,45 +581,7 @@ describe('gemini native proxy routes', () => {
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual([
|
||||
{
|
||||
responseId: '',
|
||||
modelVersion: '',
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
finishReason: 'STOP',
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ text: 'first' }],
|
||||
},
|
||||
groundingMetadata: { source: 'web' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
responseId: '',
|
||||
modelVersion: '',
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
finishReason: 'STOP',
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ text: 'second', thoughtSignature: 'sig-1' }],
|
||||
},
|
||||
citationMetadata: { citations: [{ startIndex: 0, endIndex: 5 }] },
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 11,
|
||||
candidatesTokenCount: 6,
|
||||
totalTokenCount: 17,
|
||||
cachedContentTokenCount: 2,
|
||||
thoughtsTokenCount: 3,
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(response.json()).toEqual(upstreamPayload);
|
||||
});
|
||||
|
||||
it('derives gemini-3 thinkingLevel from OpenAI-style reasoning inputs in the runtime request path', async () => {
|
||||
@@ -665,12 +641,13 @@ describe('gemini native proxy routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('streams SSE payloads as normalized chunk events instead of cumulative aggregate snapshots', async () => {
|
||||
it('streams SSE payloads as raw upstream events without reserializing tool-calling chunks', async () => {
|
||||
const encoder = new TextEncoder();
|
||||
const upstreamBody = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode('data: {"responseId":"resp-sse","modelVersion":"gemini-2.5-flash","candidates":[{"content":{"role":"model","parts":[{"text":"first"}]},"groundingMetadata":{"source":"web"}}]}\r\n\r\n'));
|
||||
controller.enqueue(encoder.encode('data: {"candidates":[{"content":{"role":"model","parts":[{"text":"second","thoughtSignature":"sig-1"}]},"citationMetadata":{"citations":[{"startIndex":0,"endIndex":5}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":11,"candidatesTokenCount":6,"totalTokenCount":17,"cachedContentTokenCount":2,"thoughtsTokenCount":3}}\r\n\r\n'));
|
||||
controller.enqueue(encoder.encode('data: {"promptFeedback":{"blockReason":"BLOCK_REASON_UNSPECIFIED"},"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"id":"tool-1","name":"lookup_weather","args":{"city":"Shanghai"}},"thoughtSignature":"sig-tool-1"}]},"groundingMetadata":{"source":"web"}}]}\r\n\r\n'));
|
||||
controller.enqueue(encoder.encode('data: {"serverContent":{"modelTurn":{"parts":[{"text":"tool result received"}]}},"candidates":[{"content":{"role":"model","parts":[{"text":"second","thoughtSignature":"sig-1"}]},"citationMetadata":{"citations":[{"startIndex":0,"endIndex":5}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":11,"candidatesTokenCount":6,"totalTokenCount":17,"cachedContentTokenCount":2,"thoughtsTokenCount":3}}\r\n\r\n'));
|
||||
controller.enqueue(encoder.encode('data: [DONE]\r\n\r\n'));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
@@ -702,25 +679,30 @@ describe('gemini native proxy routes', () => {
|
||||
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0]).toMatchObject({
|
||||
responseId: 'resp-sse',
|
||||
modelVersion: 'gemini-2.5-flash',
|
||||
promptFeedback: { blockReason: 'BLOCK_REASON_UNSPECIFIED' },
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: 'first' }],
|
||||
parts: [
|
||||
{
|
||||
functionCall: { id: 'tool-1', name: 'lookup_weather', args: { city: 'Shanghai' } },
|
||||
thoughtSignature: 'sig-tool-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
groundingMetadata: { source: 'web' },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(events[0]).not.toMatchObject({
|
||||
candidates: [
|
||||
{
|
||||
citationMetadata: expect.anything(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(events[0]).not.toHaveProperty('responseId');
|
||||
expect(events[0]).not.toHaveProperty('modelVersion');
|
||||
expect(events[0].candidates?.[0]).not.toHaveProperty('finishReason');
|
||||
expect(events[1]).toMatchObject({
|
||||
serverContent: {
|
||||
modelTurn: {
|
||||
parts: [{ text: 'tool result received' }],
|
||||
},
|
||||
},
|
||||
usageMetadata: {
|
||||
promptTokenCount: 11,
|
||||
candidatesTokenCount: 6,
|
||||
@@ -738,14 +720,8 @@ describe('gemini native proxy routes', () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(events[1]).not.toMatchObject({
|
||||
candidates: [
|
||||
{
|
||||
groundingMetadata: expect.anything(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(response.body).not.toContain('\r\n\r\n');
|
||||
expect(response.body).toContain('\r\n\r\n');
|
||||
expect(response.body).toContain('data: [DONE]\r\n\r\n');
|
||||
});
|
||||
|
||||
it('falls back to the next channel when first Gemini channel returns 400 before any bytes are written', async () => {
|
||||
|
||||
@@ -5,8 +5,10 @@ import { extractResponseMetadata, serializeGeminiAggregateResponse } from './out
|
||||
import {
|
||||
applyJsonPayloadToAggregate,
|
||||
applySsePayloadsToAggregate,
|
||||
consumeUpstreamSseBuffer,
|
||||
geminiGenerateContentStream,
|
||||
parseGeminiStreamPayload,
|
||||
serializeUpstreamJsonPayload,
|
||||
serializeAggregateSsePayload,
|
||||
} from './stream.js';
|
||||
import { extractGeminiUsage } from './usage.js';
|
||||
@@ -169,6 +171,56 @@ describe('geminiGenerateContentStream', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('returns raw upstream sse blocks while aggregating tool-calling state', () => {
|
||||
const state = createGeminiGenerateContentAggregateState();
|
||||
const firstBlock = 'data: {"promptFeedback":{"blockReason":"BLOCK_REASON_UNSPECIFIED"},"candidates":[{"content":{"parts":[{"functionCall":{"id":"tool-1","name":"lookup","args":{"q":"cat"}},"thoughtSignature":"sig-tool-1"}]}}]}\r\n\r\n';
|
||||
const secondBlock = 'data: {"candidates":[{"content":{"parts":[{"text":"answer"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":4,"totalTokenCount":14}}\r\n\r\n';
|
||||
const doneBlock = 'data: [DONE]\r\n\r\n';
|
||||
const result = consumeUpstreamSseBuffer(
|
||||
state,
|
||||
`${firstBlock}${secondBlock}${doneBlock}data: {"responseId":"partial"`,
|
||||
);
|
||||
|
||||
expect(result.lines).toEqual([firstBlock, secondBlock, doneBlock]);
|
||||
expect(result.events).toEqual([
|
||||
{
|
||||
promptFeedback: { blockReason: 'BLOCK_REASON_UNSPECIFIED' },
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ functionCall: { id: 'tool-1', name: 'lookup', args: { q: 'cat' } }, thoughtSignature: 'sig-tool-1' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: 'answer' }],
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 4,
|
||||
totalTokenCount: 14,
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(result.rest).toBe('data: {"responseId":"partial"');
|
||||
expect(extractGeminiUsage(result.state)).toEqual({
|
||||
promptTokens: 10,
|
||||
completionTokens: 4,
|
||||
totalTokens: 14,
|
||||
cachedTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes object payloads through the JSON-array path', () => {
|
||||
const state = createGeminiGenerateContentAggregateState();
|
||||
|
||||
@@ -203,4 +255,47 @@ describe('geminiGenerateContentStream', () => {
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps json stream chunks raw while still aggregating usage', () => {
|
||||
const state = createGeminiGenerateContentAggregateState();
|
||||
const payload = [
|
||||
{
|
||||
promptFeedback: { blockReason: 'BLOCK_REASON_UNSPECIFIED' },
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ functionCall: { id: 'tool-1', name: 'lookup', args: { q: 'cat' } } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
serverContent: { modelTurn: { parts: [{ text: 'tool result received' }] } },
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: 'final answer' }],
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 8,
|
||||
candidatesTokenCount: 5,
|
||||
totalTokenCount: 13,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
expect(serializeUpstreamJsonPayload(state, payload, true)).toEqual(payload);
|
||||
expect(extractGeminiUsage(state)).toEqual({
|
||||
promptTokens: 8,
|
||||
completionTokens: 5,
|
||||
totalTokens: 13,
|
||||
cachedTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { serializeGeminiAggregateResponse } from './outbound.js';
|
||||
|
||||
type ParsedSsePayloads = {
|
||||
events: unknown[];
|
||||
lines: string[];
|
||||
rest: string;
|
||||
};
|
||||
|
||||
@@ -15,10 +16,11 @@ type GeminiGenerateContentStreamFormat = 'sse' | 'json';
|
||||
type ParsedGeminiStreamPayload = {
|
||||
format: GeminiGenerateContentStreamFormat;
|
||||
events: unknown[];
|
||||
lines?: string[];
|
||||
rest: string;
|
||||
};
|
||||
|
||||
type AppliedGeminiStreamPayloads = ParsedSsePayloads & {
|
||||
type AppliedGeminiStreamPayloads = ParsedGeminiStreamPayload & {
|
||||
state: GeminiGenerateContentAggregateState;
|
||||
};
|
||||
|
||||
@@ -27,19 +29,24 @@ function serializeSsePayload(payload: unknown): string {
|
||||
}
|
||||
|
||||
function parseSsePayloads(buffer: string): ParsedSsePayloads {
|
||||
const normalized = buffer.replace(/\r\n/g, '\n');
|
||||
const events: unknown[] = [];
|
||||
let rest = normalized;
|
||||
const lines: string[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
while (true) {
|
||||
const boundary = rest.indexOf('\n\n');
|
||||
if (boundary < 0) break;
|
||||
while (cursor < buffer.length) {
|
||||
const boundaryMatch = /\r?\n\r?\n/.exec(buffer.slice(cursor));
|
||||
if (!boundaryMatch || typeof boundaryMatch.index !== 'number') break;
|
||||
|
||||
const boundary = cursor + boundaryMatch.index;
|
||||
const block = buffer.slice(cursor, boundary);
|
||||
const rawBlock = buffer.slice(cursor, boundary + boundaryMatch[0].length);
|
||||
cursor = boundary + boundaryMatch[0].length;
|
||||
|
||||
const block = rest.slice(0, boundary);
|
||||
rest = rest.slice(boundary + 2);
|
||||
if (!block.trim()) continue;
|
||||
lines.push(rawBlock);
|
||||
|
||||
const data = block
|
||||
.replace(/\r\n/g, '\n')
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('data:'))
|
||||
.map((line) => line.slice(5).trimStart())
|
||||
@@ -55,7 +62,11 @@ function parseSsePayloads(buffer: string): ParsedSsePayloads {
|
||||
}
|
||||
}
|
||||
|
||||
return { events, rest };
|
||||
return {
|
||||
events,
|
||||
lines,
|
||||
rest: buffer.slice(cursor),
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -77,6 +88,7 @@ function parseGeminiStreamPayload(
|
||||
return {
|
||||
format: 'sse',
|
||||
events: parsed.events,
|
||||
lines: parsed.lines,
|
||||
rest: parsed.rest,
|
||||
};
|
||||
}
|
||||
@@ -123,8 +135,7 @@ function applyParsedPayloadToAggregate(
|
||||
}
|
||||
|
||||
return {
|
||||
events: parsed.events,
|
||||
rest: parsed.rest,
|
||||
...parsed,
|
||||
state,
|
||||
};
|
||||
}
|
||||
@@ -151,7 +162,7 @@ function consumeUpstreamSseBuffer(
|
||||
const applied = applySsePayloadsToAggregate(state, buffer);
|
||||
return {
|
||||
...applied,
|
||||
lines: applied.events.map((event) => serializeAggregateSsePayload(event)),
|
||||
lines: applied.lines ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -182,10 +193,11 @@ function serializeUpstreamJsonPayload(
|
||||
streamAction = false,
|
||||
): unknown {
|
||||
if (streamAction) {
|
||||
return parseJsonArrayPayload(payload).map((event) => {
|
||||
const events = parseJsonArrayPayload(payload);
|
||||
for (const event of events) {
|
||||
applyGeminiGenerateContentAggregate(state, event);
|
||||
return serializeAggregateJsonPayload(event);
|
||||
});
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
applyJsonPayloadToAggregate(state, payload);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useRef } from 'react';
|
||||
import React, { createContext, useContext, useState, useCallback, useMemo, useRef } from 'react';
|
||||
|
||||
type ToastType = 'success' | 'error' | 'info';
|
||||
|
||||
@@ -47,12 +47,18 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
setTimeout(() => removeToast(id), 3200);
|
||||
}, [removeToast]);
|
||||
|
||||
const value: ToastContextValue = {
|
||||
const success = useCallback((msg: string) => addToast('success', msg), [addToast]);
|
||||
const error = useCallback((msg: string) => addToast('error', msg), [addToast]);
|
||||
const info = useCallback((msg: string) => addToast('info', msg), [addToast]);
|
||||
|
||||
// Keep the context object stable so effect dependencies on `useToast()` do not refire
|
||||
// every time the toast list changes.
|
||||
const value = useMemo<ToastContextValue>(() => ({
|
||||
toast: addToast,
|
||||
success: useCallback((msg: string) => addToast('success', msg), [addToast]),
|
||||
error: useCallback((msg: string) => addToast('error', msg), [addToast]),
|
||||
info: useCallback((msg: string) => addToast('info', msg), [addToast]),
|
||||
};
|
||||
success,
|
||||
error,
|
||||
info,
|
||||
}), [addToast, error, info, success]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
|
||||
@@ -317,6 +317,49 @@ describe('DownstreamKeys page', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('does not refetch drawer trend repeatedly after a trend error toast', async () => {
|
||||
apiMock.getDownstreamApiKeyTrend
|
||||
.mockRejectedValueOnce(new Error('function date_trunc(unknown, text) does not exist'))
|
||||
.mockResolvedValue({
|
||||
success: true,
|
||||
buckets: [
|
||||
{ startUtc: '2026-03-15T08:00:00.000Z', totalRequests: 2, totalTokens: 1200, totalCost: 0.12, successRate: 100 },
|
||||
],
|
||||
});
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/downstream-keys']}>
|
||||
<ToastProvider>
|
||||
<DownstreamKeys />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const row = root!.root.findAll((node) => node.type === 'tr' && typeof node.props.onClick === 'function')[0];
|
||||
await act(async () => {
|
||||
row.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.getDownstreamApiKeyOverview).toHaveBeenCalledTimes(1);
|
||||
expect(apiMock.getDownstreamApiKeyTrend).toHaveBeenCalledTimes(1);
|
||||
|
||||
const toastMessages = root!.root.findAll((node) => {
|
||||
if (typeof node.props.className !== 'string') return false;
|
||||
return node.props.className.includes('toast-error') && collectText(node).includes('function date_trunc(unknown, text) does not exist');
|
||||
});
|
||||
expect(toastMessages).toHaveLength(1);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('separates exact models from group routes in advanced config and uses single-column layout', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type MissingTokenModelsByName,
|
||||
} from './helpers/routeMissingTokenHints.js';
|
||||
import { buildVisibleRouteList } from './helpers/routeListVisibility.js';
|
||||
import { buildZeroChannelPlaceholderRoutes } from './helpers/zeroChannelRoutes.js';
|
||||
|
||||
import type {
|
||||
RouteSortBy,
|
||||
@@ -80,6 +81,7 @@ export default function TokenRoutes() {
|
||||
const [activeGroupFilter, setActiveGroupFilter] = useState<GroupFilter>(null);
|
||||
const [filterCollapsed, setFilterCollapsed] = useState(true);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [showZeroChannelRoutes, setShowZeroChannelRoutes] = useState(false);
|
||||
const [sortBy, setSortBy] = useState<RouteSortBy>('channelCount');
|
||||
const [sortDir, setSortDir] = useState<RouteSortDir>('desc');
|
||||
|
||||
@@ -251,6 +253,16 @@ export default function TokenRoutes() {
|
||||
[routeSummaries],
|
||||
);
|
||||
|
||||
const zeroChannelPlaceholderRoutes = useMemo(
|
||||
() => buildZeroChannelPlaceholderRoutes(routeSummaries, missingTokenModelsByName, missingTokenGroupModelsByName),
|
||||
[routeSummaries, missingTokenModelsByName, missingTokenGroupModelsByName],
|
||||
);
|
||||
|
||||
const visibleRouteRows = useMemo(
|
||||
() => (showZeroChannelRoutes ? [...routeSummaries, ...zeroChannelPlaceholderRoutes] : routeSummaries),
|
||||
[routeSummaries, showZeroChannelRoutes, zeroChannelPlaceholderRoutes],
|
||||
);
|
||||
|
||||
const canSaveRoute = !saving
|
||||
&& !!form.modelPattern.trim()
|
||||
&& !getModelPatternError(form.modelPattern);
|
||||
@@ -391,22 +403,22 @@ export default function TokenRoutes() {
|
||||
|
||||
// Stable derived value: only changes when route patterns change (not on enabled toggle)
|
||||
const routePatterns = useMemo(
|
||||
() => routeSummaries.map((r) => ({ id: r.id, modelPattern: r.modelPattern })),
|
||||
() => visibleRouteRows.map((r) => ({ id: r.id, modelPattern: r.modelPattern })),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[routeSummaries.map((r) => `${r.id}:${r.modelPattern}`).join(',')],
|
||||
[visibleRouteRows.map((r) => `${r.id}:${r.modelPattern}`).join(',')],
|
||||
);
|
||||
|
||||
const routeBrandById = useMemo(() => {
|
||||
const next = new Map<number, BrandInfo | null>();
|
||||
for (const route of routeSummaries) {
|
||||
for (const route of visibleRouteRows) {
|
||||
next.set(route.id, resolveRouteBrand(route));
|
||||
}
|
||||
return next;
|
||||
}, [routeSummaries]);
|
||||
}, [visibleRouteRows]);
|
||||
|
||||
const listVisibleRoutes = useMemo(
|
||||
() => buildVisibleRouteList(routeSummaries, isExactModelPattern, matchesModelPattern),
|
||||
[routeSummaries],
|
||||
() => buildVisibleRouteList(visibleRouteRows, isExactModelPattern, matchesModelPattern),
|
||||
[visibleRouteRows],
|
||||
);
|
||||
|
||||
const brandList = useMemo(() => {
|
||||
@@ -503,7 +515,7 @@ export default function TokenRoutes() {
|
||||
const routeBrandIconCandidates = useMemo(() => {
|
||||
const byIcon = new Map<string, BrandInfo>();
|
||||
|
||||
for (const route of routeSummaries) {
|
||||
for (const route of visibleRouteRows) {
|
||||
const brand = resolveRouteBrand(route);
|
||||
if (brand) byIcon.set(brand.icon, brand);
|
||||
}
|
||||
@@ -515,7 +527,7 @@ export default function TokenRoutes() {
|
||||
|
||||
return Array.from(byIcon.values())
|
||||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
|
||||
}, [routeSummaries, modelCandidates]);
|
||||
}, [visibleRouteRows, modelCandidates]);
|
||||
|
||||
const routeIconSelectOptions = useMemo<RouteIconOption[]>(() => ([
|
||||
...ROUTE_ICON_OPTIONS,
|
||||
@@ -656,6 +668,11 @@ export default function TokenRoutes() {
|
||||
return routeModelCandidateIndex[routeId] || EMPTY_ROUTE_CANDIDATE_VIEW;
|
||||
};
|
||||
|
||||
const routeById = useMemo(
|
||||
() => new Map(visibleRouteRows.map((route) => [route.id, route])),
|
||||
[visibleRouteRows],
|
||||
);
|
||||
|
||||
const handleCreateTokenForMissingAccount = (accountId: number, modelName: string) => {
|
||||
if (!Number.isFinite(accountId) || accountId <= 0) return;
|
||||
const params = new URLSearchParams();
|
||||
@@ -758,7 +775,9 @@ export default function TokenRoutes() {
|
||||
} else {
|
||||
setExpandedRouteIds((prev) => [...prev, routeId]);
|
||||
// Load channels on demand
|
||||
if (!channelsByRouteId[routeId]) {
|
||||
const route = routeById.get(routeId) || null;
|
||||
const isReadOnlyRoute = route?.kind === 'zero_channel' || route?.readOnly === true || route?.isVirtual === true;
|
||||
if (!channelsByRouteId[routeId] && !isReadOnlyRoute) {
|
||||
try {
|
||||
await loadChannels(routeId);
|
||||
} catch {
|
||||
@@ -1004,6 +1023,16 @@ export default function TokenRoutes() {
|
||||
? tr('取消编辑')
|
||||
: (showManual ? tr('收起群组创建') : tr('新建群组'))}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={showZeroChannelRoutes}
|
||||
onClick={() => setShowZeroChannelRoutes((prev) => !prev)}
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
|
||||
>
|
||||
{showZeroChannelRoutes ? tr('隐藏 0 通道路由') : tr('显示 0 通道路由')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span className="badge badge-info" style={{ fontSize: 12, fontWeight: 500, marginLeft: 'auto' }}>
|
||||
@@ -1086,6 +1115,7 @@ export default function TokenRoutes() {
|
||||
<div className={isMobile ? 'mobile-card-list' : 'route-card-grid'}>
|
||||
{visibleRoutes.map((route) => {
|
||||
const isExpanded = expandedRouteIds.includes(route.id);
|
||||
const isReadOnlyRoute = route.kind === 'zero_channel' || route.readOnly === true || route.isVirtual === true;
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
@@ -1093,15 +1123,15 @@ export default function TokenRoutes() {
|
||||
key={route.id}
|
||||
title={resolveRouteTitle(route)}
|
||||
actions={(
|
||||
<span className={`badge ${route.enabled ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 10 }}>
|
||||
{route.enabled ? tr('启用') : tr('禁用')}
|
||||
<span className={`badge ${isReadOnlyRoute ? 'badge-muted' : (route.enabled ? 'badge-success' : 'badge-muted')}`} style={{ fontSize: 10 }}>
|
||||
{isReadOnlyRoute ? tr('未生成') : (route.enabled ? tr('启用') : tr('禁用'))}
|
||||
</span>
|
||||
)}
|
||||
>
|
||||
<MobileField label="模型" value={route.modelPattern} />
|
||||
<MobileField label="通道" value={route.channelCount} />
|
||||
<MobileField label="策略" value={route.routingStrategy === 'round_robin' ? tr('轮询') : tr('权重随机')} />
|
||||
<MobileField label="状态" value={route.enabled ? tr('启用') : tr('禁用')} />
|
||||
<MobileField label="策略" value={isReadOnlyRoute ? tr('未生成') : (route.routingStrategy === 'round_robin' ? tr('轮询') : tr('权重随机'))} />
|
||||
<MobileField label="状态" value={isReadOnlyRoute ? tr('未生成') : (route.enabled ? tr('启用') : tr('禁用'))} />
|
||||
<div className="mobile-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildZeroChannelPlaceholderRoutes } from './zeroChannelRoutes.js';
|
||||
import type { MissingTokenModelsByName } from './routeMissingTokenHints.js';
|
||||
import type { RouteSummaryRow } from '../token-routes/types.js';
|
||||
|
||||
describe('buildZeroChannelPlaceholderRoutes', () => {
|
||||
it('merges missing-token and missing-group models into exact zero-channel placeholders', () => {
|
||||
const routes: RouteSummaryRow[] = [
|
||||
{
|
||||
id: 1,
|
||||
modelPattern: 'gpt-4o-mini',
|
||||
displayName: 'gpt-4o-mini',
|
||||
displayIcon: null,
|
||||
modelMapping: null,
|
||||
routingStrategy: 'weighted',
|
||||
enabled: true,
|
||||
channelCount: 1,
|
||||
enabledChannelCount: 1,
|
||||
siteNames: ['site-a'],
|
||||
decisionSnapshot: null,
|
||||
decisionRefreshedAt: null,
|
||||
},
|
||||
];
|
||||
|
||||
const modelsWithoutToken: MissingTokenModelsByName = {
|
||||
'gpt-4o-mini': [
|
||||
{ accountId: 1, username: 'alice', siteId: 11, siteName: 'site-a' },
|
||||
],
|
||||
'gpt-5.2-codex': [
|
||||
{ accountId: 2, username: 'bob', siteId: 12, siteName: 'site-b' },
|
||||
],
|
||||
};
|
||||
const modelsMissingTokenGroups: MissingTokenModelsByName = {
|
||||
'gpt-5.2-codex': [
|
||||
{
|
||||
accountId: 3,
|
||||
username: 'cici',
|
||||
siteId: 13,
|
||||
siteName: 'site-c',
|
||||
missingGroups: ['opus'],
|
||||
requiredGroups: ['default', 'opus'],
|
||||
availableGroups: ['default'],
|
||||
},
|
||||
],
|
||||
're:^claude-.*$': [
|
||||
{ accountId: 4, username: 'dan', siteId: 14, siteName: 'site-d' },
|
||||
],
|
||||
};
|
||||
|
||||
const placeholders = buildZeroChannelPlaceholderRoutes(routes, modelsWithoutToken, modelsMissingTokenGroups);
|
||||
|
||||
expect(placeholders).toHaveLength(1);
|
||||
expect(placeholders[0]).toMatchObject({
|
||||
modelPattern: 'gpt-5.2-codex',
|
||||
channelCount: 0,
|
||||
enabledChannelCount: 0,
|
||||
enabled: false,
|
||||
decisionSnapshot: null,
|
||||
kind: 'zero_channel',
|
||||
readOnly: true,
|
||||
isVirtual: true,
|
||||
});
|
||||
expect(placeholders[0].siteNames).toEqual(['site-b', 'site-c']);
|
||||
expect(placeholders[0].id).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { MissingTokenModelsByName } from './routeMissingTokenHints.js';
|
||||
import type { RouteSummaryRow } from '../token-routes/types.js';
|
||||
import { isExactModelPattern } from '../token-routes/utils.js';
|
||||
|
||||
function buildStableVirtualRouteId(modelName: string): number {
|
||||
const normalized = modelName.trim().toLowerCase();
|
||||
let hash = 0;
|
||||
for (let index = 0; index < normalized.length; index += 1) {
|
||||
hash = ((hash * 131) + normalized.charCodeAt(index)) % 2_147_483_647;
|
||||
}
|
||||
return -Math.max(1, hash || normalized.length || 1);
|
||||
}
|
||||
|
||||
export function buildZeroChannelPlaceholderRoutes(
|
||||
routes: RouteSummaryRow[],
|
||||
modelsWithoutToken: MissingTokenModelsByName,
|
||||
modelsMissingTokenGroups: MissingTokenModelsByName,
|
||||
): RouteSummaryRow[] {
|
||||
const exactRouteNames = new Set(
|
||||
(routes || [])
|
||||
.filter((route) => isExactModelPattern(route.modelPattern))
|
||||
.map((route) => (route.modelPattern || '').trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
const placeholderByModel = new Map<string, RouteSummaryRow>();
|
||||
const mergeMissingHints = (missingByModel: MissingTokenModelsByName) => {
|
||||
for (const [rawModelName, accounts] of Object.entries(missingByModel || {})) {
|
||||
const modelName = String(rawModelName || '').trim();
|
||||
if (!modelName) continue;
|
||||
if (!isExactModelPattern(modelName)) continue;
|
||||
if (exactRouteNames.has(modelName.toLowerCase())) continue;
|
||||
|
||||
const routeKey = modelName.toLowerCase();
|
||||
const existing = placeholderByModel.get(routeKey);
|
||||
const siteNames = new Set<string>(existing?.siteNames || []);
|
||||
for (const account of accounts || []) {
|
||||
const siteName = String(account?.siteName || '').trim();
|
||||
if (siteName) siteNames.add(siteName);
|
||||
}
|
||||
|
||||
placeholderByModel.set(routeKey, {
|
||||
id: buildStableVirtualRouteId(modelName),
|
||||
modelPattern: modelName,
|
||||
displayName: null,
|
||||
displayIcon: null,
|
||||
modelMapping: null,
|
||||
routingStrategy: null,
|
||||
enabled: false,
|
||||
channelCount: 0,
|
||||
enabledChannelCount: 0,
|
||||
siteNames: Array.from(siteNames).sort((left, right) => left.localeCompare(right, undefined, { sensitivity: 'base' })),
|
||||
decisionSnapshot: null,
|
||||
decisionRefreshedAt: null,
|
||||
kind: 'zero_channel',
|
||||
readOnly: true,
|
||||
isVirtual: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
mergeMissingHints(modelsWithoutToken);
|
||||
mergeMissingHints(modelsMissingTokenGroups);
|
||||
|
||||
return Array.from(placeholderByModel.values())
|
||||
.sort((left, right) => left.modelPattern.localeCompare(right.modelPattern, undefined, { sensitivity: 'base' }));
|
||||
}
|
||||
@@ -114,6 +114,7 @@ function RouteCardInner({
|
||||
}: RouteCardProps) {
|
||||
const routeIcon = resolveRouteIcon(route);
|
||||
const exactRoute = isExactModelPattern(route.modelPattern);
|
||||
const readOnlyRoute = route.kind === 'zero_channel' || route.readOnly === true || route.isVirtual === true;
|
||||
const title = resolveRouteTitle(route);
|
||||
const routingStrategy = route.routingStrategy === 'round_robin' ? 'round_robin' : 'weighted';
|
||||
const routingStrategyOptions = [
|
||||
@@ -187,6 +188,11 @@ function RouteCardInner({
|
||||
<span className="badge badge-muted" style={{ fontSize: 10, flexShrink: 0 }}>{route.modelPattern}</span>
|
||||
) : null}
|
||||
|
||||
{readOnlyRoute ? (
|
||||
<span className="badge badge-muted" style={{ fontSize: 10, flexShrink: 0 }}>
|
||||
{tr('未生成')}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
className={`badge route-enable-toggle ${route.enabled ? 'is-enabled' : 'is-disabled'}`}
|
||||
style={{ fontSize: 11, cursor: 'pointer', border: 'none', flexShrink: 0, minWidth: 36, textAlign: 'center' }}
|
||||
@@ -195,14 +201,21 @@ function RouteCardInner({
|
||||
>
|
||||
{route.enabled ? tr('启用') : tr('禁用')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<span className="badge badge-info" style={{ fontSize: 10, flexShrink: 0 }}>
|
||||
{route.channelCount} {tr('通道')}
|
||||
</span>
|
||||
|
||||
{readOnlyRoute ? (
|
||||
<span className="badge badge-warning" style={{ fontSize: 10, flexShrink: 0 }}>
|
||||
{tr('0 通道')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="badge badge-muted" style={{ fontSize: 10, flexShrink: 0 }}>
|
||||
{routingStrategy === 'round_robin' ? tr('轮询') : tr('权重随机')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<svg
|
||||
width="14" height="14" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2"
|
||||
@@ -239,6 +252,11 @@ function RouteCardInner({
|
||||
{route.displayName && route.displayName.trim() !== route.modelPattern ? (
|
||||
<span className="badge badge-muted" style={{ fontSize: 10 }}>{route.modelPattern}</span>
|
||||
) : null}
|
||||
{readOnlyRoute ? (
|
||||
<span className="badge badge-muted" style={{ fontSize: 10 }}>
|
||||
{tr('未生成')}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
className={`badge route-enable-toggle ${route.enabled ? 'is-enabled' : 'is-disabled'}`}
|
||||
style={{ fontSize: 11, cursor: 'pointer', border: 'none' }}
|
||||
@@ -247,19 +265,25 @@ function RouteCardInner({
|
||||
>
|
||||
{route.enabled ? tr('启用') : tr('禁用')}
|
||||
</button>
|
||||
)}
|
||||
<span className="badge badge-info" style={{ fontSize: 10 }}>
|
||||
{route.channelCount} {tr('通道')}
|
||||
</span>
|
||||
{readOnlyRoute && (
|
||||
<span className="badge badge-warning" style={{ fontSize: 10 }}>
|
||||
{tr('0 通道')}
|
||||
</span>
|
||||
)}
|
||||
{savingPriority && (
|
||||
<span className="badge badge-warning" style={{ fontSize: 10 }}>{tr('排序保存中')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
{!exactRoute && (
|
||||
{!readOnlyRoute && !exactRoute && (
|
||||
<button onClick={() => onEdit(route)} className="btn btn-link">{tr('编辑群组')}</button>
|
||||
)}
|
||||
<button onClick={() => onDelete(route.id)} className="btn btn-link btn-link-danger">{tr('删除路由')}</button>
|
||||
{!readOnlyRoute && <button onClick={() => onDelete(route.id)} className="btn btn-link btn-link-danger">{tr('删除路由')}</button>}
|
||||
<button
|
||||
onClick={() => onToggleExpand(route.id)}
|
||||
className="btn btn-ghost"
|
||||
@@ -283,6 +307,7 @@ function RouteCardInner({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!readOnlyRoute && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
|
||||
{tr('路由策略')}
|
||||
@@ -299,6 +324,7 @@ function RouteCardInner({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Missing token hints + Add channel button */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
|
||||
@@ -340,6 +366,7 @@ function RouteCardInner({
|
||||
)}
|
||||
</div>
|
||||
) : <div />}
|
||||
{!readOnlyRoute && (
|
||||
<button
|
||||
onClick={() => onAddChannel(route.id)}
|
||||
className="btn btn-ghost"
|
||||
@@ -347,6 +374,7 @@ function RouteCardInner({
|
||||
>
|
||||
+ {tr('添加通道')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Channel list */}
|
||||
@@ -444,7 +472,9 @@ function RouteCardInner({
|
||||
</DndContext>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 13, color: 'var(--color-text-muted)', paddingLeft: 4 }}>暂无通道</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--color-text-muted)', paddingLeft: 4 }}>
|
||||
{readOnlyRoute ? tr('暂无通道,先补齐令牌后再重建路由。') : tr('暂无通道')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ export type RouteSortBy = 'modelPattern' | 'channelCount';
|
||||
export type RouteSortDir = 'asc' | 'desc';
|
||||
export type GroupFilter = null | '__all__' | number;
|
||||
export type RouteRoutingStrategy = 'weighted' | 'round_robin';
|
||||
export type RouteRowKind = 'persisted' | 'zero_channel';
|
||||
|
||||
export type RouteChannelDraft = {
|
||||
accountId: number;
|
||||
@@ -67,6 +68,9 @@ export type RouteSummaryRow = {
|
||||
siteNames: string[];
|
||||
decisionSnapshot: RouteDecision | null;
|
||||
decisionRefreshedAt: string | null;
|
||||
kind?: RouteRowKind;
|
||||
readOnly?: boolean;
|
||||
isVirtual?: boolean;
|
||||
};
|
||||
|
||||
export type RouteDecisionCandidate = {
|
||||
|
||||
@@ -250,6 +250,112 @@ describe('TokenRoutes grouped source models', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps zero-channel placeholder routes hidden by default', async () => {
|
||||
apiMock.getRoutesSummary.mockResolvedValue([]);
|
||||
apiMock.getModelTokenCandidates.mockResolvedValue({
|
||||
models: {},
|
||||
modelsWithoutToken: {
|
||||
'gpt-5.2-codex': [
|
||||
{ accountId: 101, username: 'linuxdo_11494', siteId: 11, siteName: 'Wong' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
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).toContain('显示 0 通道路由');
|
||||
expect(text).not.toContain('gpt-5.2-codex');
|
||||
expect(text).not.toContain('未生成');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows read-only zero-channel placeholder routes after toggle without loading channels', async () => {
|
||||
apiMock.getRoutesSummary.mockResolvedValue([]);
|
||||
apiMock.getModelTokenCandidates.mockResolvedValue({
|
||||
models: {},
|
||||
modelsWithoutToken: {
|
||||
'gpt-5.2-codex': [
|
||||
{ accountId: 101, username: 'linuxdo_11494', siteId: 11, siteName: 'Wong' },
|
||||
],
|
||||
},
|
||||
modelsMissingTokenGroups: {
|
||||
'claude-opus-4-6': [
|
||||
{
|
||||
accountId: 201,
|
||||
username: 'linuxdo_4677',
|
||||
siteId: 12,
|
||||
siteName: '香草api',
|
||||
missingGroups: ['opus'],
|
||||
requiredGroups: ['default', 'opus'],
|
||||
availableGroups: ['default'],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/routes']}>
|
||||
<ToastProvider>
|
||||
<TokenRoutes />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const toggle = findButtonByText(root.root, '显示 0 通道路由');
|
||||
await act(async () => {
|
||||
toggle.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(collectText(root.root)).toContain('隐藏 0 通道路由');
|
||||
expect(collectText(root.root)).toContain('gpt-5.2-codex');
|
||||
expect(collectText(root.root)).toContain('claude-opus-4-6');
|
||||
expect(collectText(root.root)).toContain('未生成');
|
||||
expect(collectText(root.root)).toContain('0 通道');
|
||||
|
||||
const expandCards = root.root.findAll((node) =>
|
||||
node.type === 'div' && String(node.props.className || '').includes('route-card-collapsed'),
|
||||
);
|
||||
const gptCard = expandCards.find((node) => collectText(node).includes('gpt-5.2-codex'));
|
||||
expect(gptCard).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
gptCard!.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const expandedText = collectText(root.root);
|
||||
expect(expandedText).toContain('待注册站点');
|
||||
expect(expandedText).toContain('Wong');
|
||||
expect(expandedText).toContain('暂无通道,先补齐令牌后再重建路由。');
|
||||
expect(expandedText).not.toContain('添加通道');
|
||||
expect(expandedText).not.toContain('删除路由');
|
||||
expect(expandedText).not.toContain('编辑群组');
|
||||
expect(apiMock.getRouteChannels).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not render missing-token site tags when the hint lacks a valid account id', async () => {
|
||||
apiMock.getRoutesSummary.mockResolvedValue([
|
||||
{
|
||||
@@ -575,6 +681,52 @@ describe('TokenRoutes grouped source models', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('still hides zero-channel placeholders when a named group route covers the exact model', async () => {
|
||||
apiMock.getRoutesSummary.mockResolvedValue([
|
||||
{
|
||||
id: 3, modelPattern: 're:^(gpt-5\\.2-codex)$', displayName: 'Codex',
|
||||
displayIcon: null, modelMapping: null, enabled: true,
|
||||
channelCount: 0, enabledChannelCount: 0, siteNames: [],
|
||||
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||
},
|
||||
]);
|
||||
apiMock.getModelTokenCandidates.mockResolvedValue({
|
||||
models: {},
|
||||
modelsWithoutToken: {
|
||||
'gpt-5.2-codex': [
|
||||
{ accountId: 101, username: 'linuxdo_11494', siteId: 11, siteName: 'Wong' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/routes']}>
|
||||
<ToastProvider>
|
||||
<TokenRoutes />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const toggle = findButtonByText(root.root, '显示 0 通道路由');
|
||||
await act(async () => {
|
||||
toggle.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const normalizedText = collectText(root.root).replace(/\s+/g, '');
|
||||
expect(normalizedText).toContain('共1条路由');
|
||||
expect(normalizedText).toContain('Codex');
|
||||
expect(normalizedText).not.toContain('gpt-5.2-codex0通道');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps exact routes visible when a group display name collides with a real exact model', async () => {
|
||||
apiMock.getRoutesSummary.mockResolvedValue([
|
||||
{
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import fs from 'fs';
|
||||
|
||||
async function run() {
|
||||
const url = 'https://openai.api-test.us.ci';
|
||||
const accessToken = 'nMld3p6XNa6dB2CjwDKPAnJFb35Z';
|
||||
const userId = '2434';
|
||||
|
||||
try {
|
||||
const res = await fetch(`${url}/api/token/?p=0`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'New-Api-User': userId,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
const data = await res.json();
|
||||
fs.writeFileSync('tokens_result2.json', JSON.stringify({ apiData: data }, null, 2));
|
||||
console.log('Saved result to tokens_result2.json');
|
||||
} catch (err: any) {
|
||||
console.error('Fetch error:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"site": {
|
||||
"id": 17,
|
||||
"name": "api-test.us.ci",
|
||||
"url": "https://openai.api-test.us.ci",
|
||||
"platform": "new-api",
|
||||
"proxy_url": null,
|
||||
"status": "active",
|
||||
"is_pinned": 0,
|
||||
"sort_order": 3,
|
||||
"api_key": "",
|
||||
"created_at": "2026-03-01 11:50:09",
|
||||
"updated_at": "2026-03-01 11:50:09",
|
||||
"global_weight": 1,
|
||||
"external_checkin_url": null,
|
||||
"use_system_proxy": 0,
|
||||
"custom_headers": null
|
||||
},
|
||||
"accounts": [
|
||||
{
|
||||
"id": 18,
|
||||
"site_id": 17,
|
||||
"username": "linuxdo_2434",
|
||||
"access_token": "nMld3p6XNa6dB2CjwDKPAnJFb35Z",
|
||||
"api_token": "MoKuoMzqsfLbPsZ4RtCJ3uLm2TZALUVwSivGatJQ8UlClPxz",
|
||||
"balance": 22.53511,
|
||||
"balance_used": 0.001764,
|
||||
"quota": 22.536874,
|
||||
"unit_cost": null,
|
||||
"value_score": 0,
|
||||
"status": "expired",
|
||||
"is_pinned": 0,
|
||||
"sort_order": 4,
|
||||
"checkin_enabled": 1,
|
||||
"last_checkin_at": "2026-03-16T00:00:01.203Z",
|
||||
"last_balance_refresh": "2026-03-16T00:00:04.473Z",
|
||||
"extra_config": "{\"platformUserId\":2434,\"todayIncomeSnapshot\":{\"day\":\"2026-03-16\",\"baseline\":0,\"latest\":8.970182,\"updatedAt\":\"2026-03-16T00:00:04.473Z\"},\"runtimeHealth\":{\"state\":\"unhealthy\",\"reason\":\"访问令牌失效:无权进行此操作,access token 无效,请在中转站重新生成系统访问令牌后重新绑定账号\",\"source\":\"auth\",\"checkedAt\":\"2026-03-16T00:00:12.099Z\"}}",
|
||||
"created_at": "2026-03-01 11:51:09",
|
||||
"updated_at": "2026-03-16T00:00:12.099Z"
|
||||
}
|
||||
],
|
||||
"apiData": {
|
||||
"message": "无权进行此操作,未提供 New-Api-User",
|
||||
"success": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"apiData": {
|
||||
"data": {
|
||||
"page": 1,
|
||||
"page_size": 10,
|
||||
"total": 1,
|
||||
"items": [
|
||||
{
|
||||
"id": 6051,
|
||||
"user_id": 2434,
|
||||
"key": "MoKu**********lPxz",
|
||||
"status": 1,
|
||||
"name": "metapi",
|
||||
"created_time": 1772628598,
|
||||
"accessed_time": 1772628683,
|
||||
"expired_time": -1,
|
||||
"remain_quota": -882,
|
||||
"unlimited_quota": true,
|
||||
"model_limits_enabled": false,
|
||||
"model_limits": "",
|
||||
"allow_ips": "",
|
||||
"used_quota": 882,
|
||||
"group": "gemini",
|
||||
"cross_group_retry": false,
|
||||
"DeletedAt": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"message": "",
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user