Merge remote-tracking branch 'origin/main' into codex/codex-review-fixes
# Conflicts: # src/server/services/modelService.ts
This commit is contained in:
@@ -8,6 +8,7 @@ const getApiTokenMock = vi.fn();
|
||||
const getModelsMock = vi.fn();
|
||||
const undiciFetchMock = vi.fn();
|
||||
const proxyAgentCtorMock = vi.fn();
|
||||
const refreshOauthAccessTokenSingleflightMock = vi.fn();
|
||||
|
||||
class MockProxyAgent {
|
||||
readonly proxyUrl: string;
|
||||
@@ -33,6 +34,10 @@ vi.mock('undici', () => ({
|
||||
Agent: MockAgent,
|
||||
}));
|
||||
|
||||
vi.mock('./oauth/refreshSingleflight.js', () => ({
|
||||
refreshOauthAccessTokenSingleflight: (...args: unknown[]) => refreshOauthAccessTokenSingleflightMock(...args),
|
||||
}));
|
||||
|
||||
type DbModule = typeof import('../db/index.js');
|
||||
type ModelServiceModule = typeof import('./modelService.js');
|
||||
|
||||
@@ -62,6 +67,7 @@ describe('refreshModelsForAccount credential discovery', () => {
|
||||
getModelsMock.mockReset();
|
||||
undiciFetchMock.mockReset();
|
||||
proxyAgentCtorMock.mockReset();
|
||||
refreshOauthAccessTokenSingleflightMock.mockReset();
|
||||
|
||||
await db.delete(schema.routeChannels).run();
|
||||
await db.delete(schema.tokenRoutes).run();
|
||||
@@ -614,6 +620,78 @@ describe('refreshModelsForAccount credential discovery', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('discovers Claude OAuth models from the upstream /v1/models response', async () => {
|
||||
getApiTokenMock.mockResolvedValue(null);
|
||||
getModelsMock.mockRejectedValue(new Error('claude oauth discovery should not call adapter.getModels'));
|
||||
undiciFetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{ id: 'claude-3-7-sonnet-latest' },
|
||||
{ id: 'claude-opus-4-1-20250805' },
|
||||
],
|
||||
}),
|
||||
text: async () => JSON.stringify({ ok: true }),
|
||||
});
|
||||
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'claude-site',
|
||||
url: 'https://api.anthropic.com',
|
||||
platform: 'claude',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'claude-user@example.com',
|
||||
accessToken: 'claude-oauth-token',
|
||||
apiToken: null,
|
||||
status: 'active',
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'session',
|
||||
oauth: {
|
||||
provider: 'claude',
|
||||
email: 'claude-user@example.com',
|
||||
accountKey: 'claude-user@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: 2,
|
||||
modelsPreview: ['claude-3-7-sonnet-latest', 'claude-opus-4-1-20250805'],
|
||||
});
|
||||
expect(getModelsMock).not.toHaveBeenCalled();
|
||||
expect(undiciFetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(String(undiciFetchMock.mock.calls[0]?.[0] || '')).toBe('https://api.anthropic.com/v1/models');
|
||||
expect(undiciFetchMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
method: 'GET',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer claude-oauth-token',
|
||||
Accept: 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
}),
|
||||
});
|
||||
|
||||
const rows = await db.select().from(schema.modelAvailability)
|
||||
.where(eq(schema.modelAvailability.accountId, account.id))
|
||||
.all();
|
||||
expect(rows.map((row) => row.modelName).sort()).toEqual([
|
||||
'claude-3-7-sonnet-latest',
|
||||
'claude-opus-4-1-20250805',
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks codex oauth account abnormal when upstream cloud discovery fails', async () => {
|
||||
getApiTokenMock.mockResolvedValue(null);
|
||||
getModelsMock.mockRejectedValue(new Error('codex plan discovery should not call adapter.getModels'));
|
||||
@@ -796,6 +874,130 @@ describe('refreshModelsForAccount credential discovery', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('refreshes gemini oauth access token during validation through singleflight and reuses the refreshed account state', async () => {
|
||||
getApiTokenMock.mockResolvedValue(null);
|
||||
getModelsMock.mockRejectedValue(new Error('gemini oauth validation should not call adapter.getModels'));
|
||||
refreshOauthAccessTokenSingleflightMock.mockImplementation(async (accountId: number) => {
|
||||
const refreshedExtraConfig = JSON.stringify({
|
||||
credentialMode: 'session',
|
||||
oauth: {
|
||||
provider: 'gemini-cli',
|
||||
email: 'gemini-refreshed-user@example.com',
|
||||
accountId: 'gemini-refreshed-user@example.com',
|
||||
accountKey: 'gemini-refreshed-user@example.com',
|
||||
projectId: 'project-refresh-demo',
|
||||
refreshToken: 'gemini-refresh-token-next',
|
||||
},
|
||||
});
|
||||
|
||||
await db.update(schema.accounts).set({
|
||||
accessToken: 'gemini-access-token-refreshed',
|
||||
oauthProvider: 'gemini-cli',
|
||||
oauthAccountKey: 'gemini-refreshed-user@example.com',
|
||||
oauthProjectId: 'project-refresh-demo',
|
||||
extraConfig: refreshedExtraConfig,
|
||||
status: 'active',
|
||||
updatedAt: '2026-03-21T00:00:00.000Z',
|
||||
}).where(eq(schema.accounts.id, accountId)).run();
|
||||
|
||||
return {
|
||||
accountId,
|
||||
accessToken: 'gemini-access-token-refreshed',
|
||||
accountKey: 'gemini-refreshed-user@example.com',
|
||||
extraConfig: refreshedExtraConfig,
|
||||
};
|
||||
});
|
||||
undiciFetchMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({ error: 'expired' }),
|
||||
text: async () => 'expired',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ state: 'ENABLED' }),
|
||||
text: async () => JSON.stringify({ state: 'ENABLED' }),
|
||||
});
|
||||
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'gemini-refresh-site',
|
||||
url: 'https://gemini.example.com',
|
||||
platform: 'gemini',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'gemini-user@example.com',
|
||||
accessToken: 'gemini-access-token-expired',
|
||||
apiToken: null,
|
||||
status: 'active',
|
||||
oauthProvider: 'gemini-cli',
|
||||
oauthAccountKey: 'gemini-user@example.com',
|
||||
oauthProjectId: 'project-refresh-demo',
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'session',
|
||||
oauth: {
|
||||
provider: 'gemini-cli',
|
||||
email: 'gemini-user@example.com',
|
||||
accountId: 'gemini-user@example.com',
|
||||
accountKey: 'gemini-user@example.com',
|
||||
projectId: 'project-refresh-demo',
|
||||
refreshToken: 'gemini-refresh-token',
|
||||
},
|
||||
}),
|
||||
}).returning().get();
|
||||
|
||||
const result = await refreshModelsForAccount(account.id);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
accountId: account.id,
|
||||
refreshed: true,
|
||||
status: 'success',
|
||||
modelCount: expect.any(Number),
|
||||
discoveredByCredential: true,
|
||||
});
|
||||
expect(refreshOauthAccessTokenSingleflightMock).toHaveBeenCalledWith(account.id);
|
||||
expect(undiciFetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(String(undiciFetchMock.mock.calls[0]?.[0] || '')).toContain('/projects/project-refresh-demo/services/');
|
||||
expect(undiciFetchMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
method: 'GET',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer gemini-access-token-expired',
|
||||
}),
|
||||
});
|
||||
expect(
|
||||
undiciFetchMock.mock.calls.some(([url]) => String(url || '').includes('oauth2.googleapis.com/token')),
|
||||
).toBe(false);
|
||||
expect(String(undiciFetchMock.mock.calls[1]?.[0] || '')).toContain('/projects/project-refresh-demo/services/');
|
||||
expect(undiciFetchMock.mock.calls[1]?.[1]).toMatchObject({
|
||||
method: 'GET',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer gemini-access-token-refreshed',
|
||||
}),
|
||||
});
|
||||
|
||||
const latest = await db.select().from(schema.accounts)
|
||||
.where(eq(schema.accounts.id, account.id))
|
||||
.get();
|
||||
expect(latest).toMatchObject({
|
||||
accessToken: 'gemini-access-token-refreshed',
|
||||
oauthProvider: 'gemini-cli',
|
||||
oauthAccountKey: 'gemini-refreshed-user@example.com',
|
||||
oauthProjectId: 'project-refresh-demo',
|
||||
});
|
||||
const parsed = JSON.parse(latest!.extraConfig || '{}');
|
||||
expect(parsed.oauth).toMatchObject({
|
||||
provider: 'gemini-cli',
|
||||
email: 'gemini-refreshed-user@example.com',
|
||||
refreshToken: 'gemini-refresh-token-next',
|
||||
projectId: 'project-refresh-demo',
|
||||
modelDiscoveryStatus: 'healthy',
|
||||
});
|
||||
});
|
||||
|
||||
it('discovers antigravity oauth models via fetchAvailableModels fallback using the oauth project id', async () => {
|
||||
getApiTokenMock.mockResolvedValue(null);
|
||||
getModelsMock.mockRejectedValue(new Error('antigravity oauth discovery should not call adapter.getModels'));
|
||||
|
||||
Reference in New Issue
Block a user