fix: address latest coderabbit review findings
This commit is contained in:
@@ -475,6 +475,9 @@ export async function handleChatSurfaceRequest(
|
|||||||
recordDownstreamCost: (estimatedCost) => {
|
recordDownstreamCost: (estimatedCost) => {
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
},
|
},
|
||||||
|
bestEffortMetrics: {
|
||||||
|
errorLabel: '[proxy/chat] failed to record success metrics',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -541,6 +544,9 @@ export async function handleChatSurfaceRequest(
|
|||||||
recordDownstreamCost: (estimatedCost) => {
|
recordDownstreamCost: (estimatedCost) => {
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
},
|
},
|
||||||
|
bestEffortMetrics: {
|
||||||
|
errorLabel: '[proxy/chat] failed to record success metrics',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.send(downstreamResponse);
|
return reply.send(downstreamResponse);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const resolveProxyUsageWithSelfLogFallbackMock = vi.fn();
|
|||||||
const resolveProxyLogBillingMock = vi.fn();
|
const resolveProxyLogBillingMock = vi.fn();
|
||||||
const refreshOauthAccessTokenSingleflightMock = vi.fn();
|
const refreshOauthAccessTokenSingleflightMock = vi.fn();
|
||||||
const consoleWarnMock = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
const consoleWarnMock = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
const consoleErrorMock = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
vi.mock('../../services/tokenRouter.js', () => ({
|
vi.mock('../../services/tokenRouter.js', () => ({
|
||||||
tokenRouter: {
|
tokenRouter: {
|
||||||
@@ -88,6 +89,7 @@ vi.mock('../../services/oauth/refreshSingleflight.js', () => ({
|
|||||||
describe('selectSurfaceChannelForAttempt', () => {
|
describe('selectSurfaceChannelForAttempt', () => {
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
consoleWarnMock.mockRestore();
|
consoleWarnMock.mockRestore();
|
||||||
|
consoleErrorMock.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -111,6 +113,7 @@ describe('selectSurfaceChannelForAttempt', () => {
|
|||||||
resolveProxyLogBillingMock.mockReset();
|
resolveProxyLogBillingMock.mockReset();
|
||||||
refreshOauthAccessTokenSingleflightMock.mockReset();
|
refreshOauthAccessTokenSingleflightMock.mockReset();
|
||||||
consoleWarnMock.mockClear();
|
consoleWarnMock.mockClear();
|
||||||
|
consoleErrorMock.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('refreshes models and retries selectChannel on the first attempt when no channel is available', async () => {
|
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();
|
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 () => {
|
it('writes proxy logs through the shared log formatter and store', async () => {
|
||||||
composeProxyLogMessageMock.mockReturnValue('normalized error');
|
composeProxyLogMessageMock.mockReturnValue('normalized error');
|
||||||
formatUtcSqlDateTimeMock.mockReturnValue('2026-03-21 22:00:00');
|
formatUtcSqlDateTimeMock.mockReturnValue('2026-03-21 22:00:00');
|
||||||
@@ -824,4 +850,64 @@ describe('selectSurfaceChannelForAttempt', () => {
|
|||||||
billingDetails: { source: 'pricing-test' },
|
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) {
|
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);
|
selected = await tokenRouter.selectChannel(input.requestedModel, input.downstreamPolicy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -316,4 +316,37 @@ describe('accounts credential mode', { timeout: 15_000 }, () => {
|
|||||||
};
|
};
|
||||||
expect(parsedCleared.sub2apiAuth).toBeUndefined();
|
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 && explicitNextMode !== 'auto'
|
||||||
? explicitNextMode
|
? explicitNextMode
|
||||||
: (hasSessionTokenValue(nextAccessToken) ? 'session' : 'apikey');
|
: (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({
|
await convergeAccountMutation({
|
||||||
accountId: id,
|
accountId: id,
|
||||||
preferredApiToken: nextCredentialMode !== 'apikey' ? updates.apiToken : null,
|
preferredApiToken: nextCredentialMode !== 'apikey' ? updates.apiToken : null,
|
||||||
defaultTokenSource: 'manual',
|
defaultTokenSource: 'manual',
|
||||||
refreshModels: true,
|
refreshModels: needsModelRefresh,
|
||||||
rebuildRoutes: true,
|
rebuildRoutes: true,
|
||||||
continueOnError: true,
|
continueOnError: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ describe('route refresh workflow architecture boundaries', () => {
|
|||||||
|
|
||||||
expect(statsSource).toContain("from '../../services/routeRefreshWorkflow.js'");
|
expect(statsSource).toContain("from '../../services/routeRefreshWorkflow.js'");
|
||||||
expectNoDirectModelServiceRouteRefresh(statsSource);
|
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', () => {
|
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 refresh = await refreshModelsForAccount(accountId);
|
||||||
const rebuild = routeRefreshWorkflow.rebuildRoutesOnly();
|
const rebuild = await routeRefreshWorkflow.rebuildRoutesOnly();
|
||||||
return { success: true, refresh, rebuild };
|
return { success: true, refresh, rebuild };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1168,11 +1168,11 @@ export async function tokensRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
// Rebuild routes/channels from model availability.
|
// Rebuild routes/channels from model availability.
|
||||||
app.post<{ Body?: { refreshModels?: boolean; wait?: boolean } }>('/api/routes/rebuild', async (request, reply) => {
|
app.post<{ Body?: { refreshModels?: boolean; wait?: boolean } }>('/api/routes/rebuild', async (request, reply) => {
|
||||||
const body = (request.body || {}) as { refreshModels?: boolean };
|
const body = (request.body || {}) as { refreshModels?: boolean };
|
||||||
if (body.refreshModels === false) {
|
if (body.refreshModels === false) {
|
||||||
const rebuild = routeRefreshWorkflow.rebuildRoutesOnly();
|
const rebuild = await routeRefreshWorkflow.rebuildRoutesOnly();
|
||||||
return { success: true, rebuild };
|
return { success: true, rebuild };
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((request.body as { wait?: boolean } | undefined)?.wait) {
|
if ((request.body as { wait?: boolean } | undefined)?.wait) {
|
||||||
const result = await routeRefreshWorkflow.refreshModelsAndRebuildRoutes();
|
const result = await routeRefreshWorkflow.refreshModelsAndRebuildRoutes();
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ describe('proxy route architecture boundaries', () => {
|
|||||||
expect(chatSurfaceSource).not.toContain('refreshOauthAccessTokenSingleflight(');
|
expect(chatSurfaceSource).not.toContain('refreshOauthAccessTokenSingleflight(');
|
||||||
expect(chatSurfaceSource).not.toContain('resolveProxyUsageWithSelfLogFallback(');
|
expect(chatSurfaceSource).not.toContain('resolveProxyUsageWithSelfLogFallback(');
|
||||||
expect(chatSurfaceSource).not.toContain('resolveProxyLogBilling(');
|
expect(chatSurfaceSource).not.toContain('resolveProxyLogBilling(');
|
||||||
|
expect((chatSurfaceSource.match(/bestEffortMetrics:/g) || []).length).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
expect(responsesSurfaceSource).toContain('trySurfaceOauthRefreshRecovery(');
|
expect(responsesSurfaceSource).toContain('trySurfaceOauthRefreshRecovery(');
|
||||||
expect(responsesSurfaceSource).toContain('recordSurfaceSuccess(');
|
expect(responsesSurfaceSource).toContain('recordSurfaceSuccess(');
|
||||||
|
|||||||
@@ -1084,4 +1084,65 @@ describe('refreshModelsForAccount credential discovery', () => {
|
|||||||
'gemini-3-pro-preview',
|
'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,
|
buildOauthInfoFromAccount,
|
||||||
buildStoredOauthStateFromAccount,
|
buildStoredOauthStateFromAccount,
|
||||||
getOauthInfoFromAccount,
|
getOauthInfoFromAccount,
|
||||||
|
isOauthProvider,
|
||||||
} from './oauthAccount.js';
|
} from './oauthAccount.js';
|
||||||
|
|
||||||
describe('oauth account identity helpers', () => {
|
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({
|
const oauth = getOauthInfoFromAccount({
|
||||||
oauthProvider: 'gemini-cli',
|
oauthProvider: 'gemini-cli',
|
||||||
oauthAccountKey: 'structured-user@example.com',
|
oauthAccountKey: 'structured-user@example.com',
|
||||||
@@ -25,7 +26,7 @@ describe('oauth account identity helpers', () => {
|
|||||||
|
|
||||||
expect(oauth).toEqual(expect.objectContaining({
|
expect(oauth).toEqual(expect.objectContaining({
|
||||||
provider: 'gemini-cli',
|
provider: 'gemini-cli',
|
||||||
accountId: 'structured-user@example.com',
|
accountId: 'json-user',
|
||||||
accountKey: 'structured-user@example.com',
|
accountKey: 'structured-user@example.com',
|
||||||
projectId: 'structured-project',
|
projectId: 'structured-project',
|
||||||
refreshToken: 'refresh-token',
|
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', () => {
|
it('builds patched oauth state from structured identity columns and current runtime state', () => {
|
||||||
const oauth = buildOauthInfoFromAccount({
|
const oauth = buildOauthInfoFromAccount({
|
||||||
oauthProvider: 'codex',
|
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', () => {
|
it('builds a structured identity backfill patch from legacy oauth metadata only for missing columns', () => {
|
||||||
expect(buildOauthIdentityBackfillPatch({
|
expect(buildOauthIdentityBackfillPatch({
|
||||||
oauthProvider: null,
|
oauthProvider: null,
|
||||||
|
|||||||
@@ -179,11 +179,12 @@ export function getOauthInfoFromAccount(account?: OauthIdentityCarrier | null):
|
|||||||
if (!provider) return null;
|
if (!provider) return null;
|
||||||
const structuredAccountKey = asTrimmedString(account.oauthAccountKey);
|
const structuredAccountKey = asTrimmedString(account.oauthAccountKey);
|
||||||
const accountKey = structuredAccountKey || storedIdentity?.accountKey || storedIdentity?.accountId;
|
const accountKey = structuredAccountKey || storedIdentity?.accountKey || storedIdentity?.accountId;
|
||||||
|
const accountId = storedIdentity?.accountId || structuredAccountKey || accountKey;
|
||||||
const projectId = asTrimmedString(account.oauthProjectId) || storedIdentity?.projectId;
|
const projectId = asTrimmedString(account.oauthProjectId) || storedIdentity?.projectId;
|
||||||
return {
|
return {
|
||||||
...(storedRuntime || {}),
|
...(storedRuntime || {}),
|
||||||
provider,
|
provider,
|
||||||
accountId: structuredAccountKey || storedIdentity?.accountId || accountKey,
|
accountId,
|
||||||
accountKey,
|
accountKey,
|
||||||
projectId,
|
projectId,
|
||||||
};
|
};
|
||||||
@@ -275,13 +276,12 @@ export function buildStoredOauthStateFromAccount(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isOauthProvider(
|
export function isOauthProvider(
|
||||||
account: Pick<typeof schema.accounts.$inferSelect, 'extraConfig'> | string | null | undefined,
|
account: OauthIdentityCarrier | string | null | undefined,
|
||||||
provider?: string,
|
provider?: string,
|
||||||
): boolean {
|
): boolean {
|
||||||
const extraConfig = typeof account === 'string' || account == null
|
const oauth = typeof account === 'string' || account == null
|
||||||
? account
|
? getOauthInfoFromExtraConfig(account)
|
||||||
: account.extraConfig;
|
: getOauthInfoFromAccount(account);
|
||||||
const oauth = getOauthInfoFromExtraConfig(extraConfig);
|
|
||||||
if (!oauth) return false;
|
if (!oauth) return false;
|
||||||
if (!provider) return true;
|
if (!provider) return true;
|
||||||
return oauth.provider === provider;
|
return oauth.provider === provider;
|
||||||
|
|||||||
@@ -218,34 +218,39 @@ export async function discoverAntigravityModelsFromCloud(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const oauth = getOauthInfoFromAccount(input.account);
|
const oauth = getOauthInfoFromAccount(input.account);
|
||||||
const requestBody = oauth?.projectId ? { project: oauth.projectId } : {};
|
const projectId = (oauth?.projectId || '').trim();
|
||||||
|
const requestBody = projectId ? { project: projectId } : {};
|
||||||
let lastError = '';
|
let lastError = '';
|
||||||
|
|
||||||
for (const baseUrl of buildAntigravityDiscoveryBaseUrls(input.site.url || ANTIGRAVITY_UPSTREAM_BASE_URL)) {
|
for (const baseUrl of buildAntigravityDiscoveryBaseUrls(input.site.url || ANTIGRAVITY_UPSTREAM_BASE_URL)) {
|
||||||
const response = await fetch(
|
try {
|
||||||
`${baseUrl}/v1internal:fetchAvailableModels`,
|
const response = await fetch(
|
||||||
withSiteRecordProxyRequestInit(input.site, {
|
`${baseUrl}/v1internal:fetchAvailableModels`,
|
||||||
method: 'POST',
|
withSiteRecordProxyRequestInit(input.site, {
|
||||||
headers: {
|
method: 'POST',
|
||||||
Authorization: `Bearer ${accessToken}`,
|
headers: {
|
||||||
Accept: 'application/json',
|
Authorization: `Bearer ${accessToken}`,
|
||||||
'Content-Type': 'application/json',
|
Accept: 'application/json',
|
||||||
'User-Agent': ANTIGRAVITY_MODELS_USER_AGENT,
|
'Content-Type': 'application/json',
|
||||||
},
|
'User-Agent': ANTIGRAVITY_MODELS_USER_AGENT,
|
||||||
body: JSON.stringify(requestBody),
|
},
|
||||||
}),
|
body: JSON.stringify(requestBody),
|
||||||
);
|
}),
|
||||||
if (!response.ok) {
|
);
|
||||||
lastError = await response.text().catch(() => '') || `HTTP ${response.status}`;
|
if (!response.ok) {
|
||||||
continue;
|
lastError = await response.text().catch(() => '') || `HTTP ${response.status}`;
|
||||||
}
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
const models = normalizeDiscoveredModels(extractAntigravityModelIds(payload));
|
const models = normalizeDiscoveredModels(extractAntigravityModelIds(payload));
|
||||||
if (models.length > 0) {
|
if (models.length > 0) {
|
||||||
return models;
|
return models;
|
||||||
|
}
|
||||||
|
lastError = '未获取到可用模型';
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error ? `${baseUrl}: ${error.message}` : String(error);
|
||||||
}
|
}
|
||||||
lastError = '未获取到可用模型';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new 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', () => {
|
describe('sanitizeResponsesBodyForProxy', () => {
|
||||||
|
|||||||
@@ -8,6 +8,25 @@ function asTrimmedString(value: unknown): string {
|
|||||||
return typeof value === 'string' ? value.trim() : '';
|
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' {
|
function toTextBlockType(role: string): 'input_text' | 'output_text' {
|
||||||
return role === 'assistant' ? 'output_text' : 'input_text';
|
return role === 'assistant' ? 'output_text' : 'input_text';
|
||||||
}
|
}
|
||||||
@@ -47,12 +66,12 @@ function normalizeResponsesContentItem(
|
|||||||
|
|
||||||
const type = asTrimmedString(item.type).toLowerCase();
|
const type = asTrimmedString(item.type).toLowerCase();
|
||||||
if (!type) {
|
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;
|
return text ? { type: toTextBlockType(role), text } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'input_text' || type === 'output_text' || type === 'text') {
|
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;
|
if (!text) return null;
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
@@ -62,7 +81,7 @@ function normalizeResponsesContentItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'input_image' || type === 'image_url') {
|
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;
|
if (!imageUrl) return null;
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
@@ -124,7 +143,10 @@ export function normalizeResponsesMessageItem(item: Record<string, unknown>): Re
|
|||||||
}
|
}
|
||||||
|
|
||||||
const role = asTrimmedString(item.role).toLowerCase() || 'user';
|
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') {
|
if (type === 'message') {
|
||||||
return {
|
return {
|
||||||
@@ -143,8 +165,8 @@ export function normalizeResponsesMessageItem(item: Record<string, unknown>): Re
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof item.content === 'string') {
|
if (typeof item.content === 'string' || typeof item.text === 'string') {
|
||||||
const text = item.content.trim();
|
const text = firstNonEmptyTrimmedString(item.content, item.text);
|
||||||
return text ? toResponsesInputMessageFromText(text) : item;
|
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 () => {
|
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 {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export default function DownstreamKeyDrawer({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !item?.id) return;
|
if (!open || !item?.id) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
setOverview(null);
|
||||||
setOverviewLoading(true);
|
setOverviewLoading(true);
|
||||||
api.getDownstreamApiKeyOverview(item.id)
|
api.getDownstreamApiKeyOverview(item.id)
|
||||||
.then((res: any) => {
|
.then((res: any) => {
|
||||||
@@ -71,6 +72,7 @@ export default function DownstreamKeyDrawer({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !item?.id) return;
|
if (!open || !item?.id) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
setBuckets([]);
|
||||||
setTrendLoading(true);
|
setTrendLoading(true);
|
||||||
api.getDownstreamApiKeyTrend(item.id, { range: trendRange })
|
api.getDownstreamApiKeyTrend(item.id, { range: trendRange })
|
||||||
.then((res: any) => {
|
.then((res: any) => {
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ export default function ConversationComposer({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onRemoveConversationFile(file.localId)}
|
onClick={() => onRemoveConversationFile(file.localId)}
|
||||||
|
aria-label={`移除附件 ${file.name || file.localId || '附件'}`}
|
||||||
style={{
|
style={{
|
||||||
border: 'none',
|
border: 'none',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
@@ -148,6 +149,7 @@ export default function ConversationComposer({
|
|||||||
value={input}
|
value={input}
|
||||||
onChange={(event) => onInputChange(event.target.value)}
|
onChange={(event) => onInputChange(event.target.value)}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
|
if (event.nativeEvent.isComposing) return;
|
||||||
if (event.key === 'Enter' && !event.shiftKey) {
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (sending) {
|
if (sending) {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ type DebugPanelPresence = {
|
|||||||
type DebugPanelProps = {
|
type DebugPanelProps = {
|
||||||
presence: DebugPanelPresence;
|
presence: DebugPanelPresence;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
debugTimestamp: number | null;
|
debugTimestamp: string | null;
|
||||||
activeDebugTab: DebugTab;
|
activeDebugTab: DebugTab;
|
||||||
onTabChange: (tab: DebugTab) => void;
|
onTabChange: (tab: DebugTab) => void;
|
||||||
debugTabContent: string;
|
debugTabContent: string;
|
||||||
|
|||||||
@@ -9,4 +9,11 @@ describe('ModelTester conversation composer extraction', () => {
|
|||||||
expect(source).toContain("import ConversationComposer from './model-tester/ConversationComposer.js'");
|
expect(source).toContain("import ConversationComposer from './model-tester/ConversationComposer.js'");
|
||||||
expect(source).not.toContain('添加文件');
|
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).toContain("import DebugPanel from './model-tester/DebugPanel.js'");
|
||||||
expect(source).not.toContain('时间线');
|
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