diff --git a/src/server/routes/api/tokens.route-update-rebuild.test.ts b/src/server/routes/api/tokens.route-update-rebuild.test.ts index d3479be..b13db3b 100644 --- a/src/server/routes/api/tokens.route-update-rebuild.test.ts +++ b/src/server/routes/api/tokens.route-update-rebuild.test.ts @@ -244,6 +244,75 @@ describe('PUT /api/routes/:id route rebuild', () => { ])); }); + it('fills missing sourceModel from source exact routes when loading explicit-group channels', async () => { + const sourceA = await seedAccountWithToken('deepseek-chat'); + const sourceB = await seedAccountWithToken('deepseek-reasoner'); + + const exactRouteA = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'deepseek-chat', + enabled: true, + }).returning().get(); + const exactRouteB = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'deepseek-reasoner', + enabled: true, + }).returning().get(); + + await db.insert(schema.routeChannels).values([ + { + routeId: exactRouteA.id, + accountId: sourceA.account.id, + tokenId: sourceA.token.id, + sourceModel: null, + priority: 0, + weight: 10, + enabled: true, + manualOverride: false, + }, + { + routeId: exactRouteB.id, + accountId: sourceB.account.id, + tokenId: sourceB.token.id, + sourceModel: null, + priority: 1, + weight: 8, + enabled: true, + manualOverride: false, + }, + ]).run(); + + const createResponse = await app.inject({ + method: 'POST', + url: '/api/routes', + payload: { + routeMode: 'explicit_group', + displayName: 'deepseekv1', + sourceRouteIds: [exactRouteA.id, exactRouteB.id], + }, + }); + + expect(createResponse.statusCode).toBe(200); + const createdRouteId = (createResponse.json() as { id: number }).id; + + const channelsResponse = await app.inject({ + method: 'GET', + url: `/api/routes/${createdRouteId}/channels`, + }); + + expect(channelsResponse.statusCode).toBe(200); + expect(channelsResponse.json()).toEqual(expect.arrayContaining([ + expect.objectContaining({ + routeId: exactRouteA.id, + accountId: sourceA.account.id, + sourceModel: 'deepseek-chat', + }), + expect.objectContaining({ + routeId: exactRouteB.id, + accountId: sourceB.account.id, + sourceModel: 'deepseek-reasoner', + }), + ])); + }); + it('rejects invalid explicit-group payloads', async () => { const response = await app.inject({ method: 'POST', diff --git a/src/server/routes/api/tokens.ts b/src/server/routes/api/tokens.ts index 298f531..6a5ff5f 100644 --- a/src/server/routes/api/tokens.ts +++ b/src/server/routes/api/tokens.ts @@ -534,7 +534,7 @@ async function fetchChannelsForRouteRows(routes: RouteRow[]): Promise isExplicitGroupRoute(route)) .flatMap((route) => route.sourceRouteIds))); - const enabledExplicitSourceRouteIds = explicitSourceRouteIds.length > 0 + const explicitSourceRoutes = explicitSourceRouteIds.length > 0 ? (await db.select({ id: schema.tokenRoutes.id, modelPattern: schema.tokenRoutes.modelPattern, @@ -543,9 +543,10 @@ async function fetchChannelsForRouteRows(routes: RouteRow[]): Promise route.enabled && !isExplicitGroupRoute(route) && isExactModelPattern(route.modelPattern)) - .map((route) => route.id) : []; + const enabledExplicitSourceRouteIds = explicitSourceRoutes + .filter((route) => route.enabled && !isExplicitGroupRoute(route) && isExactModelPattern(route.modelPattern)) + .map((route) => route.id); const actualRouteIds = Array.from(new Set([ ...routes.filter((route) => !isExplicitGroupRoute(route)).map((route) => route.id), ...enabledExplicitSourceRouteIds, @@ -554,6 +555,14 @@ async function fetchChannelsForRouteRows(routes: RouteRow[]): Promise [route.id, []])); } + const actualRouteById = new Map(); + for (const route of routes.filter((item) => !isExplicitGroupRoute(item))) { + actualRouteById.set(route.id, { modelPattern: route.modelPattern, routeMode: route.routeMode ?? null }); + } + for (const route of explicitSourceRoutes) { + actualRouteById.set(route.id, { modelPattern: route.modelPattern, routeMode: route.routeMode ?? null }); + } + const channelRows = await db.select().from(schema.routeChannels) .innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id)) .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) @@ -565,13 +574,19 @@ async function fetchChannelsForRouteRows(routes: RouteRow[]): Promise { + for (const line of lines) reply.raw.write(line); + }; + const streamSession = openAiResponsesTransformer.proxyStream.createSession({ + modelName, + successfulUpstreamPath, + strictTerminalEvents: Object.entries(request.headers as Record) + .some(([rawKey, rawValue]) => rawKey.trim().toLowerCase() === 'x-metapi-responses-websocket-transport' + && String(rawValue).trim() === '1'), + getUsage: () => parsedUsage, + onParsedPayload: (payload) => { + if (payload && typeof payload === 'object') { + parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(payload)); + } + }, + writeLines, + writeRaw: (chunk) => { + reply.raw.write(chunk); + }, + }); if (!upstreamContentType.includes('text/event-stream')) { const rawText = await upstream.text(); @@ -447,7 +475,7 @@ export async function responsesProxyRoute(app: FastifyInstance) { upstreamData = unwrapGeminiCliPayload(upstreamData); } - const parsedUsage = parseProxyUsage(upstreamData); + parsedUsage = parseProxyUsage(upstreamData); const latency = Date.now() - startTime; const failure = detectProxyFailure({ rawText, usage: parsedUsage }); if (failure) { @@ -483,17 +511,31 @@ export async function responsesProxyRoute(app: FastifyInstance) { return reply.code(failure.status).send({ error: { message: failure.reason, type: 'upstream_error' } }); } - const normalized = openAiResponsesTransformer.transformFinalResponse( - upstreamData, - modelName, - rawText, - ); - const downstreamData = openAiResponsesTransformer.outbound.serializeFinal({ - upstreamPayload: upstreamData, - normalized, - usage: parsedUsage, - serializationMode: 'response', - }); + startSseResponse(); + const streamResult = streamSession.consumeUpstreamFinalPayload(upstreamData, rawText, reply.raw); + if (streamResult.status === 'failed') { + tokenRouter.recordFailure(selected.channel.id); + logProxy( + selected, + requestedModel, + 'failed', + 200, + latency, + streamResult.errorMessage, + retryCount, + downstreamPath, + parsedUsage.promptTokens, + parsedUsage.completionTokens, + parsedUsage.totalTokens, + 0, + null, + successfulUpstreamPath, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, + ); + return; + } + const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ site: selected.site, account: selected.account, @@ -526,32 +568,6 @@ export async function responsesProxyRoute(app: FastifyInstance) { clientContext, logDownstreamApiKeyId ? downstreamApiKeyId : null, ); - - const streamPayload = isRecord(downstreamData) ? downstreamData : { - id: `resp_${Date.now()}`, - object: 'response', - status: 'completed', - model: modelName, - output: [], - output_text: '', - usage: { - input_tokens: parsedUsage.promptTokens, - output_tokens: parsedUsage.completionTokens, - total_tokens: parsedUsage.totalTokens, - }, - }; - const createdPayload = { - ...streamPayload, - status: 'in_progress', - output: [], - output_text: '', - }; - - startSseResponse(); - reply.raw.write(`event: response.created\ndata: ${JSON.stringify({ type: 'response.created', response: createdPayload })}\n\n`); - reply.raw.write(`event: response.completed\ndata: ${JSON.stringify({ type: 'response.completed', response: streamPayload })}\n\n`); - reply.raw.write('data: [DONE]\n\n'); - reply.raw.end(); return; } @@ -561,14 +577,6 @@ export async function responsesProxyRoute(app: FastifyInstance) { const baseReader = String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli' && upstreamReader ? createGeminiCliStreamReader(upstreamReader) : upstreamReader; - let parsedUsage: UsageSummary = { - promptTokens: 0, - completionTokens: 0, - totalTokens: 0, - cacheReadTokens: 0, - cacheCreationTokens: 0, - promptTokensIncludeCache: null, - }; let rawText = ''; const decoder = new TextDecoder(); const reader = baseReader @@ -588,26 +596,6 @@ export async function responsesProxyRoute(app: FastifyInstance) { }, } : baseReader; - const writeLines = (lines: string[]) => { - for (const line of lines) reply.raw.write(line); - }; - const streamSession = openAiResponsesTransformer.proxyStream.createSession({ - modelName, - successfulUpstreamPath, - strictTerminalEvents: Object.entries(request.headers as Record) - .some(([rawKey, rawValue]) => rawKey.trim().toLowerCase() === 'x-metapi-responses-websocket-transport' - && String(rawValue).trim() === '1'), - getUsage: () => parsedUsage, - onParsedPayload: (payload) => { - if (payload && typeof payload === 'object') { - parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(payload)); - } - }, - writeLines, - writeRaw: (chunk) => { - reply.raw.write(chunk); - }, - }); const streamResult = await streamSession.run(reader, reply.raw); rawText += decoder.decode(); diff --git a/src/server/transformers/openai/responses/proxyStream.test.ts b/src/server/transformers/openai/responses/proxyStream.test.ts new file mode 100644 index 0000000..fa59cf2 --- /dev/null +++ b/src/server/transformers/openai/responses/proxyStream.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; + +import { createResponsesProxyStreamSession } from './proxyStream.js'; + +describe('createResponsesProxyStreamSession', () => { + it('serializes non-SSE fallback payloads into canonical responses SSE closeout events', () => { + const lines: string[] = []; + let ended = false; + const usage = { + promptTokens: 5, + completionTokens: 3, + totalTokens: 8, + cacheReadTokens: 0, + cacheCreationTokens: 0, + promptTokensIncludeCache: null, + }; + const payload = { + id: 'resp_fallback_1', + object: 'response', + status: 'completed', + model: 'gpt-5.2', + output_text: 'hello from responses upstream', + output: [ + { + id: 'msg_fallback_1', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'hello from responses upstream' }], + }, + ], + usage: { + input_tokens: usage.promptTokens, + output_tokens: usage.completionTokens, + total_tokens: usage.totalTokens, + }, + }; + + const session = createResponsesProxyStreamSession({ + modelName: 'gpt-5.2', + successfulUpstreamPath: '/v1/responses', + getUsage: () => usage, + writeLines: (nextLines) => { + lines.push(...nextLines); + }, + writeRaw: () => {}, + }); + + const result = session.consumeUpstreamFinalPayload( + payload, + JSON.stringify(payload), + { + end() { + ended = true; + }, + }, + ); + + expect(result).toEqual({ + status: 'completed', + errorMessage: null, + }); + expect(ended).toBe(true); + + const output = lines.join(''); + expect(output).toContain('event: response.created'); + expect(output).toContain('event: response.completed'); + expect(output).toContain('"type":"response.completed"'); + expect(output).toContain('"output_text":"hello from responses upstream"'); + expect(output).toContain('data: [DONE]'); + }); +}); diff --git a/src/server/transformers/openai/responses/proxyStream.ts b/src/server/transformers/openai/responses/proxyStream.ts index c7e73a3..d27191d 100644 --- a/src/server/transformers/openai/responses/proxyStream.ts +++ b/src/server/transformers/openai/responses/proxyStream.ts @@ -1,6 +1,7 @@ import { createProxyStreamLifecycle } from '../../shared/protocolLifecycle.js'; import { type ParsedSseEvent } from '../../shared/normalized.js'; import { completeResponsesStream, createOpenAiResponsesAggregateState, failResponsesStream, serializeConvertedResponsesEvents } from './aggregator.js'; +import { openAiResponsesOutbound } from './outbound.js'; import { openAiResponsesStream } from './stream.js'; type StreamReader = { @@ -148,6 +149,51 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes }; return { + consumeUpstreamFinalPayload(payload: unknown, fallbackText: string, response?: ResponseSink): ResponsesProxyStreamResult { + if (payload && typeof payload === 'object') { + input.onParsedPayload?.(payload); + } + + const payloadType = (isRecord(payload) && typeof payload.type === 'string') + ? payload.type + : ''; + if (payloadType === 'error' || payloadType === 'response.failed') { + fail(payload); + response?.end(); + return terminalResult; + } + + const normalizedFinal = openAiResponsesOutbound.normalizeFinal(payload, input.modelName, fallbackText); + streamContext.id = normalizedFinal.id; + streamContext.model = normalizedFinal.model; + streamContext.created = normalizedFinal.created; + + const streamPayload = openAiResponsesOutbound.serializeFinal({ + upstreamPayload: payload, + normalized: normalizedFinal, + usage: input.getUsage(), + serializationMode: 'response', + }); + const createdPayload = { + ...streamPayload, + status: 'in_progress', + output: [], + output_text: '', + }; + + finalized = true; + terminalResult = { + status: 'completed', + errorMessage: null, + }; + input.writeLines([ + `event: response.created\ndata: ${JSON.stringify({ type: 'response.created', response: createdPayload })}\n\n`, + `event: response.completed\ndata: ${JSON.stringify({ type: 'response.completed', response: streamPayload })}\n\n`, + 'data: [DONE]\n\n', + ]); + response?.end(); + return terminalResult; + }, async run(reader: StreamReader | null | undefined, response: ResponseSink): Promise { const lifecycle = createProxyStreamLifecycle({ reader, diff --git a/src/web/pages/TokenRoutes.tsx b/src/web/pages/TokenRoutes.tsx index 7e6df4f..c009335 100644 --- a/src/web/pages/TokenRoutes.tsx +++ b/src/web/pages/TokenRoutes.tsx @@ -563,6 +563,15 @@ export default function TokenRoutes() { }) as [string, number][]; }, [listVisibleRoutes, routeEndpointTypesByRouteId]); + const sourceEndpointTypesByRouteId = useMemo(() => { + const next: Record = {}; + for (const route of exactSourceRouteOptions) { + next[route.id] = Array.from(routeEndpointTypesByRouteId[route.id] || new Set()) + .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); + } + return next; + }, [exactSourceRouteOptions, routeEndpointTypesByRouteId]); + const routeBrandIconCandidates = useMemo(() => { const byIcon = new Map(); @@ -600,6 +609,7 @@ export default function TokenRoutes() { brand: routeBrandById.get(route.id) || null, modelPattern: route.modelPattern, channelCount: route.channelCount, + sourceRouteCount: Array.isArray(route.sourceRouteIds) ? route.sourceRouteIds.length : 0, })) .sort((a, b) => { if (a.channelCount === b.channelCount) return a.title.localeCompare(b.title, undefined, { sensitivity: 'base' }); @@ -1152,6 +1162,7 @@ export default function TokenRoutes() { routeIconSelectOptions={routeIconSelectOptions} previewModelSamples={previewModelSamples} exactSourceRouteOptions={exactSourceRouteOptions} + sourceEndpointTypesByRouteId={sourceEndpointTypesByRouteId} onSave={handleAddRoute} onCancel={handleCancelEditRoute} /> diff --git a/src/web/pages/token-routes/ManualRoutePanel.tsx b/src/web/pages/token-routes/ManualRoutePanel.tsx index 2a11f92..1feec1a 100644 --- a/src/web/pages/token-routes/ManualRoutePanel.tsx +++ b/src/web/pages/token-routes/ManualRoutePanel.tsx @@ -1,4 +1,5 @@ -import { useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react'; +import { useEffect, useMemo, useState, type Dispatch, type ReactNode, type SetStateAction } from 'react'; +import { BrandGlyph, InlineBrandIcon, hashColor, type BrandInfo } from '../../components/BrandIcon.js'; import CenteredModal from '../../components/CenteredModal.js'; import ModernSelect from '../../components/ModernSelect.js'; import { tr } from '../../i18n.js'; @@ -11,6 +12,9 @@ import { matchesModelPattern, normalizeRouteDisplayIconValue, normalizeRouteMode, + resolveEndpointTypeIconModel, + resolveRouteBrand, + siteAvatarLetters, } from './utils.js'; type RouteEditorForm = { @@ -32,6 +36,7 @@ type ManualRoutePanelProps = { routeIconSelectOptions: RouteIconOption[]; previewModelSamples: string[]; exactSourceRouteOptions: RouteSummaryRow[]; + sourceEndpointTypesByRouteId: Record; onSave: () => void; onCancel: () => void; }; @@ -71,6 +76,41 @@ function SearchField({ ); } +function FilterChip({ + active, + label, + count, + icon, + onClick, +}: { + active: boolean; + label: string; + count?: number; + icon?: ReactNode; + onClick: () => void; +}) { + return ( + + ); +} + +function FilterRow({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ {label} +
{children}
+
+ ); +} + export default function ManualRoutePanel({ show, editingRouteId, @@ -81,18 +121,25 @@ export default function ManualRoutePanel({ routeIconSelectOptions, previewModelSamples, exactSourceRouteOptions, + sourceEndpointTypesByRouteId, onSave, onCancel, }: ManualRoutePanelProps) { const [showSourcePicker, setShowSourcePicker] = useState(false); const [sourceSearch, setSourceSearch] = useState(''); const [sourcePickerSelection, setSourcePickerSelection] = useState([]); + const [activeSourceBrand, setActiveSourceBrand] = useState(null); + const [activeSourceSite, setActiveSourceSite] = useState(null); + const [activeSourceEndpointType, setActiveSourceEndpointType] = useState(null); useEffect(() => { if (!show) { setShowSourcePicker(false); setSourceSearch(''); setSourcePickerSelection([]); + setActiveSourceBrand(null); + setActiveSourceSite(null); + setActiveSourceEndpointType(null); } }, [show]); @@ -119,14 +166,136 @@ export default function ManualRoutePanel({ return previewModelSamples.filter((modelName) => matchesModelPattern(modelName, normalizedPattern)); }, [form.modelPattern, modelPatternError, previewModelSamples]); + const sourceRouteBrandById = useMemo(() => { + const next = new Map(); + for (const route of exactSourceRouteOptions) { + next.set(route.id, resolveRouteBrand(route)); + } + return next; + }, [exactSourceRouteOptions]); + + const sourceBrandList = useMemo(() => { + const grouped = new Map(); + let otherCount = 0; + + for (const route of exactSourceRouteOptions) { + const brand = sourceRouteBrandById.get(route.id) || null; + if (!brand) { + otherCount += 1; + continue; + } + const existing = grouped.get(brand.name); + if (existing) { + existing.count += 1; + } else { + grouped.set(brand.name, { count: 1, brand }); + } + } + + return { + list: [...grouped.entries()].sort((a, b) => { + if (a[1].count === b[1].count) { + return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' }); + } + return b[1].count - a[1].count; + }) as [string, { count: number; brand: BrandInfo }][], + otherCount, + }; + }, [exactSourceRouteOptions, sourceRouteBrandById]); + + const sourceSiteList = useMemo(() => { + const grouped = new Map(); + + for (const route of exactSourceRouteOptions) { + const seenSites = new Set(); + for (const siteName of route.siteNames || []) { + const normalizedSite = String(siteName || '').trim(); + if (!normalizedSite || seenSites.has(normalizedSite)) continue; + seenSites.add(normalizedSite); + grouped.set(normalizedSite, (grouped.get(normalizedSite) || 0) + 1); + } + } + + return [...grouped.entries()].sort((a, b) => { + if (a[1] === b[1]) { + return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' }); + } + return b[1] - a[1]; + }) as [string, number][]; + }, [exactSourceRouteOptions]); + + const sourceEndpointTypeList = useMemo(() => { + const grouped = new Map(); + + for (const route of exactSourceRouteOptions) { + const endpointTypes = sourceEndpointTypesByRouteId[route.id] || []; + for (const endpointType of endpointTypes) { + const normalizedType = String(endpointType || '').trim(); + if (!normalizedType) continue; + grouped.set(normalizedType, (grouped.get(normalizedType) || 0) + 1); + } + } + + return [...grouped.entries()].sort((a, b) => { + if (a[1] === b[1]) { + return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' }); + } + return b[1] - a[1]; + }) as [string, number][]; + }, [exactSourceRouteOptions, sourceEndpointTypesByRouteId]); + const filteredSourceRoutes = useMemo(() => { + let list = [...exactSourceRouteOptions]; + + if (activeSourceBrand) { + if (activeSourceBrand === '__other__') { + list = list.filter((route) => !(sourceRouteBrandById.get(route.id) || null)); + } else { + list = list.filter((route) => (sourceRouteBrandById.get(route.id)?.name || '') === activeSourceBrand); + } + } + + if (activeSourceSite) { + list = list.filter((route) => (route.siteNames || []).includes(activeSourceSite)); + } + + if (activeSourceEndpointType) { + list = list.filter((route) => (sourceEndpointTypesByRouteId[route.id] || []).includes(activeSourceEndpointType)); + } + const normalizedSearch = sourceSearch.trim().toLowerCase(); - if (!normalizedSearch) return exactSourceRouteOptions; - return exactSourceRouteOptions.filter((route) => { - const label = renderRouteOptionLabel(route).toLowerCase(); - return label.includes(normalizedSearch) || route.modelPattern.toLowerCase().includes(normalizedSearch); + if (normalizedSearch) { + list = list.filter((route) => { + const label = renderRouteOptionLabel(route).toLowerCase(); + const modelPattern = route.modelPattern.toLowerCase(); + const brandName = (sourceRouteBrandById.get(route.id)?.name || '').toLowerCase(); + const siteText = (route.siteNames || []).join(' ').toLowerCase(); + const endpointTypes = (sourceEndpointTypesByRouteId[route.id] || []).join(' ').toLowerCase(); + return ( + label.includes(normalizedSearch) + || modelPattern.includes(normalizedSearch) + || brandName.includes(normalizedSearch) + || siteText.includes(normalizedSearch) + || endpointTypes.includes(normalizedSearch) + ); + }); + } + + return list.sort((a, b) => { + if (a.channelCount === b.channelCount) { + return renderRouteOptionLabel(a).localeCompare(renderRouteOptionLabel(b), undefined, { sensitivity: 'base' }); + } + return b.channelCount - a.channelCount; }); - }, [exactSourceRouteOptions, sourceSearch]); + }, [ + activeSourceBrand, + activeSourceEndpointType, + activeSourceSite, + exactSourceRouteOptions, + sourceEndpointTypesByRouteId, + sourceRouteBrandById, + sourceSearch, + ]); const selectedSourceRoutes = useMemo(() => { const routeById = new Map(exactSourceRouteOptions.map((route) => [route.id, route])); @@ -135,18 +304,29 @@ export default function ManualRoutePanel({ .filter((route): route is RouteSummaryRow => !!route); }, [exactSourceRouteOptions, form.sourceRouteIds]); + const sourcePickerSelectionSet = useMemo( + () => new Set(sourcePickerSelection), + [sourcePickerSelection], + ); + const autoBrandIconEnabled = !isRouteIconNoneValue(form.displayIcon); const hasExplicitIconValue = !!normalizeRouteDisplayIconValue(form.displayIcon); const openSourcePicker = () => { setSourcePickerSelection([...form.sourceRouteIds]); setSourceSearch(''); + setActiveSourceBrand(null); + setActiveSourceSite(null); + setActiveSourceEndpointType(null); setShowSourcePicker(true); }; const closeSourcePicker = () => { setShowSourcePicker(false); setSourceSearch(''); + setActiveSourceBrand(null); + setActiveSourceSite(null); + setActiveSourceEndpointType(null); }; const confirmSourcePicker = () => { @@ -156,6 +336,9 @@ export default function ManualRoutePanel({ })); setShowSourcePicker(false); setSourceSearch(''); + setActiveSourceBrand(null); + setActiveSourceSite(null); + setActiveSourceEndpointType(null); }; const footer = ( @@ -584,7 +767,101 @@ export default function ManualRoutePanel({ placeholder={tr('搜索来源模型')} /> -
+
+
+ + ✦} + onClick={() => setActiveSourceBrand(null)} + /> + {sourceBrandList.list.map(([brandName, { count, brand }]) => ( + } + onClick={() => setActiveSourceBrand(activeSourceBrand === brandName ? null : brandName)} + /> + ))} + {sourceBrandList.otherCount > 0 ? ( + ?} + onClick={() => setActiveSourceBrand(activeSourceBrand === '__other__' ? null : '__other__')} + /> + ) : null} + + + {sourceSiteList.length > 0 ? ( + + ⚡} + onClick={() => setActiveSourceSite(null)} + /> + {sourceSiteList.map(([siteName, count]) => ( + + {siteAvatarLetters(siteName)} + + )} + onClick={() => setActiveSourceSite(activeSourceSite === siteName ? null : siteName)} + /> + ))} + + ) : null} + + + ⚙} + onClick={() => setActiveSourceEndpointType(null)} + /> + {sourceEndpointTypeList.map(([endpointType, count]) => { + const iconModel = resolveEndpointTypeIconModel(endpointType); + return ( + : ⚙} + onClick={() => setActiveSourceEndpointType(activeSourceEndpointType === endpointType ? null : endpointType)} + /> + ); + })} + {sourceEndpointTypeList.length === 0 ? ( + {tr('暂无接口能力数据')} + ) : null} + +
+
+ +
{filteredSourceRoutes.length === 0 ? (
{exactSourceRouteOptions.length === 0 @@ -592,50 +869,131 @@ export default function ManualRoutePanel({ : tr('没有匹配的来源模型。')}
) : ( - filteredSourceRoutes.map((route) => { - const selected = sourcePickerSelection.includes(route.id); - const label = renderRouteOptionLabel(route); - return ( -
- - {selected ? tr('已选中') : tr('可选择')} - - - ); - }) + + ); + })} +
)} diff --git a/src/web/pages/token-routes/RouteCard.tsx b/src/web/pages/token-routes/RouteCard.tsx index e75646d..8804b2e 100644 --- a/src/web/pages/token-routes/RouteCard.tsx +++ b/src/web/pages/token-routes/RouteCard.tsx @@ -116,6 +116,7 @@ function RouteCardInner({ const routeIcon = resolveRouteIcon(route); const exactRoute = isRouteExactModel(route); const explicitGroupRoute = isExplicitGroupRoute(route); + const explicitGroupSourceCount = Array.isArray(route.sourceRouteIds) ? route.sourceRouteIds.length : 0; const readOnlyRoute = route.kind === 'zero_channel' || route.readOnly === true || route.isVirtual === true; const channelManagementDisabled = explicitGroupRoute; const title = resolveRouteTitle(route); @@ -206,9 +207,20 @@ function RouteCardInner({ )} - - {route.channelCount} {tr('通道')} - + {explicitGroupRoute && explicitGroupSourceCount > 0 ? ( + <> + + {explicitGroupSourceCount} {tr('来源模型')} + + + {route.channelCount} {tr('通道')} + + + ) : ( + + {route.channelCount} {tr('通道')} + + )} {readOnlyRoute ? ( @@ -269,9 +281,20 @@ function RouteCardInner({ {route.enabled ? tr('启用') : tr('禁用')} )} - - {route.channelCount} {tr('通道')} - + {explicitGroupRoute && explicitGroupSourceCount > 0 ? ( + <> + + {explicitGroupSourceCount} {tr('来源模型')} + + + {route.channelCount} {tr('通道')} + + + ) : ( + + {route.channelCount} {tr('通道')} + + )} {readOnlyRoute && ( {tr('0 通道')} @@ -445,35 +468,29 @@ function RouteCardInner({ {explicitGroupRoute ? ( -
- {group.channels.map((channel) => ( -
- - {channel.account?.username || `account-${channel.accountId}`} - - - {channel.site?.name || 'unknown'} - - {channel.sourceModel ? ( - - {channel.sourceModel} - - ) : null} -
- ))} -
+ c.id)} strategy={verticalListSortingStrategy}> + {group.channels.map((channel) => { + const tokenOptions = candidateView.tokenOptionsByAccountId[channel.accountId] || []; + const activeTokenId = channelTokenDraft[channel.id] ?? channel.tokenId ?? 0; + return ( + onSaveToken(route.id, channel.id, channel.accountId)} + onDeleteChannel={() => onDeleteChannel(channel.id, route.id)} + /> + ); + })} + ) : ( c.id)} strategy={verticalListSortingStrategy}> {group.channels.map((channel) => { diff --git a/src/web/pages/token-routes/RouteFilterBar.tsx b/src/web/pages/token-routes/RouteFilterBar.tsx index bb86f6d..edc3824 100644 --- a/src/web/pages/token-routes/RouteFilterBar.tsx +++ b/src/web/pages/token-routes/RouteFilterBar.tsx @@ -203,7 +203,7 @@ export default function RouteFilterBar(props: RouteFilterBarProps) { key={groupRoute.id} active={activeGroupFilter === groupRoute.id} label={groupRoute.title} - count={groupRoute.channelCount} + count={groupRoute.sourceRouteCount > 0 ? groupRoute.sourceRouteCount : groupRoute.channelCount} icon={ groupRoute.icon.kind === 'brand' ? ( diff --git a/src/web/pages/token-routes/SortableChannelRow.tsx b/src/web/pages/token-routes/SortableChannelRow.tsx index 1b21069..6d4b54b 100644 --- a/src/web/pages/token-routes/SortableChannelRow.tsx +++ b/src/web/pages/token-routes/SortableChannelRow.tsx @@ -16,6 +16,7 @@ export function SortableChannelRow({ isExactRoute, loadingDecision, isSavingPriority, + readOnly = false, tokenOptions, activeTokenId, isUpdatingToken, @@ -33,7 +34,7 @@ export function SortableChannelRow({ isDragging, } = useSortable({ id: channel.id, - disabled: isSavingPriority, + disabled: isSavingPriority || readOnly, }); const rowStyle: CSSProperties = { @@ -42,7 +43,7 @@ export function SortableChannelRow({ opacity: isDragging ? 0.72 : 1, zIndex: isDragging ? 10 : 1, display: 'grid', - gridTemplateColumns: 'minmax(0, 1fr) auto auto', + gridTemplateColumns: readOnly ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) auto auto', alignItems: 'center', gap: 8, padding: '8px 12px', @@ -63,7 +64,7 @@ export function SortableChannelRow({ ref={setActivatorNodeRef} {...attributes} {...listeners} - disabled={isSavingPriority} + disabled={isSavingPriority || readOnly} className="btn btn-ghost" style={{ width: 22, @@ -72,9 +73,10 @@ export function SortableChannelRow({ padding: 0, border: '1px solid var(--color-border-light)', color: 'var(--color-text-muted)', - cursor: isSavingPriority ? 'not-allowed' : 'grab', + cursor: isSavingPriority || readOnly ? 'not-allowed' : 'grab', + opacity: readOnly ? 0.65 : 1, }} - data-tooltip="拖拽调整优先级" + data-tooltip={readOnly ? '来源群组继承通道优先级,不能在这里拖动' : '拖拽调整优先级'} aria-label="拖拽调整优先级" > @@ -197,46 +199,50 @@ export function SortableChannelRow({ -
-
- onTokenDraftChange(channel.id, Number.parseInt(nextValue, 10) || 0)} - disabled={isUpdatingToken} - options={[ - { - value: '0', - label: '跟随账号默认', - description: tokenBinding.followOptionDescription, - }, - ...tokenOptions.map((token) => ({ - value: String(token.id), - label: buildFixedTokenOptionLabel(token, { includeDefaultTag: true }), - description: buildFixedTokenOptionDescription(token), - })), - ]} - placeholder="选择令牌绑定方式" - /> -
- {tokenBinding.helperText} + {!readOnly ? ( + <> +
+
+ onTokenDraftChange(channel.id, Number.parseInt(nextValue, 10) || 0)} + disabled={isUpdatingToken} + options={[ + { + value: '0', + label: '跟随账号默认', + description: tokenBinding.followOptionDescription, + }, + ...tokenOptions.map((token) => ({ + value: String(token.id), + label: buildFixedTokenOptionLabel(token, { includeDefaultTag: true }), + description: buildFixedTokenOptionDescription(token), + })), + ]} + placeholder="选择令牌绑定方式" + /> +
+ {tokenBinding.helperText} +
+
+
-
- -
- + + + ) : null}
); } diff --git a/src/web/pages/token-routes/types.ts b/src/web/pages/token-routes/types.ts index 2745e84..4a79410 100644 --- a/src/web/pages/token-routes/types.ts +++ b/src/web/pages/token-routes/types.ts @@ -150,6 +150,7 @@ export type SortableChannelRowProps = { isExactRoute: boolean; loadingDecision: boolean; isSavingPriority: boolean; + readOnly?: boolean; tokenOptions: RouteTokenOption[]; activeTokenId: number; isUpdatingToken: boolean; @@ -165,4 +166,5 @@ export type GroupRouteItem = { brand: BrandInfo | null; modelPattern: string; channelCount: number; + sourceRouteCount: number; }; diff --git a/src/web/pages/tokenRoutes.group-collapse.test.tsx b/src/web/pages/tokenRoutes.group-collapse.test.tsx index 3c99b35..04df77e 100644 --- a/src/web/pages/tokenRoutes.group-collapse.test.tsx +++ b/src/web/pages/tokenRoutes.group-collapse.test.tsx @@ -48,6 +48,23 @@ function findButtonByText(root: ReactTestInstance, text: string): ReactTestInsta )); } +function findButtonByClassAndText(root: ReactTestInstance, className: string, text: string): ReactTestInstance { + return root.find((node) => ( + node.type === 'button' + && typeof node.props.onClick === 'function' + && String(node.props.className || '').includes(className) + && collectText(node).includes(text) + )); +} + +function findButtonByAriaLabel(root: ReactTestInstance, label: string): ReactTestInstance { + return root.find((node) => ( + node.type === 'button' + && typeof node.props['aria-label'] === 'string' + && node.props['aria-label'] === label + )); +} + function findInputByPlaceholder(root: ReactTestInstance, placeholderText: string): ReactTestInstance { return root.find((node) => ( node.type === 'input' @@ -865,6 +882,195 @@ describe('TokenRoutes grouped source models', () => { } }); + it('renders the source picker like the route page with brand, site, ability filters and a card grid', async () => { + getBrandMock.mockImplementation((modelName: string) => { + const model = String(modelName); + if (model.includes('gpt')) { + return { name: 'OpenAI', icon: 'openai', color: 'linear-gradient(135deg,#111,#555)' }; + } + if (model.includes('claude')) { + return { name: 'Anthropic', icon: 'anthropic', color: 'linear-gradient(135deg,#d97706,#f59e0b)' }; + } + if (model.includes('gemini')) { + return { name: 'Gemini', icon: 'gemini', color: 'linear-gradient(135deg,#2563eb,#7c3aed)' }; + } + return null; + }); + apiMock.getRoutesSummary.mockResolvedValue([ + { + id: 11, modelPattern: 'gpt-5.4', displayName: null, + displayIcon: null, modelMapping: null, enabled: true, + routeMode: 'pattern', sourceRouteIds: [], + channelCount: 3, enabledChannelCount: 3, siteNames: ['Wong'], + decisionSnapshot: null, decisionRefreshedAt: null, + }, + { + id: 12, modelPattern: 'claude-sonnet-4-5', displayName: null, + displayIcon: null, modelMapping: null, enabled: true, + routeMode: 'pattern', sourceRouteIds: [], + channelCount: 2, enabledChannelCount: 2, siteNames: ['Alpha'], + decisionSnapshot: null, decisionRefreshedAt: null, + }, + { + id: 13, modelPattern: 'gemini-2.5-pro', displayName: null, + displayIcon: null, modelMapping: null, enabled: true, + routeMode: 'pattern', sourceRouteIds: [], + channelCount: 1, enabledChannelCount: 1, siteNames: ['Wong'], + decisionSnapshot: null, decisionRefreshedAt: null, + }, + ]); + apiMock.getModelTokenCandidates.mockResolvedValue({ + models: {}, + endpointTypesByModel: { + 'gpt-5.4': ['openai'], + 'claude-sonnet-4-5': ['anthropic'], + 'gemini-2.5-pro': ['gemini'], + }, + }); + + let root: ReturnType | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + await act(async () => { + findButtonByText(root.root, '新建群组').props.onClick(); + }); + await flushMicrotasks(); + + await act(async () => { + findButtonByText(root.root, '选择来源模型').props.onClick(); + }); + await flushMicrotasks(); + + const pickerGrid = root.root.find((node) => ( + node.type === 'div' + && String(node.props.className || '').includes('source-route-picker-grid') + )); + expect(String(pickerGrid.props.style?.display || '')).toBe('grid'); + expect(String(pickerGrid.props.style?.gridTemplateColumns || '')).toContain('repeat('); + + expect(findButtonByClassAndText(root.root, 'filter-chip', 'OpenAI')).toBeTruthy(); + expect(findButtonByClassAndText(root.root, 'filter-chip', 'Wong')).toBeTruthy(); + expect(findButtonByClassAndText(root.root, 'filter-chip', 'gemini')).toBeTruthy(); + + await act(async () => { + findButtonByClassAndText(root.root, 'filter-chip', 'Wong').props.onClick(); + }); + await flushMicrotasks(); + expect(collectText(pickerGrid)).toContain('gpt-5.4'); + expect(collectText(pickerGrid)).toContain('gemini-2.5-pro'); + expect(collectText(pickerGrid)).not.toContain('claude-sonnet-4-5'); + + await act(async () => { + findButtonByClassAndText(root.root, 'filter-chip', 'Wong').props.onClick(); + }); + await flushMicrotasks(); + + await act(async () => { + findButtonByClassAndText(root.root, 'filter-chip', 'OpenAI').props.onClick(); + }); + await flushMicrotasks(); + expect(collectText(pickerGrid)).toContain('OpenAI'); + expect(collectText(pickerGrid)).toContain('gpt-5.4'); + expect(collectText(pickerGrid)).not.toContain('claude-sonnet-4-5'); + expect(collectText(pickerGrid)).not.toContain('gemini-2.5-pro'); + + await act(async () => { + findButtonByClassAndText(root.root, 'filter-chip', 'OpenAI').props.onClick(); + }); + await flushMicrotasks(); + + await act(async () => { + findButtonByClassAndText(root.root, 'filter-chip', 'anthropic').props.onClick(); + }); + await flushMicrotasks(); + expect(collectText(pickerGrid)).toContain('Anthropic'); + expect(collectText(pickerGrid)).toContain('claude-sonnet-4-5'); + expect(collectText(pickerGrid)).not.toContain('gpt-5.4'); + expect(collectText(pickerGrid)).not.toContain('gemini-2.5-pro'); + } finally { + root?.unmount(); + } + }); + + it('shows explicit-group source counts instead of aggregated channel counts in the route list and filter chips', async () => { + apiMock.getRoutesSummary.mockResolvedValue([ + { + id: 11, modelPattern: 'deepseek-chat', displayName: null, + displayIcon: null, modelMapping: null, enabled: true, + routeMode: 'pattern', sourceRouteIds: [], + channelCount: 40, enabledChannelCount: 40, siteNames: ['Wong'], + decisionSnapshot: null, decisionRefreshedAt: null, + }, + { + id: 12, modelPattern: 'deepseek-reasoner', displayName: null, + displayIcon: null, modelMapping: null, enabled: true, + routeMode: 'pattern', sourceRouteIds: [], + channelCount: 55, enabledChannelCount: 55, siteNames: ['Wong'], + decisionSnapshot: null, decisionRefreshedAt: null, + }, + { + id: 21, modelPattern: 'deepseekv1', displayName: 'deepseekv1', + displayIcon: '', modelMapping: null, enabled: true, + routeMode: 'explicit_group', sourceRouteIds: [11, 12], + channelCount: 95, enabledChannelCount: 95, siteNames: ['Wong'], + decisionSnapshot: null, decisionRefreshedAt: null, + }, + ]); + apiMock.getRouteChannels.mockResolvedValue([ + { + id: 101, accountId: 1, tokenId: 1, sourceModel: 'deepseek-chat', + priority: 0, weight: 1, enabled: true, manualOverride: false, + successCount: 0, failCount: 0, + account: { username: 'user-a' }, site: { name: 'Wong' }, token: null, + }, + ]); + + let root: ReturnType | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const filterSummary = root.root.find((node) => + node.type === 'button' && String(node.props.className || '').includes('route-filter-bar-summary'), + ); + await act(async () => { + filterSummary.props.onClick(); + }); + await flushMicrotasks(); + + const groupChip = findButtonByClassAndText(root.root, 'filter-chip', 'deepseekv1'); + expect(collectText(groupChip)).toContain('2'); + expect(collectText(groupChip)).not.toContain('95'); + + const routeCard = root.root.find((node) => + node.type === 'div' + && String(node.props.className || '').includes('route-card-collapsed') + && collectText(node).includes('deepseekv1'), + ); + expect(collectText(routeCard).replace(/\s+/g, '')).toContain('2来源模型'); + } finally { + root?.unmount(); + } + }); + it('uses a dedicated source picker modal and submits explicit-group sourceRouteIds', async () => { apiMock.getRoutesSummary.mockResolvedValue([ { @@ -1169,4 +1375,80 @@ describe('TokenRoutes grouped source models', () => { root?.unmount(); } }); + + it('reuses the standard channel row presentation for explicit-group details in read-only mode', async () => { + apiMock.getRoutesSummary.mockResolvedValue([ + { + id: 11, modelPattern: 'claude-haiku-4-5-20251001', displayName: null, + displayIcon: null, modelMapping: null, enabled: true, + routeMode: 'pattern', sourceRouteIds: [], + channelCount: 6, enabledChannelCount: 6, siteNames: ['Wong'], + decisionSnapshot: null, decisionRefreshedAt: null, + }, + { + id: 21, modelPattern: 'claude-haiku-proxy', displayName: 'claude-haiku-proxy', + displayIcon: '', modelMapping: null, enabled: true, + routeMode: 'explicit_group', sourceRouteIds: [11], + channelCount: 6, enabledChannelCount: 6, siteNames: ['Wong'], + decisionSnapshot: null, decisionRefreshedAt: null, + }, + ]); + apiMock.getRouteChannels.mockResolvedValue([ + { + id: 101, + routeId: 11, + accountId: 301, + tokenId: 401, + sourceModel: 'claude-haiku-4-5-20251001', + priority: 0, + weight: 10, + enabled: true, + manualOverride: false, + successCount: 6, + failCount: 1, + account: { username: 'linuxdo_131936' }, + site: { name: 'Wong' }, + token: { id: 401, name: 'token-a', accountId: 301, enabled: true, isDefault: true }, + }, + ]); + + let root: ReturnType | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const expandBtn = root.root.find((node) => + node.type === 'div' + && String(node.props.className || '').includes('route-card-collapsed') + && collectText(node).includes('claude-haiku-proxy'), + ); + await act(async () => { + expandBtn.props.onClick(); + }); + await flushMicrotasks(); + + await act(async () => { + findButtonByText(root.root, 'claude-haiku-4-5-20251001').props.onClick(); + }); + await flushMicrotasks(); + + const expandedText = collectText(root.root); + expect(expandedText).toContain('P0'); + expect(expandedText).toContain('当前生效:token-a'); + expect(expandedText).toContain('选中概率'); + expect(findButtonByAriaLabel(root.root, '拖拽调整优先级').props.disabled).toBe(true); + expect(expandedText).not.toContain('保存'); + expect(expandedText).not.toContain('移除'); + } finally { + root?.unmount(); + } + }); });