fix: address latest coderabbit review findings

This commit is contained in:
Cita
2026-03-23 15:24:00 +08:00
parent 426d9a538d
commit 764b9aa0e6
21 changed files with 472 additions and 46 deletions
@@ -475,6 +475,9 @@ export async function handleChatSurfaceRequest(
recordDownstreamCost: (estimatedCost) => {
recordDownstreamCostUsage(request, estimatedCost);
},
bestEffortMetrics: {
errorLabel: '[proxy/chat] failed to record success metrics',
},
});
return;
}
@@ -541,6 +544,9 @@ export async function handleChatSurfaceRequest(
recordDownstreamCost: (estimatedCost) => {
recordDownstreamCostUsage(request, estimatedCost);
},
bestEffortMetrics: {
errorLabel: '[proxy/chat] failed to record success metrics',
},
});
return reply.send(downstreamResponse);
@@ -21,6 +21,7 @@ const resolveProxyUsageWithSelfLogFallbackMock = vi.fn();
const resolveProxyLogBillingMock = vi.fn();
const refreshOauthAccessTokenSingleflightMock = vi.fn();
const consoleWarnMock = vi.spyOn(console, 'warn').mockImplementation(() => {});
const consoleErrorMock = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.mock('../../services/tokenRouter.js', () => ({
tokenRouter: {
@@ -88,6 +89,7 @@ vi.mock('../../services/oauth/refreshSingleflight.js', () => ({
describe('selectSurfaceChannelForAttempt', () => {
afterAll(() => {
consoleWarnMock.mockRestore();
consoleErrorMock.mockRestore();
});
beforeEach(() => {
@@ -111,6 +113,7 @@ describe('selectSurfaceChannelForAttempt', () => {
resolveProxyLogBillingMock.mockReset();
refreshOauthAccessTokenSingleflightMock.mockReset();
consoleWarnMock.mockClear();
consoleErrorMock.mockClear();
});
it('refreshes models and retries selectChannel on the first attempt when no channel is available', async () => {
@@ -155,6 +158,29 @@ describe('selectSurfaceChannelForAttempt', () => {
expect(refreshModelsAndRebuildRoutesMock).not.toHaveBeenCalled();
});
it('logs refresh failures and still retries selection once on the first attempt', async () => {
const selected = { channel: { id: 33 } };
selectChannelMock
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(selected);
refreshModelsAndRebuildRoutesMock.mockRejectedValueOnce(new Error('refresh failed'));
const { selectSurfaceChannelForAttempt } = await import('./sharedSurface.js');
const result = await selectSurfaceChannelForAttempt({
requestedModel: 'gpt-5.2',
downstreamPolicy: EMPTY_DOWNSTREAM_ROUTING_POLICY,
excludeChannelIds: [],
retryCount: 0,
});
expect(result).toBe(selected);
expect(selectChannelMock).toHaveBeenCalledTimes(2);
expect(consoleWarnMock).toHaveBeenCalledWith(
'[proxy/surface] failed to refresh routes after empty selection',
expect.any(Error),
);
});
it('writes proxy logs through the shared log formatter and store', async () => {
composeProxyLogMessageMock.mockReturnValue('normalized error');
formatUtcSqlDateTimeMock.mockReturnValue('2026-03-21 22:00:00');
@@ -824,4 +850,64 @@ describe('selectSurfaceChannelForAttempt', () => {
billingDetails: { source: 'pricing-test' },
});
});
it('treats success metrics as best-effort when requested', async () => {
resolveProxyUsageWithSelfLogFallbackMock.mockRejectedValueOnce(new Error('billing failed'));
const logSuccess = vi.fn().mockResolvedValue(undefined);
const recordDownstreamCost = vi.fn();
const { recordSurfaceSuccess } = await import('./sharedSurface.js');
const result = await recordSurfaceSuccess({
selected: {
channel: { id: 11, routeId: 22 },
account: { id: 33, username: 'oauth-user' },
site: { id: 44, url: 'https://upstream.example.com', name: 'Codex OAuth' },
tokenValue: 'live-token',
tokenName: 'default',
actualModel: 'upstream-model',
},
requestedModel: 'gpt-5.2',
modelName: 'upstream-model',
parsedUsage: {
promptTokens: 10,
completionTokens: 5,
totalTokens: 15,
},
requestStartedAtMs: 1000,
latencyMs: 250,
retryCount: 1,
upstreamPath: '/v1/responses',
logSuccess,
recordDownstreamCost,
bestEffortMetrics: {
errorLabel: '[proxy/chat] failed to record success metrics',
},
});
expect(consoleErrorMock).toHaveBeenCalledWith(
'[proxy/chat] failed to record success metrics',
expect.any(Error),
);
expect(recordSuccessMock).toHaveBeenCalledWith(11, 250, 0, 'upstream-model');
expect(recordDownstreamCost).toHaveBeenCalledWith(0);
expect(logSuccess).toHaveBeenCalledWith(expect.objectContaining({
promptTokens: 10,
completionTokens: 5,
totalTokens: 15,
estimatedCost: 0,
billingDetails: null,
}));
expect(result).toEqual({
resolvedUsage: {
promptTokens: 10,
completionTokens: 5,
totalTokens: 15,
recoveredFromSelfLog: false,
estimatedCostFromQuota: 0,
selfLogBillingMeta: null,
},
estimatedCost: 0,
billingDetails: null,
});
});
});
@@ -113,7 +113,11 @@ export async function selectSurfaceChannelForAttempt(input: {
);
if (!selected && input.retryCount === 0) {
await routeRefreshWorkflow.refreshModelsAndRebuildRoutes();
try {
await routeRefreshWorkflow.refreshModelsAndRebuildRoutes();
} catch (error) {
console.warn('[proxy/surface] failed to refresh routes after empty selection', error);
}
selected = await tokenRouter.selectChannel(input.requestedModel, input.downstreamPolicy);
}
@@ -316,4 +316,37 @@ describe('accounts credential mode', { timeout: 15_000 }, () => {
};
expect(parsedCleared.sub2apiAuth).toBeUndefined();
});
it('does not refresh models for pin-only account edits', async () => {
const site = await db.insert(schema.sites).values({
name: 'Pinned Site',
url: 'https://pinned.example.com',
platform: 'new-api',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'pinned-user',
accessToken: 'access-token',
status: 'active',
isPinned: false,
sortOrder: 0,
}).returning().get();
const response = await app.inject({
method: 'PUT',
url: `/api/accounts/${account.id}`,
payload: {
isPinned: true,
sortOrder: 5,
},
});
expect(response.statusCode).toBe(200);
expect(getModelsMock).not.toHaveBeenCalled();
expect(verifyTokenMock).not.toHaveBeenCalled();
const updated = await db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
expect(updated?.isPinned).toBe(true);
expect(updated?.sortOrder).toBe(5);
});
});
+7 -1
View File
@@ -1315,12 +1315,18 @@ export async function accountsRoutes(app: FastifyInstance) {
explicitNextMode && explicitNextMode !== 'auto'
? explicitNextMode
: (hasSessionTokenValue(nextAccessToken) ? 'session' : 'apikey');
const needsModelRefresh =
Object.prototype.hasOwnProperty.call(body, 'accessToken')
|| Object.prototype.hasOwnProperty.call(body, 'apiToken')
|| Object.prototype.hasOwnProperty.call(body, 'extraConfig')
|| Object.prototype.hasOwnProperty.call(body, 'proxyUrl')
|| wantsManagedSub2ApiAuthPatch;
await convergeAccountMutation({
accountId: id,
preferredApiToken: nextCredentialMode !== 'apikey' ? updates.apiToken : null,
defaultTokenSource: 'manual',
refreshModels: true,
refreshModels: needsModelRefresh,
rebuildRoutes: true,
continueOnError: true,
});
@@ -24,6 +24,8 @@ describe('route refresh workflow architecture boundaries', () => {
expect(statsSource).toContain("from '../../services/routeRefreshWorkflow.js'");
expectNoDirectModelServiceRouteRefresh(statsSource);
expect(tokensSource).toContain('const rebuild = await routeRefreshWorkflow.rebuildRoutesOnly();');
expect(statsSource).toContain('const rebuild = await routeRefreshWorkflow.rebuildRoutesOnly();');
});
it('keeps proxy fallback refreshes and scheduler hooks on the route refresh workflow', () => {
+1 -1
View File
@@ -1387,7 +1387,7 @@ export async function statsRoutes(app: FastifyInstance) {
}
const refresh = await refreshModelsForAccount(accountId);
const rebuild = routeRefreshWorkflow.rebuildRoutesOnly();
const rebuild = await routeRefreshWorkflow.rebuildRoutesOnly();
return { success: true, refresh, rebuild };
});
+1 -1
View File
@@ -1170,7 +1170,7 @@ export async function tokensRoutes(app: FastifyInstance) {
app.post<{ Body?: { refreshModels?: boolean; wait?: boolean } }>('/api/routes/rebuild', async (request, reply) => {
const body = (request.body || {}) as { refreshModels?: boolean };
if (body.refreshModels === false) {
const rebuild = routeRefreshWorkflow.rebuildRoutesOnly();
const rebuild = await routeRefreshWorkflow.rebuildRoutesOnly();
return { success: true, rebuild };
}
@@ -154,6 +154,7 @@ describe('proxy route architecture boundaries', () => {
expect(chatSurfaceSource).not.toContain('refreshOauthAccessTokenSingleflight(');
expect(chatSurfaceSource).not.toContain('resolveProxyUsageWithSelfLogFallback(');
expect(chatSurfaceSource).not.toContain('resolveProxyLogBilling(');
expect((chatSurfaceSource.match(/bestEffortMetrics:/g) || []).length).toBeGreaterThanOrEqual(2);
expect(responsesSurfaceSource).toContain('trySurfaceOauthRefreshRecovery(');
expect(responsesSurfaceSource).toContain('recordSurfaceSuccess(');
@@ -1084,4 +1084,65 @@ describe('refreshModelsForAccount credential discovery', () => {
'gemini-3-pro-preview',
]);
});
it('continues antigravity discovery after fetch errors and trims the oauth project id before posting', async () => {
getApiTokenMock.mockResolvedValue(null);
getModelsMock.mockRejectedValue(new Error('antigravity oauth discovery should not call adapter.getModels'));
undiciFetchMock
.mockRejectedValueOnce(new Error('network boom'))
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
models: {
'gemini-3-pro-preview': { displayName: 'Gemini 3 Pro Preview' },
},
}),
text: async () => JSON.stringify({ ok: true }),
});
const site = await db.insert(schema.sites).values({
name: 'antigravity-site-trimmed',
url: 'https://cloudcode-pa.googleapis.com',
platform: 'antigravity',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'antigravity-trimmed@example.com',
accessToken: 'antigravity-access-token',
apiToken: null,
status: 'active',
oauthProvider: 'antigravity',
oauthAccountKey: 'antigravity-trimmed@example.com',
oauthProjectId: ' project-demo ',
extraConfig: JSON.stringify({
credentialMode: 'session',
oauth: {
provider: 'antigravity',
email: 'antigravity-trimmed@example.com',
},
}),
}).returning().get();
const result = await refreshModelsForAccount(account.id);
expect(result).toMatchObject({
accountId: account.id,
refreshed: true,
status: 'success',
errorCode: null,
tokenScanned: 0,
discoveredByCredential: true,
discoveredApiToken: false,
modelCount: 1,
modelsPreview: ['gemini-3-pro-preview'],
});
expect(undiciFetchMock).toHaveBeenCalledTimes(2);
expect(String(undiciFetchMock.mock.calls[1]?.[0] || '')).toBe('https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels');
expect(JSON.parse(String(undiciFetchMock.mock.calls[1]?.[1]?.body || '{}'))).toEqual({
project: 'project-demo',
});
});
});
+52 -2
View File
@@ -4,10 +4,11 @@ import {
buildOauthInfoFromAccount,
buildStoredOauthStateFromAccount,
getOauthInfoFromAccount,
isOauthProvider,
} from './oauthAccount.js';
describe('oauth account identity helpers', () => {
it('prefers structured oauth identity columns over extraConfig metadata', () => {
it('prefers structured oauth provider/accountKey/projectId columns while preserving a legacy accountId', () => {
const oauth = getOauthInfoFromAccount({
oauthProvider: 'gemini-cli',
oauthAccountKey: 'structured-user@example.com',
@@ -25,7 +26,7 @@ describe('oauth account identity helpers', () => {
expect(oauth).toEqual(expect.objectContaining({
provider: 'gemini-cli',
accountId: 'structured-user@example.com',
accountId: 'json-user',
accountKey: 'structured-user@example.com',
projectId: 'structured-project',
refreshToken: 'refresh-token',
@@ -82,6 +83,29 @@ describe('oauth account identity helpers', () => {
}));
});
it('preserves legacy accountId when structured oauthAccountKey differs', () => {
const oauth = getOauthInfoFromAccount({
oauthProvider: 'codex',
oauthAccountKey: 'structured-account-key',
oauthProjectId: null,
extraConfig: JSON.stringify({
oauth: {
provider: 'codex',
accountId: 'legacy-account-id',
accountKey: 'legacy-account-key',
refreshToken: 'refresh-token',
},
}),
});
expect(oauth).toEqual(expect.objectContaining({
provider: 'codex',
accountId: 'legacy-account-id',
accountKey: 'structured-account-key',
refreshToken: 'refresh-token',
}));
});
it('builds patched oauth state from structured identity columns and current runtime state', () => {
const oauth = buildOauthInfoFromAccount({
oauthProvider: 'codex',
@@ -135,6 +159,32 @@ describe('oauth account identity helpers', () => {
});
});
it('still recognizes oauth providers from account rows after persisted state strips identity fields', () => {
const account = {
oauthProvider: 'codex',
oauthAccountKey: 'structured-account',
oauthProjectId: 'structured-project',
extraConfig: JSON.stringify({
oauth: buildStoredOauthStateFromAccount({
oauthProvider: 'codex',
oauthAccountKey: 'structured-account',
oauthProjectId: 'structured-project',
extraConfig: JSON.stringify({
oauth: {
provider: 'codex',
accountId: 'legacy-account',
refreshToken: 'refresh-token',
},
}),
}),
}),
};
expect(isOauthProvider(account)).toBe(true);
expect(isOauthProvider(account, 'codex')).toBe(true);
expect(isOauthProvider(account, 'gemini-cli')).toBe(false);
});
it('builds a structured identity backfill patch from legacy oauth metadata only for missing columns', () => {
expect(buildOauthIdentityBackfillPatch({
oauthProvider: null,
+6 -6
View File
@@ -179,11 +179,12 @@ export function getOauthInfoFromAccount(account?: OauthIdentityCarrier | null):
if (!provider) return null;
const structuredAccountKey = asTrimmedString(account.oauthAccountKey);
const accountKey = structuredAccountKey || storedIdentity?.accountKey || storedIdentity?.accountId;
const accountId = storedIdentity?.accountId || structuredAccountKey || accountKey;
const projectId = asTrimmedString(account.oauthProjectId) || storedIdentity?.projectId;
return {
...(storedRuntime || {}),
provider,
accountId: structuredAccountKey || storedIdentity?.accountId || accountKey,
accountId,
accountKey,
projectId,
};
@@ -275,13 +276,12 @@ export function buildStoredOauthStateFromAccount(
}
export function isOauthProvider(
account: Pick<typeof schema.accounts.$inferSelect, 'extraConfig'> | string | null | undefined,
account: OauthIdentityCarrier | string | null | undefined,
provider?: string,
): boolean {
const extraConfig = typeof account === 'string' || account == null
? account
: account.extraConfig;
const oauth = getOauthInfoFromExtraConfig(extraConfig);
const oauth = typeof account === 'string' || account == null
? getOauthInfoFromExtraConfig(account)
: getOauthInfoFromAccount(account);
if (!oauth) return false;
if (!provider) return true;
return oauth.provider === provider;
@@ -218,34 +218,39 @@ export async function discoverAntigravityModelsFromCloud(input: {
}
const oauth = getOauthInfoFromAccount(input.account);
const requestBody = oauth?.projectId ? { project: oauth.projectId } : {};
const projectId = (oauth?.projectId || '').trim();
const requestBody = projectId ? { project: projectId } : {};
let lastError = '';
for (const baseUrl of buildAntigravityDiscoveryBaseUrls(input.site.url || ANTIGRAVITY_UPSTREAM_BASE_URL)) {
const response = await fetch(
`${baseUrl}/v1internal:fetchAvailableModels`,
withSiteRecordProxyRequestInit(input.site, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': ANTIGRAVITY_MODELS_USER_AGENT,
},
body: JSON.stringify(requestBody),
}),
);
if (!response.ok) {
lastError = await response.text().catch(() => '') || `HTTP ${response.status}`;
continue;
}
try {
const response = await fetch(
`${baseUrl}/v1internal:fetchAvailableModels`,
withSiteRecordProxyRequestInit(input.site, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': ANTIGRAVITY_MODELS_USER_AGENT,
},
body: JSON.stringify(requestBody),
}),
);
if (!response.ok) {
lastError = await response.text().catch(() => '') || `HTTP ${response.status}`;
continue;
}
const payload = await response.json();
const models = normalizeDiscoveredModels(extractAntigravityModelIds(payload));
if (models.length > 0) {
return models;
const payload = await response.json();
const models = normalizeDiscoveredModels(extractAntigravityModelIds(payload));
if (models.length > 0) {
return models;
}
lastError = '未获取到可用模型';
} catch (error) {
lastError = error instanceof Error ? `${baseUrl}: ${error.message}` : String(error);
}
lastError = '未获取到可用模型';
}
throw new Error(lastError || '未获取到可用模型');
@@ -75,6 +75,48 @@ describe('responses conversion single source of truth', () => {
},
]);
});
it('falls back to non-empty text and image sources when earlier compatibility fields are blank', () => {
const normalized = normalizeResponsesInputForCompatibility([
{
role: 'assistant',
content: '',
text: 'done',
},
{
role: 'user',
content: [
{ type: 'text', text: ' ', content: 'hello' },
{ type: 'image_url', image_url: ' ', url: 'https://example.com/image.png' },
],
},
]);
expect(normalized).toEqual([
expect.objectContaining({
type: 'message',
role: 'assistant',
text: 'done',
content: [{ type: 'output_text', text: 'done' }],
}),
expect.objectContaining({
type: 'message',
role: 'user',
content: [
expect.objectContaining({
type: 'input_text',
text: 'hello',
content: 'hello',
}),
expect.objectContaining({
type: 'input_image',
image_url: 'https://example.com/image.png',
url: 'https://example.com/image.png',
}),
],
}),
]);
});
});
describe('sanitizeResponsesBodyForProxy', () => {
@@ -8,6 +8,25 @@ function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function firstNonEmptyTrimmedString(...values: unknown[]): string {
for (const value of values) {
const normalized = asTrimmedString(value);
if (normalized) return normalized;
}
return '';
}
function firstMeaningfulValue(...values: unknown[]): unknown {
for (const value of values) {
if (typeof value === 'string') {
if (value.trim()) return value;
continue;
}
if (value !== undefined && value !== null) return value;
}
return undefined;
}
function toTextBlockType(role: string): 'input_text' | 'output_text' {
return role === 'assistant' ? 'output_text' : 'input_text';
}
@@ -47,12 +66,12 @@ function normalizeResponsesContentItem(
const type = asTrimmedString(item.type).toLowerCase();
if (!type) {
const text = asTrimmedString(item.text ?? item.content ?? item.output_text);
const text = firstNonEmptyTrimmedString(item.text, item.content, item.output_text);
return text ? { type: toTextBlockType(role), text } : null;
}
if (type === 'input_text' || type === 'output_text' || type === 'text') {
const text = asTrimmedString(item.text ?? item.content ?? item.output_text);
const text = firstNonEmptyTrimmedString(item.text, item.content, item.output_text);
if (!text) return null;
return {
...item,
@@ -62,7 +81,7 @@ function normalizeResponsesContentItem(
}
if (type === 'input_image' || type === 'image_url') {
const imageUrl = normalizeImageUrlValue(item.image_url ?? item.url);
const imageUrl = normalizeImageUrlValue(item.image_url) ?? normalizeImageUrlValue(item.url);
if (!imageUrl) return null;
return {
...item,
@@ -124,7 +143,10 @@ export function normalizeResponsesMessageItem(item: Record<string, unknown>): Re
}
const role = asTrimmedString(item.role).toLowerCase() || 'user';
const normalizedContent = normalizeResponsesMessageContent(item.content ?? item.text, role);
const normalizedContent = normalizeResponsesMessageContent(
firstMeaningfulValue(item.content, item.text),
role,
);
if (type === 'message') {
return {
@@ -143,8 +165,8 @@ export function normalizeResponsesMessageItem(item: Record<string, unknown>): Re
};
}
if (typeof item.content === 'string') {
const text = item.content.trim();
if (typeof item.content === 'string' || typeof item.text === 'string') {
const text = firstNonEmptyTrimmedString(item.content, item.text);
return text ? toResponsesInputMessageFromText(text) : item;
}
+91
View File
@@ -360,6 +360,97 @@ describe('DownstreamKeys page', () => {
}
});
it('clears stale drawer overview and trend data when switching to another key', async () => {
apiMock.getDownstreamApiKeysSummary.mockResolvedValue({
success: true,
items: [
buildSummaryItem(),
buildSummaryItem({
id: 2,
name: 'batch-key',
keyMasked: 'sk-b****0315',
groupName: '项目B',
rangeUsage: {
totalRequests: 1,
successRequests: 1,
failedRequests: 0,
successRate: 100,
totalTokens: 12,
totalCost: 0.01,
},
}),
],
});
apiMock.getDownstreamApiKeys.mockResolvedValue({
success: true,
items: [
buildRawItem(),
buildRawItem({
id: 2,
name: 'batch-key',
key: 'sk-batch-0315',
keyMasked: 'sk-b****0315',
groupName: '项目B',
}),
],
});
apiMock.getDownstreamApiKeyOverview
.mockResolvedValueOnce({
success: true,
item: buildSummaryItem(),
usage: {
last24h: { totalRequests: 3, successRequests: 2, failedRequests: 1, successRate: 66.7, totalTokens: 4200, totalCost: 0.42 },
last7d: { totalRequests: 9, successRequests: 8, failedRequests: 1, successRate: 88.9, totalTokens: 12400, totalCost: 1.24 },
all: { totalRequests: 20, successRequests: 18, failedRequests: 2, successRate: 90, totalTokens: 55200, totalCost: 5.52 },
},
})
.mockImplementationOnce(() => new Promise(() => {}));
apiMock.getDownstreamApiKeyTrend
.mockResolvedValueOnce({
success: true,
buckets: [
{ startUtc: '2026-03-15T08:00:00.000Z', totalRequests: 2, totalTokens: 1200, totalCost: 0.12, successRate: 100 },
{ startUtc: '2026-03-15T09:00:00.000Z', totalRequests: 1, totalTokens: 3000, totalCost: 0.3, successRate: 0 },
],
})
.mockImplementationOnce(() => new Promise(() => {}));
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const rows = root!.root.findAll((node) => node.type === 'tr' && typeof node.props.onClick === 'function');
const smokeRow = rows.find((node) => collectText(node).includes('smoke-key'));
const batchRow = rows.find((node) => collectText(node).includes('batch-key'));
await act(async () => {
smokeRow!.props.onClick();
});
await flushMicrotasks();
expect(collectText(root!.root)).toContain('固定窗口对比');
expect(collectText(root!.root)).toContain('trend:2');
await act(async () => {
batchRow!.props.onClick();
});
await flushMicrotasks();
expect(collectText(root!.root)).not.toContain('固定窗口对比');
expect(collectText(root!.root)).not.toContain('trend:2');
} finally {
root?.unmount();
}
});
it('separates exact models from group routes in advanced config and uses single-column layout', async () => {
let root: ReturnType<typeof create> | null = null;
try {
@@ -49,6 +49,7 @@ export default function DownstreamKeyDrawer({
useEffect(() => {
if (!open || !item?.id) return;
let cancelled = false;
setOverview(null);
setOverviewLoading(true);
api.getDownstreamApiKeyOverview(item.id)
.then((res: any) => {
@@ -71,6 +72,7 @@ export default function DownstreamKeyDrawer({
useEffect(() => {
if (!open || !item?.id) return;
let cancelled = false;
setBuckets([]);
setTrendLoading(true);
api.getDownstreamApiKeyTrend(item.id, { range: trendRange })
.then((res: any) => {
@@ -126,6 +126,7 @@ export default function ConversationComposer({
<button
type="button"
onClick={() => onRemoveConversationFile(file.localId)}
aria-label={`移除附件 ${file.name || file.localId || '附件'}`}
style={{
border: 'none',
background: 'transparent',
@@ -148,6 +149,7 @@ export default function ConversationComposer({
value={input}
onChange={(event) => onInputChange(event.target.value)}
onKeyDown={(event) => {
if (event.nativeEvent.isComposing) return;
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
if (sending) {
+1 -1
View File
@@ -15,7 +15,7 @@ type DebugPanelPresence = {
type DebugPanelProps = {
presence: DebugPanelPresence;
isMobile: boolean;
debugTimestamp: number | null;
debugTimestamp: string | null;
activeDebugTab: DebugTab;
onTabChange: (tab: DebugTab) => void;
debugTabContent: string;
@@ -9,4 +9,11 @@ describe('ModelTester conversation composer extraction', () => {
expect(source).toContain("import ConversationComposer from './model-tester/ConversationComposer.js'");
expect(source).not.toContain('添加文件');
});
it('keeps accessibility and IME safeguards inside the dedicated composer component', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/model-tester/ConversationComposer.tsx'), 'utf8').replace(/\r\n/g, '\n');
expect(source).toContain('aria-label={`移除附件');
expect(source).toContain('event.nativeEvent.isComposing');
});
});
@@ -9,4 +9,10 @@ describe('ModelTester debug panel extraction', () => {
expect(source).toContain("import DebugPanel from './model-tester/DebugPanel.js'");
expect(source).not.toContain('时间线');
});
it('keeps the debug timestamp prop aligned with the ISO string state used by ModelTester', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/model-tester/DebugPanel.tsx'), 'utf8').replace(/\r\n/g, '\n');
expect(source).toContain('debugTimestamp: string | null;');
});
});