From 52441f2634c983c5942eb913c066ea14bdcab4d3 Mon Sep 17 00:00:00 2001 From: cita Date: Mon, 16 Mar 2026 19:25:02 +0800 Subject: [PATCH 1/3] add zero-channel route placeholders --- src/web/pages/TokenRoutes.tsx | 56 +++++-- .../pages/helpers/zeroChannelRoutes.test.ts | 66 ++++++++ src/web/pages/helpers/zeroChannelRoutes.ts | 67 ++++++++ src/web/pages/token-routes/RouteCard.tsx | 118 +++++++++----- src/web/pages/token-routes/types.ts | 4 + .../pages/tokenRoutes.group-collapse.test.tsx | 151 ++++++++++++++++++ 6 files changed, 405 insertions(+), 57 deletions(-) create mode 100644 src/web/pages/helpers/zeroChannelRoutes.test.ts create mode 100644 src/web/pages/helpers/zeroChannelRoutes.ts diff --git a/src/web/pages/TokenRoutes.tsx b/src/web/pages/TokenRoutes.tsx index 184cd06..c880c86 100644 --- a/src/web/pages/TokenRoutes.tsx +++ b/src/web/pages/TokenRoutes.tsx @@ -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(null); const [filterCollapsed, setFilterCollapsed] = useState(true); const [showFilters, setShowFilters] = useState(false); + const [showZeroChannelRoutes, setShowZeroChannelRoutes] = useState(false); const [sortBy, setSortBy] = useState('channelCount'); const [sortDir, setSortDir] = useState('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(); - 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(); - 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(() => ([ ...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('新建群组'))} + + @@ -1086,6 +1115,7 @@ export default function TokenRoutes() {
{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={( - - {route.enabled ? tr('启用') : tr('禁用')} + + {isReadOnlyRoute ? tr('未生成') : (route.enabled ? tr('启用') : tr('禁用'))} )} > - - + +
+ {readOnlyRoute ? ( + + {tr('未生成')} + + ) : ( + + )} {route.channelCount} {tr('通道')} - - {routingStrategy === 'round_robin' ? tr('轮询') : tr('权重随机')} - + {readOnlyRoute ? ( + + {tr('0 通道')} + + ) : ( + + {routingStrategy === 'round_robin' ? tr('轮询') : tr('权重随机')} + + )} {route.modelPattern} ) : null} - + {readOnlyRoute ? ( + + {tr('未生成')} + + ) : ( + + )} {route.channelCount} {tr('通道')} + {readOnlyRoute && ( + + {tr('0 通道')} + + )} {savingPriority && ( {tr('排序保存中')} )}
- {!exactRoute && ( + {!readOnlyRoute && !exactRoute && ( )} - + {!readOnlyRoute && }
)} -
-
- {tr('路由策略')} + {!readOnlyRoute && ( +
+
+ {tr('路由策略')} +
+
+ onRoutingStrategyChange(route, nextValue as RouteRoutingStrategy)} + options={routingStrategyOptions.map((option) => ({ ...option }))} + placeholder={tr('选择路由策略')} + emptyLabel={tr('暂无可选策略')} + /> +
-
- onRoutingStrategyChange(route, nextValue as RouteRoutingStrategy)} - options={routingStrategyOptions.map((option) => ({ ...option }))} - placeholder={tr('选择路由策略')} - emptyLabel={tr('暂无可选策略')} - /> -
-
+ )} {/* Missing token hints + Add channel button */}
@@ -340,13 +366,15 @@ function RouteCardInner({ )}
) :
} - + {!readOnlyRoute && ( + + )}
{/* Channel list */} @@ -444,7 +472,9 @@ function RouteCardInner({
) : ( -
暂无通道
+
+ {readOnlyRoute ? tr('暂无通道,先补齐令牌后再重建路由。') : tr('暂无通道')} +
)}
); diff --git a/src/web/pages/token-routes/types.ts b/src/web/pages/token-routes/types.ts index ba0b53a..02f98cf 100644 --- a/src/web/pages/token-routes/types.ts +++ b/src/web/pages/token-routes/types.ts @@ -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 = { diff --git a/src/web/pages/tokenRoutes.group-collapse.test.tsx b/src/web/pages/tokenRoutes.group-collapse.test.tsx index 9e07906..961729a 100644 --- a/src/web/pages/tokenRoutes.group-collapse.test.tsx +++ b/src/web/pages/tokenRoutes.group-collapse.test.tsx @@ -250,6 +250,111 @@ 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 | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + 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 | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const toggle = findButtonByText(root.root, '显示 0 通道路由'); + await act(async () => { + toggle.props.onClick(); + }); + await flushMicrotasks(); + + 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 +680,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 | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + 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([ { From bec0457a92e88472010fe2d364c58be3d1b7796d Mon Sep 17 00:00:00 2001 From: cita Date: Mon, 16 Mar 2026 19:29:26 +0800 Subject: [PATCH 2/3] include remaining workspace changes --- account_tokens_18.json | 14 +++ query_site.ts | 44 ++++++++ query_tokens.ts | 9 ++ .../routes/api/downstreamApiKeys.test.ts | 14 +++ src/server/routes/api/downstreamApiKeys.ts | 25 +++-- src/server/routes/proxy/gemini.test.ts | 104 +++++++----------- .../gemini/generate-content/stream.test.ts | 95 ++++++++++++++++ .../gemini/generate-content/stream.ts | 40 ++++--- src/web/components/Toast.tsx | 18 ++- src/web/pages/DownstreamKeys.test.tsx | 43 ++++++++ src/web/pages/TokenRoutes.tsx | 2 +- .../pages/tokenRoutes.group-collapse.test.tsx | 1 + test_api.ts | 25 +++++ tokens_result.json | 46 ++++++++ tokens_result2.json | 32 ++++++ 15 files changed, 419 insertions(+), 93 deletions(-) create mode 100644 account_tokens_18.json create mode 100644 query_site.ts create mode 100644 query_tokens.ts create mode 100644 test_api.ts create mode 100644 tokens_result.json create mode 100644 tokens_result2.json diff --git a/account_tokens_18.json b/account_tokens_18.json new file mode 100644 index 0000000..71bab81 --- /dev/null +++ b/account_tokens_18.json @@ -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" + } +] \ No newline at end of file diff --git a/query_site.ts b/query_site.ts new file mode 100644 index 0000000..85ef3b9 --- /dev/null +++ b/query_site.ts @@ -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(); diff --git a/query_tokens.ts b/query_tokens.ts new file mode 100644 index 0000000..80546ff --- /dev/null +++ b/query_tokens.ts @@ -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); diff --git a/src/server/routes/api/downstreamApiKeys.test.ts b/src/server/routes/api/downstreamApiKeys.test.ts index a06610d..8a59c30 100644 --- a/src/server/routes/api/downstreamApiKeys.test.ts +++ b/src/server/routes/api/downstreamApiKeys.test.ts @@ -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; + + 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', diff --git a/src/server/routes/api/downstreamApiKeys.ts b/src/server/routes/api/downstreamApiKeys.ts index 36ad249..423db26 100644 --- a/src/server/routes/api/downstreamApiKeys.ts +++ b/src/server/routes/api/downstreamApiKeys.ts @@ -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`floor(unix_timestamp(${schema.proxyLogs.createdAt}) / ${bucketSeconds}) * ${bucketSeconds}`; +export function buildBucketTsExpressionForDialect( + dialect: 'sqlite' | 'mysql' | 'postgres', + createdAtColumn: SQLWrapper, + bucketSeconds: number, +) { + if (dialect === 'mysql') { + return sql`floor(unix_timestamp(${createdAtColumn}) / ${bucketSeconds}) * ${bucketSeconds}`; } - if (runtimeDbDialect === 'postgres') { + if (dialect === 'postgres') { + const createdAtTimestamp = sql`cast(${createdAtColumn} as timestamp)`; if (bucketSeconds === 86400) { - return sql`extract(epoch from date_trunc('day', ${schema.proxyLogs.createdAt}))::bigint`; + return sql`extract(epoch from date_trunc('day', ${createdAtTimestamp}))::bigint`; } - return sql`extract(epoch from date_trunc('hour', ${schema.proxyLogs.createdAt}))::bigint`; + return sql`extract(epoch from date_trunc('hour', ${createdAtTimestamp}))::bigint`; } // sqlite - return sql`cast(cast(strftime('%s', ${schema.proxyLogs.createdAt}) as integer) / ${bucketSeconds} as integer) * ${bucketSeconds}`; + return sql`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: { diff --git a/src/server/routes/proxy/gemini.test.ts b/src/server/routes/proxy/gemini.test.ts index fbe85af..445e3bd 100644 --- a/src/server/routes/proxy/gemini.test.ts +++ b/src/server/routes/proxy/gemini.test.ts @@ -52,6 +52,7 @@ vi.mock('../../db/index.js', () => ({ function parseSsePayloads(body: string): Array> { 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({ 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 () => { diff --git a/src/server/transformers/gemini/generate-content/stream.test.ts b/src/server/transformers/gemini/generate-content/stream.test.ts index f171c02..2b8e987 100644 --- a/src/server/transformers/gemini/generate-content/stream.test.ts +++ b/src/server/transformers/gemini/generate-content/stream.test.ts @@ -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, + }); + }); }); diff --git a/src/server/transformers/gemini/generate-content/stream.ts b/src/server/transformers/gemini/generate-content/stream.ts index 1393597..927d93a 100644 --- a/src/server/transformers/gemini/generate-content/stream.ts +++ b/src/server/transformers/gemini/generate-content/stream.ts @@ -7,6 +7,7 @@ import { serializeGeminiAggregateResponse } from './outbound.js'; type ParsedSsePayloads = { events: unknown[]; + lines: string[]; rest: string; }; @@ -15,6 +16,7 @@ type GeminiGenerateContentStreamFormat = 'sse' | 'json'; type ParsedGeminiStreamPayload = { format: GeminiGenerateContentStreamFormat; events: unknown[]; + lines?: string[]; rest: string; }; @@ -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 { @@ -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); diff --git a/src/web/components/Toast.tsx b/src/web/components/Toast.tsx index bd7b687..99b19b7 100644 --- a/src/web/components/Toast.tsx +++ b/src/web/components/Toast.tsx @@ -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(() => ({ 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 ( diff --git a/src/web/pages/DownstreamKeys.test.tsx b/src/web/pages/DownstreamKeys.test.tsx index c5730d6..2af5761 100644 --- a/src/web/pages/DownstreamKeys.test.tsx +++ b/src/web/pages/DownstreamKeys.test.tsx @@ -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 | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + 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 | null = null; try { diff --git a/src/web/pages/TokenRoutes.tsx b/src/web/pages/TokenRoutes.tsx index c880c86..6511a05 100644 --- a/src/web/pages/TokenRoutes.tsx +++ b/src/web/pages/TokenRoutes.tsx @@ -1031,7 +1031,7 @@ export default function TokenRoutes() { className="btn btn-ghost" style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }} > - {tr('显示 0 通道路由')} + {showZeroChannelRoutes ? tr('隐藏 0 通道路由') : tr('显示 0 通道路由')} diff --git a/src/web/pages/tokenRoutes.group-collapse.test.tsx b/src/web/pages/tokenRoutes.group-collapse.test.tsx index 961729a..8e0c919 100644 --- a/src/web/pages/tokenRoutes.group-collapse.test.tsx +++ b/src/web/pages/tokenRoutes.group-collapse.test.tsx @@ -326,6 +326,7 @@ describe('TokenRoutes grouped source models', () => { }); 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('未生成'); diff --git a/test_api.ts b/test_api.ts new file mode 100644 index 0000000..c3597d9 --- /dev/null +++ b/test_api.ts @@ -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(); diff --git a/tokens_result.json b/tokens_result.json new file mode 100644 index 0000000..5a4c783 --- /dev/null +++ b/tokens_result.json @@ -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 + } +} \ No newline at end of file diff --git a/tokens_result2.json b/tokens_result2.json new file mode 100644 index 0000000..2155daa --- /dev/null +++ b/tokens_result2.json @@ -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 + } +} \ No newline at end of file From f67d87103574f6af28acc03c940ff3f61b7bdee2 Mon Sep 17 00:00:00 2001 From: cita Date: Mon, 16 Mar 2026 19:38:17 +0800 Subject: [PATCH 3/3] fix gemini stream payload typing --- src/server/transformers/gemini/generate-content/stream.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/transformers/gemini/generate-content/stream.ts b/src/server/transformers/gemini/generate-content/stream.ts index 927d93a..010750f 100644 --- a/src/server/transformers/gemini/generate-content/stream.ts +++ b/src/server/transformers/gemini/generate-content/stream.ts @@ -20,7 +20,7 @@ type ParsedGeminiStreamPayload = { rest: string; }; -type AppliedGeminiStreamPayloads = ParsedSsePayloads & { +type AppliedGeminiStreamPayloads = ParsedGeminiStreamPayload & { state: GeminiGenerateContentAggregateState; };