diff --git a/src/server/proxy-core/surfaces/chatSurface.ts b/src/server/proxy-core/surfaces/chatSurface.ts index 37e93c5..bb8f715 100644 --- a/src/server/proxy-core/surfaces/chatSurface.ts +++ b/src/server/proxy-core/surfaces/chatSurface.ts @@ -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); diff --git a/src/server/proxy-core/surfaces/sharedSurface.test.ts b/src/server/proxy-core/surfaces/sharedSurface.test.ts index 78a120c..855f4c2 100644 --- a/src/server/proxy-core/surfaces/sharedSurface.test.ts +++ b/src/server/proxy-core/surfaces/sharedSurface.test.ts @@ -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, + }); + }); }); diff --git a/src/server/proxy-core/surfaces/sharedSurface.ts b/src/server/proxy-core/surfaces/sharedSurface.ts index efad78b..d98d3a8 100644 --- a/src/server/proxy-core/surfaces/sharedSurface.ts +++ b/src/server/proxy-core/surfaces/sharedSurface.ts @@ -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); } diff --git a/src/server/routes/api/accounts.credentialMode.test.ts b/src/server/routes/api/accounts.credentialMode.test.ts index 317f49f..40d531e 100644 --- a/src/server/routes/api/accounts.credentialMode.test.ts +++ b/src/server/routes/api/accounts.credentialMode.test.ts @@ -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); + }); }); diff --git a/src/server/routes/api/accounts.ts b/src/server/routes/api/accounts.ts index eec2665..9286cdf 100644 --- a/src/server/routes/api/accounts.ts +++ b/src/server/routes/api/accounts.ts @@ -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, }); diff --git a/src/server/routes/api/routeRefreshWorkflow.architecture.test.ts b/src/server/routes/api/routeRefreshWorkflow.architecture.test.ts index 7d2efd5..3a99ed8 100644 --- a/src/server/routes/api/routeRefreshWorkflow.architecture.test.ts +++ b/src/server/routes/api/routeRefreshWorkflow.architecture.test.ts @@ -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', () => { diff --git a/src/server/routes/api/stats.ts b/src/server/routes/api/stats.ts index 1485e6c..322bf73 100644 --- a/src/server/routes/api/stats.ts +++ b/src/server/routes/api/stats.ts @@ -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 }; }); diff --git a/src/server/routes/api/tokens.ts b/src/server/routes/api/tokens.ts index 5af88e8..a00451d 100644 --- a/src/server/routes/api/tokens.ts +++ b/src/server/routes/api/tokens.ts @@ -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(); diff --git a/src/server/routes/proxy/architecture-boundaries.test.ts b/src/server/routes/proxy/architecture-boundaries.test.ts index 28f5e20..3d0be22 100644 --- a/src/server/routes/proxy/architecture-boundaries.test.ts +++ b/src/server/routes/proxy/architecture-boundaries.test.ts @@ -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('); diff --git a/src/server/services/modelService.discovery.test.ts b/src/server/services/modelService.discovery.test.ts index 8364678..145d924 100644 --- a/src/server/services/modelService.discovery.test.ts +++ b/src/server/services/modelService.discovery.test.ts @@ -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', + }); + }); }); diff --git a/src/server/services/oauth/oauthAccount.test.ts b/src/server/services/oauth/oauthAccount.test.ts index 904c55a..299e8d0 100644 --- a/src/server/services/oauth/oauthAccount.test.ts +++ b/src/server/services/oauth/oauthAccount.test.ts @@ -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, diff --git a/src/server/services/oauth/oauthAccount.ts b/src/server/services/oauth/oauthAccount.ts index 77710b8..b27e18f 100644 --- a/src/server/services/oauth/oauthAccount.ts +++ b/src/server/services/oauth/oauthAccount.ts @@ -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 | 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; diff --git a/src/server/services/platformDiscoveryRegistry.ts b/src/server/services/platformDiscoveryRegistry.ts index 2dd2c07..3b1b1c0 100644 --- a/src/server/services/platformDiscoveryRegistry.ts +++ b/src/server/services/platformDiscoveryRegistry.ts @@ -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 || '未获取到可用模型'); diff --git a/src/server/transformers/openai/responses/conversion.test.ts b/src/server/transformers/openai/responses/conversion.test.ts index e767c47..c54b3db 100644 --- a/src/server/transformers/openai/responses/conversion.test.ts +++ b/src/server/transformers/openai/responses/conversion.test.ts @@ -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', () => { diff --git a/src/server/transformers/openai/responses/normalization.ts b/src/server/transformers/openai/responses/normalization.ts index 9dc820f..e1c7716 100644 --- a/src/server/transformers/openai/responses/normalization.ts +++ b/src/server/transformers/openai/responses/normalization.ts @@ -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): 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): 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; } diff --git a/src/web/pages/DownstreamKeys.test.tsx b/src/web/pages/DownstreamKeys.test.tsx index d22d5df..0ff7256 100644 --- a/src/web/pages/DownstreamKeys.test.tsx +++ b/src/web/pages/DownstreamKeys.test.tsx @@ -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 | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + 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 | null = null; try { diff --git a/src/web/pages/downstream-keys/DownstreamKeyDrawer.tsx b/src/web/pages/downstream-keys/DownstreamKeyDrawer.tsx index 6ef3d3e..61552d4 100644 --- a/src/web/pages/downstream-keys/DownstreamKeyDrawer.tsx +++ b/src/web/pages/downstream-keys/DownstreamKeyDrawer.tsx @@ -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) => { diff --git a/src/web/pages/model-tester/ConversationComposer.tsx b/src/web/pages/model-tester/ConversationComposer.tsx index 86ca20b..28057c9 100644 --- a/src/web/pages/model-tester/ConversationComposer.tsx +++ b/src/web/pages/model-tester/ConversationComposer.tsx @@ -126,6 +126,7 @@ export default function ConversationComposer({