include remaining workspace changes

This commit is contained in:
cita
2026-03-16 19:29:26 +08:00
parent 52441f2634
commit bec0457a92
15 changed files with 419 additions and 93 deletions
+14
View File
@@ -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"
}
]
+44
View File
@@ -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();
+9
View File
@@ -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 { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { PgDialect } from 'drizzle-orm/pg-core';
type DbModule = typeof import('../../db/index.js'); type DbModule = typeof import('../../db/index.js');
@@ -39,6 +40,19 @@ describe('downstream api keys routes', () => {
delete process.env.DATA_DIR; 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 () => { it('creates, updates, resets and deletes downstream api keys', async () => {
const site = await db.insert(schema.sites).values({ const site = await db.insert(schema.sites).values({
name: 'portal-site', name: 'portal-site',
+17 -8
View File
@@ -1,5 +1,5 @@
import { FastifyInstance } from 'fastify'; 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 { db, hasProxyLogDownstreamApiKeyIdColumn, runtimeDbDialect, schema } from '../../db/index.js';
import { import {
getDownstreamApiKeyById, getDownstreamApiKeyById,
@@ -95,18 +95,27 @@ function resolveBucketSeconds(range: DownstreamKeyRange): number {
return range === 'all' ? 86400 : 3600; return range === 'all' ? 86400 : 3600;
} }
function resolveBucketTsExpression(bucketSeconds: number) { export function buildBucketTsExpressionForDialect(
if (runtimeDbDialect === 'mysql') { dialect: 'sqlite' | 'mysql' | 'postgres',
return sql<number>`floor(unix_timestamp(${schema.proxyLogs.createdAt}) / ${bucketSeconds}) * ${bucketSeconds}`; 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) { 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 // 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: { async function validatePolicyReferences(input: {
+40 -64
View File
@@ -52,6 +52,7 @@ vi.mock('../../db/index.js', () => ({
function parseSsePayloads(body: string): Array<Record<string, unknown>> { function parseSsePayloads(body: string): Array<Record<string, unknown>> {
return body return body
.replace(/\r\n/g, '\n')
.split('\n\n') .split('\n\n')
.map((block) => block.trim()) .map((block) => block.trim())
.filter(Boolean) .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 () => { it('keeps non-sse json-array streaming payloads on the wire as raw chunk responses', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify([ const upstreamPayload = [
{ {
promptFeedback: { blockReason: 'BLOCK_REASON_UNSPECIFIED' },
candidates: [ 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' }, groundingMetadata: { source: 'web' },
}, },
], ],
}, },
{ {
serverContent: { modelTurn: { parts: [{ text: 'tool result received' }] } },
candidates: [ candidates: [
{ {
content: { role: 'model', parts: [{ text: 'second', thoughtSignature: 'sig-1' }] }, content: { role: 'model', parts: [{ text: 'second', thoughtSignature: 'sig-1' }] },
citationMetadata: { citations: [{ startIndex: 0, endIndex: 5 }] }, citationMetadata: { citations: [{ startIndex: 0, endIndex: 5 }] },
finishReason: 'STOP',
}, },
], ],
usageMetadata: { usageMetadata: {
@@ -545,7 +557,9 @@ describe('gemini native proxy routes', () => {
thoughtsTokenCount: 3, thoughtsTokenCount: 3,
}, },
}, },
]), { ];
fetchMock.mockResolvedValue(new Response(JSON.stringify(upstreamPayload), {
status: 200, status: 200,
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
})); }));
@@ -567,45 +581,7 @@ describe('gemini native proxy routes', () => {
}); });
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
expect(response.json()).toEqual([ expect(response.json()).toEqual(upstreamPayload);
{
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,
},
},
]);
}); });
it('derives gemini-3 thinkingLevel from OpenAI-style reasoning inputs in the runtime request path', async () => { 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 encoder = new TextEncoder();
const upstreamBody = new ReadableStream<Uint8Array>({ const upstreamBody = new ReadableStream<Uint8Array>({
start(controller) { 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: {"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: {"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: {"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(); controller.close();
}, },
}); });
@@ -702,25 +679,30 @@ describe('gemini native proxy routes', () => {
expect(events).toHaveLength(2); expect(events).toHaveLength(2);
expect(events[0]).toMatchObject({ expect(events[0]).toMatchObject({
responseId: 'resp-sse', promptFeedback: { blockReason: 'BLOCK_REASON_UNSPECIFIED' },
modelVersion: 'gemini-2.5-flash',
candidates: [ candidates: [
{ {
content: { content: {
parts: [{ text: 'first' }], parts: [
{
functionCall: { id: 'tool-1', name: 'lookup_weather', args: { city: 'Shanghai' } },
thoughtSignature: 'sig-tool-1',
},
],
}, },
groundingMetadata: { source: 'web' }, groundingMetadata: { source: 'web' },
}, },
], ],
}); });
expect(events[0]).not.toMatchObject({ expect(events[0]).not.toHaveProperty('responseId');
candidates: [ expect(events[0]).not.toHaveProperty('modelVersion');
{ expect(events[0].candidates?.[0]).not.toHaveProperty('finishReason');
citationMetadata: expect.anything(),
},
],
});
expect(events[1]).toMatchObject({ expect(events[1]).toMatchObject({
serverContent: {
modelTurn: {
parts: [{ text: 'tool result received' }],
},
},
usageMetadata: { usageMetadata: {
promptTokenCount: 11, promptTokenCount: 11,
candidatesTokenCount: 6, candidatesTokenCount: 6,
@@ -738,14 +720,8 @@ describe('gemini native proxy routes', () => {
}, },
], ],
}); });
expect(events[1]).not.toMatchObject({ expect(response.body).toContain('\r\n\r\n');
candidates: [ expect(response.body).toContain('data: [DONE]\r\n\r\n');
{
groundingMetadata: expect.anything(),
},
],
});
expect(response.body).not.toContain('\r\n\r\n');
}); });
it('falls back to the next channel when first Gemini channel returns 400 before any bytes are written', async () => { 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 { import {
applyJsonPayloadToAggregate, applyJsonPayloadToAggregate,
applySsePayloadsToAggregate, applySsePayloadsToAggregate,
consumeUpstreamSseBuffer,
geminiGenerateContentStream, geminiGenerateContentStream,
parseGeminiStreamPayload, parseGeminiStreamPayload,
serializeUpstreamJsonPayload,
serializeAggregateSsePayload, serializeAggregateSsePayload,
} from './stream.js'; } from './stream.js';
import { extractGeminiUsage } from './usage.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', () => { it('normalizes object payloads through the JSON-array path', () => {
const state = createGeminiGenerateContentAggregateState(); 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 = { type ParsedSsePayloads = {
events: unknown[]; events: unknown[];
lines: string[];
rest: string; rest: string;
}; };
@@ -15,6 +16,7 @@ type GeminiGenerateContentStreamFormat = 'sse' | 'json';
type ParsedGeminiStreamPayload = { type ParsedGeminiStreamPayload = {
format: GeminiGenerateContentStreamFormat; format: GeminiGenerateContentStreamFormat;
events: unknown[]; events: unknown[];
lines?: string[];
rest: string; rest: string;
}; };
@@ -27,19 +29,24 @@ function serializeSsePayload(payload: unknown): string {
} }
function parseSsePayloads(buffer: string): ParsedSsePayloads { function parseSsePayloads(buffer: string): ParsedSsePayloads {
const normalized = buffer.replace(/\r\n/g, '\n');
const events: unknown[] = []; const events: unknown[] = [];
let rest = normalized; const lines: string[] = [];
let cursor = 0;
while (true) { while (cursor < buffer.length) {
const boundary = rest.indexOf('\n\n'); const boundaryMatch = /\r?\n\r?\n/.exec(buffer.slice(cursor));
if (boundary < 0) break; 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; if (!block.trim()) continue;
lines.push(rawBlock);
const data = block const data = block
.replace(/\r\n/g, '\n')
.split('\n') .split('\n')
.filter((line) => line.startsWith('data:')) .filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).trimStart()) .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> { function isRecord(value: unknown): value is Record<string, unknown> {
@@ -77,6 +88,7 @@ function parseGeminiStreamPayload(
return { return {
format: 'sse', format: 'sse',
events: parsed.events, events: parsed.events,
lines: parsed.lines,
rest: parsed.rest, rest: parsed.rest,
}; };
} }
@@ -123,8 +135,7 @@ function applyParsedPayloadToAggregate(
} }
return { return {
events: parsed.events, ...parsed,
rest: parsed.rest,
state, state,
}; };
} }
@@ -151,7 +162,7 @@ function consumeUpstreamSseBuffer(
const applied = applySsePayloadsToAggregate(state, buffer); const applied = applySsePayloadsToAggregate(state, buffer);
return { return {
...applied, ...applied,
lines: applied.events.map((event) => serializeAggregateSsePayload(event)), lines: applied.lines ?? [],
}; };
} }
@@ -182,10 +193,11 @@ function serializeUpstreamJsonPayload(
streamAction = false, streamAction = false,
): unknown { ): unknown {
if (streamAction) { if (streamAction) {
return parseJsonArrayPayload(payload).map((event) => { const events = parseJsonArrayPayload(payload);
for (const event of events) {
applyGeminiGenerateContentAggregate(state, event); applyGeminiGenerateContentAggregate(state, event);
return serializeAggregateJsonPayload(event); }
}); return payload;
} }
applyJsonPayloadToAggregate(state, payload); applyJsonPayloadToAggregate(state, payload);
+12 -6
View File
@@ -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'; type ToastType = 'success' | 'error' | 'info';
@@ -47,12 +47,18 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
setTimeout(() => removeToast(id), 3200); setTimeout(() => removeToast(id), 3200);
}, [removeToast]); }, [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, toast: addToast,
success: useCallback((msg: string) => addToast('success', msg), [addToast]), success,
error: useCallback((msg: string) => addToast('error', msg), [addToast]), error,
info: useCallback((msg: string) => addToast('info', msg), [addToast]), info,
}; }), [addToast, error, info, success]);
return ( return (
<ToastContext.Provider value={value}> <ToastContext.Provider value={value}>
+43
View File
@@ -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 () => { it('separates exact models from group routes in advanced config and uses single-column layout', async () => {
let root: ReturnType<typeof create> | null = null; let root: ReturnType<typeof create> | null = null;
try { try {
+1 -1
View File
@@ -1031,7 +1031,7 @@ export default function TokenRoutes() {
className="btn btn-ghost" className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }} style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
> >
{tr('显示 0 通道路由')} {showZeroChannelRoutes ? tr('隐藏 0 通道路由') : tr('显示 0 通道路由')}
</button> </button>
</div> </div>
@@ -326,6 +326,7 @@ describe('TokenRoutes grouped source models', () => {
}); });
await flushMicrotasks(); await flushMicrotasks();
expect(collectText(root.root)).toContain('隐藏 0 通道路由');
expect(collectText(root.root)).toContain('gpt-5.2-codex'); expect(collectText(root.root)).toContain('gpt-5.2-codex');
expect(collectText(root.root)).toContain('claude-opus-4-6'); expect(collectText(root.root)).toContain('claude-opus-4-6');
expect(collectText(root.root)).toContain('未生成'); expect(collectText(root.root)).toContain('未生成');
+25
View File
@@ -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();
+46
View File
@@ -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
}
}
+32
View File
@@ -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
}
}