diff --git a/app/admin-api/credentials/route.ts b/app/admin-api/credentials/route.ts index a66170d..5cb6023 100644 --- a/app/admin-api/credentials/route.ts +++ b/app/admin-api/credentials/route.ts @@ -3,6 +3,7 @@ import { listCredentials, updateCredentialByIndex, } from '@/lib/server/domain/credentials'; +import { refreshCredentialModels } from '@/lib/server/domain/credential-models'; import { getAdminSessionErrorResponse } from '@/lib/server/admin/session'; import { getJsonBody } from '@/lib/server/shared/http'; @@ -33,12 +34,13 @@ export const POST = async (request: Request): Promise => { return Response.json(await updateCredentialByIndex(body.index, body)); } - return Response.json( - await addCredential( - body, - typeof body.filename === 'string' ? body.filename : undefined, - ), + const saved = await addCredential( + body, + typeof body.filename === 'string' ? body.filename : undefined, ); + await refreshCredentialModels(saved.filename); + + return Response.json(saved); } catch (error) { return Response.json( { diff --git a/app/page-loader.ts b/app/page-loader.ts index af74012..49c439e 100644 --- a/app/page-loader.ts +++ b/app/page-loader.ts @@ -15,14 +15,12 @@ import type { AdminUsagePreferences } from '@/lib/server/admin/session'; import { listAccessKeys } from '@/lib/server/domain/access-keys'; import { getActiveConfig, getSettingLabels } from '@/lib/server/domain/config'; import { + getCredentialSupportedModels, getCurrentCredentialInfo, listEligibleCredentialRecords, listCredentials, } from '@/lib/server/domain/credentials'; -import { - getModelsByCredential, - getModelsForCredentials, -} from '@/lib/server/proxy/codebuddy'; +import { getModelsForCredentials } from '@/lib/server/proxy/codebuddy'; import { getDebugSettings, listDebugLogs } from '@/lib/server/domain/debug'; import { getUsageAnalytics } from '@/lib/server/domain/usage'; import type { AppLocale } from '@/lib/i18n/routing'; @@ -145,19 +143,17 @@ export const getInitialData = async ({ } case 'api-test': { const eligibleCredentials = await listEligibleCredentialRecords(); - const [credentials, currentCredential, models, credentialModels] = - await Promise.all([ - listCredentials(), - getCurrentCredentialInfo(), - getModelsForCredentials(eligibleCredentials), - getModelsByCredential(eligibleCredentials), - ]); + const [credentials, currentCredential, models] = await Promise.all([ + listCredentials(), + getCurrentCredentialInfo(), + getModelsForCredentials(eligibleCredentials), + ]); return { credentialModels: Object.fromEntries( - Object.entries(credentialModels).map(([filename, value]) => [ - filename, - value.models.map((model) => model.id), + eligibleCredentials.map((credential) => [ + credential.filename, + getCredentialSupportedModels(credential.data), ]), ), credentials: credentials.credentials as unknown as CredentialSummary[], diff --git a/app/page-shell.tsx b/app/page-shell.tsx index 5bf0829..2f53b78 100644 --- a/app/page-shell.tsx +++ b/app/page-shell.tsx @@ -540,6 +540,10 @@ const AdminPageLayoutContent = ({ modelsLoading: false, })); setApiTest((current) => { + if (filename && current.credentialFilename !== filename) { + return current; + } + const selectedFilename = filename ?? current.credentialFilename; const models = modelRows[selectedFilename]?.models; @@ -1044,9 +1048,6 @@ const AdminPageLayoutContent = ({ name: result.data.filename ?? 'unknown', }), ); - if (!isEditing && result.data.filename) { - await refreshCredentialModels(result.data.filename); - } await refreshAdminData(); }; diff --git a/lib/server/domain/config.ts b/lib/server/domain/config.ts index 97a3512..9e30804 100644 --- a/lib/server/domain/config.ts +++ b/lib/server/domain/config.ts @@ -6,6 +6,10 @@ import { readStorageJson, writeStorageJson, } from '../storage'; +import { + getCredentialSupportedModels, + listEligibleCredentialRecords, +} from './credentials'; export interface RuntimeConfig { CODEBUDDY_API_ENDPOINT: string; @@ -168,7 +172,16 @@ export const getCodeBuddyApiEndpoint = async (): Promise => { : 'https://copilot.tencent.com'; }; -export const getDefaultModel = async (fallback = 'glm-5.1'): Promise => - fallback; +export const getDefaultModel = async ( + fallback = 'glm-5.1', +): Promise => { + const credentials = await listEligibleCredentialRecords(); + + return ( + credentials + .flatMap((credential) => getCredentialSupportedModels(credential.data)) + .sort((left, right) => left.localeCompare(right))[0] ?? fallback + ); +}; export { getConfigDir, getConfigPath, getCredsDir, getFileStorageDir }; diff --git a/lib/server/domain/credential-models.ts b/lib/server/domain/credential-models.ts index ef18e3a..18777e8 100644 --- a/lib/server/domain/credential-models.ts +++ b/lib/server/domain/credential-models.ts @@ -1,4 +1,5 @@ import { + findCredentialRecordByFilename, getCredentialSupportedModels, listEligibleCredentialRecords, updateCredentialSupportedModels, @@ -9,6 +10,30 @@ const globalCredentialModelRefreshState = globalThis as typeof globalThis & { __codebuddy2apiCredentialModelRefresh__?: Promise; }; +export const refreshCredentialModels = async ( + filename: string, +): Promise => { + try { + const credential = await findCredentialRecordByFilename(filename); + + if (!credential) { + return; + } + + const result = await getModelsByCredential([credential]); + const models = result[credential.filename]?.models ?? []; + + if (models.length) { + await updateCredentialSupportedModels( + credential.filename, + models.map((model) => model.id), + ); + } + } catch (error) { + console.warn('[CodeBuddy2API] Unable to refresh credential models', error); + } +}; + export const refreshMissingCredentialModels = (): Promise => { if ( !globalCredentialModelRefreshState.__codebuddy2apiCredentialModelRefresh__ @@ -24,15 +49,7 @@ export const refreshMissingCredentialModels = (): Promise => { await Promise.allSettled( missingModels.map(async (credential) => { - const result = await getModelsByCredential([credential]); - const models = result[credential.filename]?.models ?? []; - - if (models.length) { - await updateCredentialSupportedModels( - credential.filename, - models.map((model) => model.id), - ); - } + await refreshCredentialModels(credential.filename); }), ); } catch (error) { diff --git a/lib/server/domain/credentials.ts b/lib/server/domain/credentials.ts index 203117e..2192fe4 100644 --- a/lib/server/domain/credentials.ts +++ b/lib/server/domain/credentials.ts @@ -690,16 +690,26 @@ export const resolveCredentialForRequest = async ({ accessKeyId, affinityKey, allowedCredentialFilenames, + model, }: { accessKeyId?: string; affinityKey?: string; allowedCredentialFilenames?: string[]; + model?: string; } = {}): Promise => { const records = await readCredentialRecords(); + const requestedModel = model?.trim(); const eligibleRecords = getEligibleRecords( records, allowedCredentialFilenames, - ); + ).filter((record) => { + if (!requestedModel) return true; + + const supportedModels = getCredentialSupportedModels(record.data); + return ( + supportedModels.length === 0 || supportedModels.includes(requestedModel) + ); + }); if (!eligibleRecords.length) { return null; diff --git a/lib/server/proxy/codebuddy-auth.ts b/lib/server/proxy/codebuddy-auth.ts index 3a9a816..0272381 100644 --- a/lib/server/proxy/codebuddy-auth.ts +++ b/lib/server/proxy/codebuddy-auth.ts @@ -1,5 +1,6 @@ import { getCodeBuddyApiEndpoint } from '../domain/config'; import { addCredential, type CredentialData } from '../domain/credentials'; +import { refreshCredentialModels } from '../domain/credential-models'; import { getAdminSessionErrorResponse } from '../admin/session'; const getAuthStateEndpoint = async (): Promise => @@ -286,6 +287,7 @@ export const pollCodeBuddyAuth = async ( }; const credential = buildCredentialDataFromToken(tokenPayload); const saved = await addCredential(credential); + await refreshCredentialModels(saved.filename); return Response.json({ access_token: tokenPayload.access_token, diff --git a/lib/server/proxy/codebuddy.ts b/lib/server/proxy/codebuddy.ts index 97cb07e..8527932 100644 --- a/lib/server/proxy/codebuddy.ts +++ b/lib/server/proxy/codebuddy.ts @@ -433,26 +433,11 @@ const normalizeMessages = ( return applyPromptCacheControl(filtered); } - let hasSystemMessage = false; - - const normalized = filtered.map((message, index) => { - if (message.role === 'system') { - hasSystemMessage = true; - return message; - } - + const normalized = filtered.map((message) => { if (message.role !== 'developer') { return message; } - if (index === 0 && !hasSystemMessage) { - hasSystemMessage = true; - return { - ...message, - role: 'system', - }; - } - return { ...message, role: 'user', @@ -466,12 +451,14 @@ const normalizeMessages = ( export const resolveProxyContext = async ( request: NextRequest, + model?: string, ): Promise => { const accessKey = await resolveRequestAccessKey(request); const credential = await resolveCredentialForRequest({ accessKeyId: accessKey?.id, affinityKey: getCredentialAffinityKey(request, accessKey?.id ?? null), allowedCredentialFilenames: accessKey?.credentialFilenames, + model, }); if (!credential) { @@ -673,10 +660,13 @@ const buildUpstreamBody = async ( context.preferences.firstMessageRoleToSystem, ); const maxTokens = body.max_tokens ?? body.max_completion_tokens; + const credentialModels = getCredentialSupportedModels( + context.auth.credentialData, + ); const model = typeof body.model === 'string' && body.model.trim() ? body.model - : await getDefaultModel(); + : (credentialModels[0] ?? (await getDefaultModel())); return { model, @@ -1315,7 +1305,8 @@ export const proxyChatCompletions = async ( } try { - const resolvedContext = context ?? (await resolveProxyContext(request)); + const resolvedContext = + context ?? (await resolveProxyContext(request, body.model)); setDebugTraceCredential(debugTrace, resolvedContext.credentialFilename); const upstreamBody = await buildUpstreamBody(body, resolvedContext); const apiEndpoint = await getCodeBuddyApiEndpoint(); @@ -1430,7 +1421,12 @@ export const proxyResponsesUpstream = async ( debugTrace?: DebugTrace, ): Promise => { try { - const resolvedContext = context ?? (await resolveProxyContext(request)); + const resolvedContext = + context ?? + (await resolveProxyContext( + request, + typeof body.model === 'string' ? body.model : undefined, + )); setDebugTraceCredential(debugTrace, resolvedContext.credentialFilename); const upstreamBody = { ...body, diff --git a/lib/server/proxy/responses.ts b/lib/server/proxy/responses.ts index 7def18a..dfb8af9 100644 --- a/lib/server/proxy/responses.ts +++ b/lib/server/proxy/responses.ts @@ -1,6 +1,7 @@ import type { NextRequest } from 'next/server'; import { getDefaultModel } from '../domain/config'; +import { getCredentialSupportedModels } from '../domain/credentials'; import type { DebugTrace } from '../domain/debug'; import { proxyChatCompletions, @@ -1409,12 +1410,27 @@ export const handleResponsesRequest = async ( requireEligible: true, }, ) - : await resolveProxyContext(request); + : await resolveProxyContext( + request, + typeof body.model === 'string' ? body.model : undefined, + ); + + const scopedBody = + !storedPreviousSession && + (typeof body.model !== 'string' || !body.model.trim()) + ? { + ...body, + model: + getCredentialSupportedModels( + proxyContext.auth.credentialData, + )[0] ?? (await getDefaultModel()), + } + : body; if (proxyContext.preferences.responsesPassthrough) { return proxyResponsesUpstream( request, - body as Record, + scopedBody as Record, proxyContext, debugTrace, ); @@ -1426,7 +1442,7 @@ export const handleResponsesRequest = async ( ); const prepared = await prepareTranscript( - body, + scopedBody, proxyContext.accessKeyId, previousSession, ); diff --git a/messages/en-US.json b/messages/en-US.json index cb8dab3..70dd76e 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -233,8 +233,8 @@ "credentialResponsesDirectHelp": "When enabled, `/v1/responses` requests using this credential are sent upstream directly instead of being converted into Chat Completions.", "credentialResponsesProxyTag": "Responses → Chat", "credentialRoleAsSystem": "Normalize developer messages for upstream", - "credentialRoleAsSystemHelp": "Sends the first developer message as system and later developer messages as user.", - "credentialRoleAsSystemTag": "developer → system", + "credentialRoleAsSystemHelp": "Sends every developer message as user.", + "credentialRoleAsSystemTag": "developer → user", "credentialRoleKeepDeveloper": "Keep developer", "credentialSectionTitle": "Saved credentials", "modelCredential": "Credential", diff --git a/messages/ja-JP.json b/messages/ja-JP.json index 18ad6a7..d34eb60 100644 --- a/messages/ja-JP.json +++ b/messages/ja-JP.json @@ -233,8 +233,8 @@ "credentialResponsesDirectHelp": "有効にすると、この認証情報で処理される `/v1/responses` リクエストは Chat Completions へ変換せず上流へ直接送信されます。", "credentialResponsesProxyTag": "Responses → Chat", "credentialRoleAsSystem": "developer メッセージを上流向けに正規化", - "credentialRoleAsSystemHelp": "先頭の developer メッセージは system、それ以降の developer メッセージは user として送信します。", - "credentialRoleAsSystemTag": "developer → system", + "credentialRoleAsSystemHelp": "すべての developer メッセージを user として送信します。", + "credentialRoleAsSystemTag": "developer → user", "credentialRoleKeepDeveloper": "developer を保持", "credentialSectionTitle": "保存済み認証情報", "modelCredential": "認証情報", diff --git a/messages/zh-CN.json b/messages/zh-CN.json index c17d137..aa4987c 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -229,8 +229,8 @@ "credentialResponsesDirectHelp": "启用后,使用此凭证的 `/v1/responses` 请求将直接发送到上游,不再转换为 Chat Completions。", "credentialResponsesProxyTag": "Responses → Chat", "credentialRoleAsSystem": "转换 developer 消息角色以兼容上游", - "credentialRoleAsSystemHelp": "首条 developer 消息作为 system 发送,其余 developer 消息作为 user 发送。", - "credentialRoleAsSystemTag": "developer → system", + "credentialRoleAsSystemHelp": "所有 developer 消息均作为 user 发送。", + "credentialRoleAsSystemTag": "developer → user", "credentialRoleKeepDeveloper": "保留 developer", "credentialSectionTitle": "已保存的凭证", "modelCredential": "凭据", diff --git a/tests/admin/page-loader.test.ts b/tests/admin/page-loader.test.ts index 1eea8ec..f3ce968 100644 --- a/tests/admin/page-loader.test.ts +++ b/tests/admin/page-loader.test.ts @@ -14,6 +14,7 @@ vi.mock('@/lib/server/domain/config', () => ({ })); vi.mock('@/lib/server/domain/credentials', () => ({ + getCredentialSupportedModels: vi.fn(), getCurrentCredentialInfo: vi.fn(), listEligibleCredentialRecords: vi.fn(), listCredentials: vi.fn(), @@ -42,6 +43,7 @@ const { listAccessKeys } = await import('@/lib/server/domain/access-keys'); const { getActiveConfig, getSettingLabels } = await import('@/lib/server/domain/config'); const { + getCredentialSupportedModels, getCurrentCredentialInfo, listEligibleCredentialRecords, listCredentials, @@ -81,6 +83,7 @@ describe('tab-scoped initial data', () => { vi.mocked(getCurrentCredentialInfo).mockResolvedValue({ status: 'empty' }); vi.mocked(listCredentials).mockResolvedValue({ credentials: [] } as never); vi.mocked(listEligibleCredentialRecords).mockResolvedValue([]); + vi.mocked(getCredentialSupportedModels).mockReturnValue([]); vi.mocked(getModelsForCredentials).mockResolvedValue([]); vi.mocked(getModelsByCredential).mockResolvedValue({}); vi.mocked(getDebugSettings).mockResolvedValue({ @@ -110,7 +113,6 @@ describe('tab-scoped initial data', () => { 'getCurrentCredentialInfo', 'listEligibleCredentialRecords', 'getModelsForCredentials', - 'getModelsByCredential', ], ], ['debug', ['getDebugSettings', 'listDebugLogs']], @@ -143,6 +145,28 @@ describe('tab-scoped initial data', () => { }, ); + it('hydrates API-test credential models from saved values', async () => { + vi.mocked(listEligibleCredentialRecords).mockResolvedValue([ + { + data: { supported_models: 'glm-5.1' }, + filePath: '', + filename: 'credential-a.json', + }, + ] as never); + vi.mocked(getCredentialSupportedModels).mockReturnValue(['glm-5.1']); + + const initialData = await getInitialData({ + locale: 'en-US', + tab: 'api-test', + }); + + expect(initialData).toMatchObject({ + credentialModels: { 'credential-a.json': ['glm-5.1'] }, + tab: 'api-test', + }); + expect(getModelsByCredential).not.toHaveBeenCalled(); + }); + it('restores persisted usage filters and refresh settings in the usage snapshot', async () => { vi.mocked(getUsageAnalytics).mockResolvedValue({ callSeries: [], diff --git a/tests/server/units.test.ts b/tests/server/units.test.ts index c477bf6..224c98d 100644 --- a/tests/server/units.test.ts +++ b/tests/server/units.test.ts @@ -57,7 +57,11 @@ import { resetResponseSessions, translateResponsesToolsToChat, } from '@/lib/server/proxy/responses'; -import { updateSettings, getActiveConfig } from '@/lib/server/domain/config'; +import { + getActiveConfig, + getDefaultModel, + updateSettings, +} from '@/lib/server/domain/config'; import { getRequestHeaderMap } from '@/lib/server/shared/http'; import { getUsageStats, resetUsageStats } from '@/lib/server/domain/stats'; import { @@ -492,6 +496,31 @@ describe('server units', () => { )?.filename, ).toBe(keyedCredential.filename); + const modelOneCredential = (await readCredentialRecords()).find( + (record) => record.data.user_id === 'one@example.com', + ); + const modelTwoCredential = (await readCredentialRecords()).find( + (record) => record.data.user_id === 'two@example.com', + ); + await updateCredentialSupportedModels(modelOneCredential?.filename ?? '', [ + 'glm-one', + ]); + await updateCredentialSupportedModels(modelTwoCredential?.filename ?? '', [ + 'glm-two', + ]); + expect(await getDefaultModel()).toBe('glm-one'); + expect( + ( + await resolveCredentialForRequest({ + allowedCredentialFilenames: [ + modelOneCredential?.filename ?? '', + modelTwoCredential?.filename ?? '', + ], + model: 'glm-two', + }) + )?.filename, + ).toBe(modelTwoCredential?.filename); + await recordUsageEvent({ credentialFilename: 'cred-a', model: 'glm-5.1', @@ -1510,7 +1539,7 @@ describe('server units', () => { expect(fourthBody.messages[0]?.content).toBe('short reply'); }); - it('normalizes developer messages for chat upstream based on position', async () => { + it('normalizes developer messages as user messages for chat upstream', async () => { await addCredential({ bearer_token: 'token-dev-role', created_at: Math.floor(Date.now() / 1000), @@ -1563,7 +1592,7 @@ describe('server units', () => { }; expect(upstreamBody.messages).toEqual([ - { role: 'system', content: 'first developer' }, + { role: 'user', content: 'first developer' }, { role: 'user', content: 'hello' }, { role: 'user', content: 'later developer' }, { role: 'system', content: 'existing system' }, @@ -1676,7 +1705,7 @@ describe('server units', () => { expect(firstBody.messages[0]?.role).toBe('developer'); expect(secondBody.messages[0]?.role).toBe('developer'); - expect(thirdBody.messages[0]?.role).toBe('system'); + expect(thirdBody.messages[0]?.role).toBe('user'); expect(fetchMock).toHaveBeenCalledTimes(3); }); @@ -2504,6 +2533,38 @@ describe('server units', () => { ]); }); + it('uses the selected credential model for Responses requests without a model', async () => { + const credential = await addCredential({ + bearer_token: 'credential-scoped-model-token', + responses_passthrough: true, + supported_models: 'glm-credential-scoped', + user_id: 'credential-scoped-model@example.com', + }); + const accessKey = await createAccessKey({ + credentialFilenames: [credential.filename], + name: 'Credential Scoped Responses Key', + }); + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(makeJsonResponse({ output_text: 'ok' })); + + const response = await handleResponsesRequest( + makeNextRequest('http://localhost/v1/responses', { + headers: { authorization: `Bearer ${accessKey.secret}` }, + method: 'POST', + }), + { input: 'hello' }, + ); + + expect(response.status).toBe(200); + expect( + JSON.parse(String((fetchMock.mock.calls[0]?.[1] as RequestInit).body)), + ).toMatchObject({ + input: 'hello', + model: 'glm-credential-scoped', + }); + }); + it('covers responses passthrough upstream non-ok and empty stream body branches', async () => { const createdCredential = await addCredential({ bearer_token: 'token-branches', @@ -3146,6 +3207,16 @@ describe('server units', () => { tenantId: 'tenant-456', }, }), + ) + // refreshCredentialModels after the credential is saved + .mockResolvedValueOnce( + makeJsonResponse({ + code: 0, + data: { + agents: [{ models: ['glm-5.1'], name: 'cli' }], + models: [{ id: 'glm-5.1', name: 'GLM 5.1' }], + }, + }), ); const startResult = (await (await startCodeBuddyAuth()).json()) as Record< @@ -3174,6 +3245,11 @@ describe('server units', () => { (credential) => credential.tenant_id === 'tenant-456', ); expect(savedCredential?.tenant_id).toBe('tenant-456'); + expect( + (await readCredentialRecords()).find( + (credential) => credential.filename === savedCredential?.filename, + )?.data.supported_models, + ).toBe('glm-5.1'); const credInfo = await getCurrentCredentialInfo(); expect(credInfo.status).toBe('round_robin');