fix: address latest coderabbit review findings
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
|
||||
@@ -1168,11 +1168,11 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
|
||||
// Rebuild routes/channels from model availability.
|
||||
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();
|
||||
return { success: true, rebuild };
|
||||
}
|
||||
const body = (request.body || {}) as { refreshModels?: boolean };
|
||||
if (body.refreshModels === false) {
|
||||
const rebuild = await routeRefreshWorkflow.rebuildRoutesOnly();
|
||||
return { success: true, rebuild };
|
||||
}
|
||||
|
||||
if ((request.body as { wait?: boolean } | undefined)?.wait) {
|
||||
const result = await routeRefreshWorkflow.refreshModelsAndRebuildRoutes();
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user