diff --git a/src/app/api/v2/ai/agent/api-keys/route.test.ts b/src/app/api/v2/ai/agent/api-keys/route.test.ts index 8682aa1..b3fbaea 100644 --- a/src/app/api/v2/ai/agent/api-keys/route.test.ts +++ b/src/app/api/v2/ai/agent/api-keys/route.test.ts @@ -84,6 +84,7 @@ function makeRequest( describe('API /api/v2/ai/agent/api-keys', () => { const originalEnableAuth = process.env.ENABLE_AUTH; const originalLocalDevUserId = process.env.LOCAL_DEV_USER_ID; + const originalAnthropicKey = process.env.ANTHROPIC_API_KEY; const restoreEnv = () => { if (originalEnableAuth === undefined) { @@ -97,11 +98,18 @@ describe('API /api/v2/ai/agent/api-keys', () => { } else { process.env.LOCAL_DEV_USER_ID = originalLocalDevUserId; } + + if (originalAnthropicKey === undefined) { + delete process.env.ANTHROPIC_API_KEY; + } else { + process.env.ANTHROPIC_API_KEY = originalAnthropicKey; + } }; beforeEach(() => { jest.clearAllMocks(); restoreEnv(); + delete process.env.ANTHROPIC_API_KEY; }); afterAll(() => { @@ -147,6 +155,18 @@ describe('API /api/v2/ai/agent/api-keys', () => { expect(json.data.maskedKey).toBe('sk-ant...xyz9'); }); + it('returns shared key status when the provider env key is configured', async () => { + process.env.ANTHROPIC_API_KEY = 'shared-anthropic-key'; + mockGetMaskedKey.mockResolvedValue(null); + + const res = await GET(makeRequest(undefined, { sub: 'user-1' })); + + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.data.hasKey).toBe(true); + expect(json.data.maskedKey).toBeUndefined(); + }); + it('returns 400 when provider is invalid', async () => { const res = await GET(makeRequest(undefined, { sub: 'user-1' }, { provider: 'sample' })); expect(res.status).toBe(400); diff --git a/src/app/api/v2/ai/agent/api-keys/route.ts b/src/app/api/v2/ai/agent/api-keys/route.ts index 7c3f9bf..ee6ff09 100644 --- a/src/app/api/v2/ai/agent/api-keys/route.ts +++ b/src/app/api/v2/ai/agent/api-keys/route.ts @@ -20,6 +20,7 @@ import { successResponse, errorResponse } from 'server/lib/response'; import { getRequestUserIdentity } from 'server/lib/get-user'; import AgentRuntimeConfigService from 'server/services/agentRuntime/config/agentRuntimeConfig'; import UserApiKeyService from 'server/services/userApiKey'; +import AgentProviderRegistry from 'server/services/agent/ProviderRegistry'; import { STORED_AGENT_PROVIDER_NAMES, normalizeStoredAgentProviderName, @@ -118,6 +119,27 @@ async function buildProviderState( }; } +async function buildProviderStateWithSharedFallback( + userId: string, + ownerGithubUsername: string | null | undefined, + provider: SupportedProvider +): Promise { + const userState = await buildProviderState(userId, ownerGithubUsername, provider); + if (userState.hasKey) { + return userState; + } + + const sharedKey = await AgentProviderRegistry.getSharedProviderApiKey({ provider }); + if (!sharedKey) { + return userState; + } + + return { + provider, + hasKey: true, + }; +} + /** * @openapi * /api/v2/ai/agent/api-keys: @@ -260,7 +282,9 @@ const getHandler = async (req: NextRequest) => { const configuredProviders = await getConfiguredProviders(); const providers = requestedProvider ? [requestedProvider] : configuredProviders; const states = await Promise.all( - providers.map((provider) => buildProviderState(userIdentity.userId, userIdentity.githubUsername, provider)) + providers.map((provider) => + buildProviderStateWithSharedFallback(userIdentity.userId, userIdentity.githubUsername, provider) + ) ); const primaryState = states[0] || { provider: configuredProviders[0], diff --git a/src/server/jobs/__tests__/agentSessionCleanup.test.ts b/src/server/jobs/__tests__/agentSessionCleanup.test.ts index f7eb957..003fe42 100644 --- a/src/server/jobs/__tests__/agentSessionCleanup.test.ts +++ b/src/server/jobs/__tests__/agentSessionCleanup.test.ts @@ -15,6 +15,7 @@ */ jest.mock('server/models/AgentSession'); +jest.mock('server/models/AgentRun'); jest.mock('server/services/agentSession', () => { class ActiveAgentRunSuspensionError extends Error { constructor() { @@ -26,6 +27,7 @@ jest.mock('server/services/agentSession', () => { return { __esModule: true, ActiveAgentRunSuspensionError, + AGENT_RUN_TERMINAL_STATUSES: ['completed', 'failed', 'cancelled'], default: { endSession: jest.fn(), suspendChatRuntime: jest.fn(), @@ -54,6 +56,7 @@ jest.mock('server/lib/agentSession/runtimeConfig', () => { }); import AgentSession from 'server/models/AgentSession'; +import AgentRun from 'server/models/AgentRun'; import AgentSessionService, { ActiveAgentRunSuspensionError } from 'server/services/agentSession'; import { getLogger } from 'server/lib/logger'; import { processAgentSessionCleanup } from '../agentSessionCleanup'; @@ -67,6 +70,13 @@ describe('agentSessionCleanup', () => { beforeEach(() => { jest.clearAllMocks(); (getLogger as jest.Mock).mockReturnValue(mockLogger); + (AgentRun.query as jest.Mock).mockReturnValue({ + where: jest.fn().mockReturnValue({ + whereNotIn: jest.fn().mockReturnValue({ + first: jest.fn().mockResolvedValue(null), + }), + }), + }); jest.useFakeTimers().setSystemTime(new Date('2026-03-23T12:00:00.000Z')); }); @@ -143,6 +153,9 @@ describe('agentSessionCleanup', () => { sessionKind: 'chat', workspaceStatus: 'ready', status: 'active', + namespace: 'sample-namespace', + podName: 'sample-pod', + pvcName: 'sample-pvc', lastActivity: '2026-03-23T11:00:00.000Z', updatedAt: '2026-03-23T11:00:00.000Z', }, @@ -189,6 +202,261 @@ describe('agentSessionCleanup', () => { expect(AgentSessionService.endSession).not.toHaveBeenCalled(); }); + it('ends idle chat sessions when no ready runtime can be suspended', async () => { + const activeSessions = [ + { + id: 1, + uuid: 'freeform-chat-session', + userId: 'sample-user', + sessionKind: 'chat', + workspaceStatus: 'none', + status: 'active', + namespace: null, + pvcName: null, + lastActivity: '2026-03-23T11:00:00.000Z', + updatedAt: '2026-03-23T11:00:00.000Z', + }, + { + id: 2, + uuid: 'failed-chat-session', + userId: 'sample-user', + sessionKind: 'chat', + workspaceStatus: 'failed', + status: 'active', + namespace: 'sample-namespace', + pvcName: 'sample-pvc', + lastActivity: '2026-03-23T11:00:00.000Z', + updatedAt: '2026-03-23T11:00:00.000Z', + }, + { + id: 3, + uuid: 'missing-pod-chat-session', + userId: 'sample-user', + sessionKind: 'chat', + workspaceStatus: 'ready', + status: 'active', + namespace: 'sample-namespace', + podName: null, + pvcName: 'sample-pvc', + lastActivity: '2026-03-23T11:00:00.000Z', + updatedAt: '2026-03-23T11:00:00.000Z', + }, + ]; + + const activeQuery = { where: jest.fn() }; + activeQuery.where + .mockImplementationOnce(() => activeQuery) + .mockImplementationOnce(() => activeQuery) + .mockImplementationOnce((callback) => { + callback({ + whereNot: jest.fn().mockReturnValue({ + orWhereNot: jest.fn(), + }), + }); + return Promise.resolve(activeSessions); + }); + + const emptyTwoWhereQuery = { where: jest.fn() }; + emptyTwoWhereQuery.where + .mockImplementationOnce(() => emptyTwoWhereQuery) + .mockImplementationOnce(() => Promise.resolve([])); + + const emptyFourWhereQuery = { where: jest.fn() }; + emptyFourWhereQuery.where + .mockImplementationOnce(() => emptyFourWhereQuery) + .mockImplementationOnce(() => emptyFourWhereQuery) + .mockImplementationOnce(() => emptyFourWhereQuery) + .mockImplementationOnce(() => Promise.resolve([])); + + (AgentSession.query as jest.Mock) = jest + .fn() + .mockReturnValueOnce(activeQuery) + .mockReturnValueOnce(emptyTwoWhereQuery) + .mockReturnValueOnce(emptyFourWhereQuery); + (AgentSessionService.endSession as jest.Mock).mockResolvedValue(undefined); + + await processAgentSessionCleanup(); + + expect(AgentSessionService.suspendChatRuntime).not.toHaveBeenCalled(); + expect(AgentSessionService.endSession).toHaveBeenCalledTimes(3); + expect(AgentSessionService.endSession).toHaveBeenNthCalledWith(1, 'freeform-chat-session'); + expect(AgentSessionService.endSession).toHaveBeenNthCalledWith(2, 'failed-chat-session'); + expect(AgentSessionService.endSession).toHaveBeenNthCalledWith(3, 'missing-pod-chat-session'); + }); + + it('does not end a non-ready idle chat session while a run is active', async () => { + const activeSessions = [ + { + id: 1, + uuid: 'freeform-chat-session', + userId: 'sample-user', + sessionKind: 'chat', + workspaceStatus: 'none', + status: 'active', + namespace: null, + pvcName: null, + lastActivity: '2026-03-23T11:00:00.000Z', + updatedAt: '2026-03-23T11:00:00.000Z', + }, + ]; + + const activeQuery = { where: jest.fn() }; + activeQuery.where + .mockImplementationOnce(() => activeQuery) + .mockImplementationOnce(() => activeQuery) + .mockImplementationOnce((callback) => { + callback({ + whereNot: jest.fn().mockReturnValue({ + orWhereNot: jest.fn(), + }), + }); + return Promise.resolve(activeSessions); + }); + + const emptyTwoWhereQuery = { where: jest.fn() }; + emptyTwoWhereQuery.where + .mockImplementationOnce(() => emptyTwoWhereQuery) + .mockImplementationOnce(() => Promise.resolve([])); + + const emptyFourWhereQuery = { where: jest.fn() }; + emptyFourWhereQuery.where + .mockImplementationOnce(() => emptyFourWhereQuery) + .mockImplementationOnce(() => emptyFourWhereQuery) + .mockImplementationOnce(() => emptyFourWhereQuery) + .mockImplementationOnce(() => Promise.resolve([])); + + (AgentSession.query as jest.Mock) = jest + .fn() + .mockReturnValueOnce(activeQuery) + .mockReturnValueOnce(emptyTwoWhereQuery) + .mockReturnValueOnce(emptyFourWhereQuery); + (AgentRun.query as jest.Mock).mockReturnValue({ + where: jest.fn().mockReturnValue({ + whereNotIn: jest.fn().mockReturnValue({ + first: jest.fn().mockResolvedValue({ id: 123 }), + }), + }), + }); + + await processAgentSessionCleanup(); + + expect(AgentSessionService.suspendChatRuntime).not.toHaveBeenCalled(); + expect(AgentSessionService.endSession).not.toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith( + 'Session: cleanup skipped sessionId=freeform-chat-session reason=active_run' + ); + }); + + it('does not end an idle chat session while runtime provisioning is still fresh', async () => { + const activeSessions = [ + { + id: 1, + uuid: 'provisioning-chat-session', + userId: 'sample-user', + sessionKind: 'chat', + workspaceStatus: 'provisioning', + status: 'active', + namespace: 'sample-namespace', + pvcName: 'sample-pvc', + lastActivity: '2026-03-23T11:00:00.000Z', + updatedAt: '2026-03-23T11:50:00.000Z', + }, + ]; + + const activeQuery = { where: jest.fn() }; + activeQuery.where + .mockImplementationOnce(() => activeQuery) + .mockImplementationOnce(() => activeQuery) + .mockImplementationOnce((callback) => { + callback({ + whereNot: jest.fn().mockReturnValue({ + orWhereNot: jest.fn(), + }), + }); + return Promise.resolve(activeSessions); + }); + + const emptyTwoWhereQuery = { where: jest.fn() }; + emptyTwoWhereQuery.where + .mockImplementationOnce(() => emptyTwoWhereQuery) + .mockImplementationOnce(() => Promise.resolve([])); + + const emptyFourWhereQuery = { where: jest.fn() }; + emptyFourWhereQuery.where + .mockImplementationOnce(() => emptyFourWhereQuery) + .mockImplementationOnce(() => emptyFourWhereQuery) + .mockImplementationOnce(() => emptyFourWhereQuery) + .mockImplementationOnce(() => Promise.resolve([])); + + (AgentSession.query as jest.Mock) = jest + .fn() + .mockReturnValueOnce(activeQuery) + .mockReturnValueOnce(emptyTwoWhereQuery) + .mockReturnValueOnce(emptyFourWhereQuery); + + await processAgentSessionCleanup(); + + expect(AgentSessionService.suspendChatRuntime).not.toHaveBeenCalled(); + expect(AgentSessionService.endSession).not.toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith( + 'Session: cleanup skipped sessionId=provisioning-chat-session reason=runtime_provisioning' + ); + }); + + it('ends an idle chat session when runtime provisioning is stale', async () => { + const activeSessions = [ + { + id: 1, + uuid: 'stale-provisioning-chat-session', + userId: 'sample-user', + sessionKind: 'chat', + workspaceStatus: 'provisioning', + status: 'active', + namespace: 'sample-namespace', + pvcName: 'sample-pvc', + lastActivity: '2026-03-23T11:00:00.000Z', + updatedAt: '2026-03-23T11:40:00.000Z', + }, + ]; + + const activeQuery = { where: jest.fn() }; + activeQuery.where + .mockImplementationOnce(() => activeQuery) + .mockImplementationOnce(() => activeQuery) + .mockImplementationOnce((callback) => { + callback({ + whereNot: jest.fn().mockReturnValue({ + orWhereNot: jest.fn(), + }), + }); + return Promise.resolve(activeSessions); + }); + + const emptyTwoWhereQuery = { where: jest.fn() }; + emptyTwoWhereQuery.where + .mockImplementationOnce(() => emptyTwoWhereQuery) + .mockImplementationOnce(() => Promise.resolve([])); + + const emptyFourWhereQuery = { where: jest.fn() }; + emptyFourWhereQuery.where + .mockImplementationOnce(() => emptyFourWhereQuery) + .mockImplementationOnce(() => emptyFourWhereQuery) + .mockImplementationOnce(() => emptyFourWhereQuery) + .mockImplementationOnce(() => Promise.resolve([])); + + (AgentSession.query as jest.Mock) = jest + .fn() + .mockReturnValueOnce(activeQuery) + .mockReturnValueOnce(emptyTwoWhereQuery) + .mockReturnValueOnce(emptyFourWhereQuery); + (AgentSessionService.endSession as jest.Mock).mockResolvedValue(undefined); + + await processAgentSessionCleanup(); + + expect(AgentSessionService.suspendChatRuntime).not.toHaveBeenCalled(); + expect(AgentSessionService.endSession).toHaveBeenCalledWith('stale-provisioning-chat-session'); + }); + it('skips idle chat suspension when a run is still active', async () => { const activeSessions = [ { @@ -198,6 +466,9 @@ describe('agentSessionCleanup', () => { sessionKind: 'chat', workspaceStatus: 'ready', status: 'active', + namespace: 'sample-namespace', + podName: 'sample-pod', + pvcName: 'sample-pvc', lastActivity: '2026-03-23T11:00:00.000Z', updatedAt: '2026-03-23T11:00:00.000Z', }, diff --git a/src/server/jobs/agentSessionCleanup.ts b/src/server/jobs/agentSessionCleanup.ts index 73d8ad0..571879f 100644 --- a/src/server/jobs/agentSessionCleanup.ts +++ b/src/server/jobs/agentSessionCleanup.ts @@ -15,7 +15,11 @@ */ import AgentSession from 'server/models/AgentSession'; -import AgentSessionService, { ActiveAgentRunSuspensionError } from 'server/services/agentSession'; +import AgentRun from 'server/models/AgentRun'; +import AgentSessionService, { + ActiveAgentRunSuspensionError, + AGENT_RUN_TERMINAL_STATUSES, +} from 'server/services/agentSession'; import { getLogger } from 'server/lib/logger'; import { AgentSessionKind, AgentWorkspaceStatus } from 'shared/constants'; import { resolveAgentSessionCleanupConfig } from 'server/lib/agentSession/runtimeConfig'; @@ -48,11 +52,24 @@ export async function processAgentSessionCleanup(): Promise { for (const session of staleSessions) { const sessionId = session.uuid || String(session.id); try { - if ( + const isProvisioningChatRuntime = session.status === 'active' && session.sessionKind === AgentSessionKind.CHAT && - session.workspaceStatus !== AgentWorkspaceStatus.HIBERNATED - ) { + session.workspaceStatus === AgentWorkspaceStatus.PROVISIONING; + if (isProvisioningChatRuntime && new Date(session.updatedAt).getTime() >= startingCutoff.getTime()) { + logger().info(`Session: cleanup skipped sessionId=${sessionId} reason=runtime_provisioning`); + continue; + } + + const canSuspendChatRuntime = + session.status === 'active' && + session.sessionKind === AgentSessionKind.CHAT && + session.workspaceStatus === AgentWorkspaceStatus.READY && + Boolean(session.namespace) && + Boolean(session.podName) && + Boolean(session.pvcName); + + if (canSuspendChatRuntime) { logger().info(`Session: cleanup suspending sessionId=${sessionId} lastActivity=${session.lastActivity}`); await AgentSessionService.suspendChatRuntime({ sessionId, @@ -61,6 +78,17 @@ export async function processAgentSessionCleanup(): Promise { continue; } + if (session.status === 'active' && session.sessionKind === AgentSessionKind.CHAT) { + const activeRun = await AgentRun.query() + .where({ sessionId: session.id }) + .whereNotIn('status', AGENT_RUN_TERMINAL_STATUSES) + .first(); + if (activeRun) { + logger().info(`Session: cleanup skipped sessionId=${sessionId} reason=active_run`); + continue; + } + } + logger().info( `Session: cleanup starting sessionId=${sessionId} status=${session.status} lastActivity=${session.lastActivity}` ); diff --git a/src/server/services/__tests__/agentSession.test.ts b/src/server/services/__tests__/agentSession.test.ts index d680e67..1421895 100644 --- a/src/server/services/__tests__/agentSession.test.ts +++ b/src/server/services/__tests__/agentSession.test.ts @@ -398,6 +398,8 @@ const baseOpts: CreateSessionOptions = { }; describe('AgentSessionService', () => { + const originalAnthropicKey = process.env.ANTHROPIC_API_KEY; + describe('buildAgentSessionPodName', () => { it('keeps the legacy short form when no build UUID is provided', () => { expect(buildAgentSessionPodName('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')).toBe('agent-aaaaaaaa'); @@ -422,6 +424,7 @@ describe('AgentSessionService', () => { beforeEach(() => { jest.clearAllMocks(); + delete process.env.ANTHROPIC_API_KEY; (AgentSession.query as jest.Mock) = jest.fn().mockReturnValue(mockSessionQuery); (AgentSession.transaction as jest.Mock) = jest.fn(async (callback) => callback({ trx: true })); (AgentThread.query as jest.Mock) = jest.fn().mockReturnValue(mockThreadQuery); @@ -640,6 +643,14 @@ describe('AgentSessionService', () => { ); }); + afterAll(() => { + if (originalAnthropicKey === undefined) { + delete process.env.ANTHROPIC_API_KEY; + } else { + process.env.ANTHROPIC_API_KEY = originalAnthropicKey; + } + }); + it('creates a chat session without provisioning runtime resources', async () => { mockSessionQuery.insertAndFetch.mockResolvedValueOnce({ id: 321, @@ -1122,11 +1133,11 @@ describe('AgentSessionService', () => { ); }); - it('requires a stored provider API key to launch the session workspace', async () => { + it('requires a provider API key to launch the session workspace', async () => { (UserApiKeyService.getDecryptedKey as jest.Mock).mockResolvedValue(null); await expect(AgentSessionService.createSession(baseOpts)).rejects.toThrow( - 'No stored API key is configured for provider "anthropic"' + 'No API key is configured for provider "anthropic"' ); expect(createAgentPvc).not.toHaveBeenCalled(); expect(createSessionWorkspacePod).not.toHaveBeenCalled(); diff --git a/src/server/services/agent/ChatSessionService.ts b/src/server/services/agent/ChatSessionService.ts index ee115ff..35b7bff 100644 --- a/src/server/services/agent/ChatSessionService.ts +++ b/src/server/services/agent/ChatSessionService.ts @@ -90,9 +90,10 @@ export default class AgentChatSessionService { requestedProvider, requestedModelId, }); - await AgentProviderRegistry.getRequiredStoredApiKey({ + await AgentProviderRegistry.getRequiredProviderApiKey({ provider: selection.provider, userIdentity: providerUserIdentity, + repoFullName: primaryWorkspaceRepo?.repo, }); let validatedRuntimeControlChoices: ValidatedEntryRuntimeControlChoices | null = null; if (opts.runtimeControlChoices) { diff --git a/src/server/services/agent/ProviderRegistry.ts b/src/server/services/agent/ProviderRegistry.ts index 86232b9..c745986 100644 --- a/src/server/services/agent/ProviderRegistry.ts +++ b/src/server/services/agent/ProviderRegistry.ts @@ -41,7 +41,7 @@ export class MissingAgentProviderApiKeyError extends Error { constructor(provider: string) { super( - `No stored API key is configured for provider "${provider}". Save your ${provider} API key in Agent Session settings before starting a session or run.` + `No API key is configured for provider "${provider}". Save your ${provider} API key in Agent Session settings or configure a shared Agent provider key.` ); this.name = 'MissingAgentProviderApiKeyError'; this.provider = provider; @@ -69,6 +69,31 @@ function getProviderInstance(provider: AgentResolvedModelSelection['provider'], } } +function readSharedProviderApiKey(providerName: string, explicitEnvVar?: string): string | null { + for (const envVar of getProviderEnvVarCandidates(providerName, explicitEnvVar)) { + const apiKey = process.env[envVar]?.trim(); + if (apiKey) { + return apiKey; + } + } + + return null; +} + +function findProviderConfig(providerConfigs: ProviderConfig[], providerName: string): ProviderConfig | null { + const targetProvider = normalizeStoredAgentProviderName(providerName) || providerName; + return ( + providerConfigs.find((provider) => { + if (provider?.enabled === false || typeof provider.name !== 'string') { + return false; + } + + const normalized = normalizeStoredAgentProviderName(provider.name) || provider.name; + return normalized === targetProvider; + }) || null + ); +} + export function resolveRequestedModelSelection( models: AgentModelSummary[], requestedProvider?: string, @@ -137,6 +162,60 @@ export default class AgentProviderRegistry { return getProviderEnvVarCandidates(providerName, explicitEnvVar); } + private static async getEnabledProviderConfig( + repoFullName: string | undefined, + providerName: string + ): Promise { + const config = await AgentRuntimeConfigService.getInstance().getEffectiveConfig(repoFullName); + const providerConfigs = Array.isArray(config.providers) ? (config.providers as ProviderConfig[]) : []; + return findProviderConfig(providerConfigs, providerName); + } + + static async getSharedProviderApiKey({ + repoFullName, + provider, + }: { + repoFullName?: string; + provider: string; + }): Promise { + const providerConfig = await this.getEnabledProviderConfig(repoFullName, provider).catch((error) => { + getLogger().warn( + { error, repoFullName, provider }, + `AgentExec: shared provider credential lookup skipped provider=${provider} repo=${repoFullName || 'none'}` + ); + return null; + }); + + return readSharedProviderApiKey(providerConfig?.name || provider, providerConfig?.apiKeyEnvVar); + } + + static async getProviderApiKey({ + repoFullName, + provider, + userIdentity, + apiKeyEnvVar, + }: { + repoFullName?: string; + provider: string; + userIdentity: Pick; + apiKeyEnvVar?: string; + }): Promise { + const userApiKey = await UserApiKeyService.getDecryptedKey( + userIdentity.userId, + provider, + userIdentity.githubUsername + ); + if (userApiKey) { + return userApiKey; + } + + const sharedApiKey = + apiKeyEnvVar === undefined + ? await this.getSharedProviderApiKey({ repoFullName, provider }) + : readSharedProviderApiKey(provider, apiKeyEnvVar); + return sharedApiKey; + } + static async listAvailableModels(repoFullName?: string): Promise { const config = await AgentRuntimeConfigService.getInstance().getEffectiveConfig(repoFullName); return transformProviderModels(config.providers || []).flatMap((model) => { @@ -167,11 +246,11 @@ export default class AgentProviderRegistry { const configuredProviders = new Set(); await Promise.all( uniqueProviders.map(async (provider) => { - const apiKey = await UserApiKeyService.getDecryptedKey( - userIdentity.userId, + const apiKey = await this.getProviderApiKey({ + repoFullName, provider, - userIdentity.githubUsername - ); + userIdentity, + }); if (apiKey) { configuredProviders.add(provider); @@ -208,11 +287,12 @@ export default class AgentProviderRegistry { .filter((provider) => provider?.enabled !== false && typeof provider.name === 'string') .map(async (provider) => { const envVarCandidates = this.getProviderEnvVarCandidates(provider.name, provider.apiKeyEnvVar); - const apiKey = await UserApiKeyService.getDecryptedKey( - userIdentity.userId, - provider.name, - userIdentity.githubUsername - ); + const apiKey = await this.getProviderApiKey({ + repoFullName, + provider: provider.name, + userIdentity, + apiKeyEnvVar: provider.apiKeyEnvVar, + }); if (!apiKey) { return; @@ -240,14 +320,20 @@ export default class AgentProviderRegistry { return resolveRequestedModelSelection(models, requestedProvider, requestedModelId); } - static async getRequiredStoredApiKey({ + static async getRequiredProviderApiKey({ provider, userIdentity, + repoFullName, }: { provider: string; userIdentity: Pick; + repoFullName?: string; }): Promise { - const apiKey = await UserApiKeyService.getDecryptedKey(userIdentity.userId, provider, userIdentity.githubUsername); + const apiKey = await this.getProviderApiKey({ + repoFullName, + provider, + userIdentity, + }); if (!apiKey) { throw new MissingAgentProviderApiKeyError(provider); @@ -257,6 +343,7 @@ export default class AgentProviderRegistry { } static async createLanguageModel({ + repoFullName, selection, userIdentity, }: { @@ -264,9 +351,10 @@ export default class AgentProviderRegistry { selection: AgentResolvedModelSelection; userIdentity: RequestUserIdentity; }): Promise { - const apiKey = await this.getRequiredStoredApiKey({ + const apiKey = await this.getRequiredProviderApiKey({ provider: selection.provider, userIdentity, + repoFullName, }); const provider = getProviderInstance(selection.provider, apiKey); diff --git a/src/server/services/agent/SettingsService.ts b/src/server/services/agent/SettingsService.ts index fcb3d97..14e8566 100644 --- a/src/server/services/agent/SettingsService.ts +++ b/src/server/services/agent/SettingsService.ts @@ -20,6 +20,7 @@ import UserApiKeyService from 'server/services/userApiKey'; import { McpConfigService } from 'server/services/agentRuntime/mcp/config'; import type { AgentMcpConnection } from 'server/services/agentRuntime/mcp/types'; import { normalizeStoredAgentProviderName, type StoredAgentProviderName } from './providerConfig'; +import AgentProviderRegistry from './ProviderRegistry'; export type AgentProviderCredentialState = { provider: StoredAgentProviderName; @@ -60,17 +61,27 @@ export default class AgentSettingsService { providers.map(async (provider) => { const masked = await UserApiKeyService.getMaskedKey(userIdentity.userId, provider, userIdentity.githubUsername); - return masked - ? { - provider, - hasKey: true, - maskedKey: masked.maskedKey, - updatedAt: masked.updatedAt, - } - : { - provider, - hasKey: false, - }; + if (masked) { + return { + provider, + hasKey: true, + maskedKey: masked.maskedKey, + updatedAt: masked.updatedAt, + }; + } + + const sharedKey = await AgentProviderRegistry.getSharedProviderApiKey({ repoFullName, provider }); + if (sharedKey) { + return { + provider, + hasKey: true, + }; + } + + return { + provider, + hasKey: false, + }; }) ); } diff --git a/src/server/services/agent/__tests__/BuildContextChatService.test.ts b/src/server/services/agent/__tests__/BuildContextChatService.test.ts index 35b52e3..5020fc9 100644 --- a/src/server/services/agent/__tests__/BuildContextChatService.test.ts +++ b/src/server/services/agent/__tests__/BuildContextChatService.test.ts @@ -22,7 +22,7 @@ const mockAgentSessionTransaction = jest.fn(); const mockAgentThreadQuery = jest.fn(); const mockAgentSourceQuery = jest.fn(); const mockResolveSelection = jest.fn(); -const mockGetRequiredStoredApiKey = jest.fn(); +const mockGetRequiredProviderApiKey = jest.fn(); const mockLoggerInfo = jest.fn(); jest.mock('server/models/Build', () => ({ @@ -58,7 +58,7 @@ jest.mock('../ProviderRegistry', () => ({ __esModule: true, default: { resolveSelection: (...args: unknown[]) => mockResolveSelection(...args), - getRequiredStoredApiKey: (...args: unknown[]) => mockGetRequiredStoredApiKey(...args), + getRequiredProviderApiKey: (...args: unknown[]) => mockGetRequiredProviderApiKey(...args), }, })); @@ -291,7 +291,7 @@ describe('BuildContextChatService', () => { provider: 'gemini', modelId: 'gemini-3-flash-preview', }); - mockGetRequiredStoredApiKey.mockResolvedValue('sample-api-key'); + mockGetRequiredProviderApiKey.mockResolvedValue('sample-api-key'); }); afterEach(() => { @@ -326,12 +326,13 @@ describe('BuildContextChatService', () => { requestedProvider: undefined, requestedModelId: 'gemini-3-flash-preview', }); - expect(mockGetRequiredStoredApiKey).toHaveBeenCalledWith({ + expect(mockGetRequiredProviderApiKey).toHaveBeenCalledWith({ provider: 'gemini', userIdentity: { userId: 'sample-user', githubUsername: 'sample-user', }, + repoFullName: 'example-org/example-repo', }); expect(arranged.sessionInsertAndFetch).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/server/services/agent/__tests__/ChatSessionService.test.ts b/src/server/services/agent/__tests__/ChatSessionService.test.ts index 8ca65be..9573187 100644 --- a/src/server/services/agent/__tests__/ChatSessionService.test.ts +++ b/src/server/services/agent/__tests__/ChatSessionService.test.ts @@ -19,7 +19,7 @@ const mockAgentSessionTransaction = jest.fn(); const mockAgentThreadQuery = jest.fn(); const mockCreateSessionSource = jest.fn(); const mockResolveSelection = jest.fn(); -const mockGetRequiredStoredApiKey = jest.fn(); +const mockGetRequiredProviderApiKey = jest.fn(); const mockValidateEntryChoices = jest.fn(); const mockLoggerInfo = jest.fn(); @@ -42,7 +42,7 @@ jest.mock('../ProviderRegistry', () => ({ __esModule: true, default: { resolveSelection: (...args: unknown[]) => mockResolveSelection(...args), - getRequiredStoredApiKey: (...args: unknown[]) => mockGetRequiredStoredApiKey(...args), + getRequiredProviderApiKey: (...args: unknown[]) => mockGetRequiredProviderApiKey(...args), }, })); @@ -129,7 +129,7 @@ function arrangePersistence() { describe('AgentChatSessionService.createChatSession', () => { beforeEach(() => { jest.clearAllMocks(); - mockGetRequiredStoredApiKey.mockResolvedValue('sample-key'); + mockGetRequiredProviderApiKey.mockResolvedValue('sample-key'); mockValidateEntryChoices.mockResolvedValue(null); }); @@ -151,12 +151,13 @@ describe('AgentChatSessionService.createChatSession', () => { requestedProvider: 'sample-provider', requestedModelId: 'sample-model', }); - expect(mockGetRequiredStoredApiKey).toHaveBeenCalledWith({ + expect(mockGetRequiredProviderApiKey).toHaveBeenCalledWith({ provider: 'sample-provider', userIdentity: { userId: 'sample-user', githubUsername: null, }, + repoFullName: undefined, }); expect(persistence.sessionInsertAndFetch).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/server/services/agent/__tests__/ProviderRegistry.test.ts b/src/server/services/agent/__tests__/ProviderRegistry.test.ts index b9a205e..32d6b7b 100644 --- a/src/server/services/agent/__tests__/ProviderRegistry.test.ts +++ b/src/server/services/agent/__tests__/ProviderRegistry.test.ts @@ -89,6 +89,7 @@ describe('resolveRequestedModelSelection', () => { describe('AgentProviderRegistry credential resolution', () => { const originalAnthropicKey = process.env.ANTHROPIC_API_KEY; + const originalGeminiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY; beforeEach(() => { jest.clearAllMocks(); @@ -109,7 +110,8 @@ describe('AgentProviderRegistry credential resolution', () => { ], }); (UserApiKeyService.getDecryptedKey as jest.Mock).mockResolvedValue(null); - process.env.ANTHROPIC_API_KEY = 'env-anthropic-key-that-must-be-ignored'; + delete process.env.ANTHROPIC_API_KEY; + delete process.env.GOOGLE_GENERATIVE_AI_API_KEY; }); afterAll(() => { @@ -118,9 +120,15 @@ describe('AgentProviderRegistry credential resolution', () => { } else { process.env.ANTHROPIC_API_KEY = originalAnthropicKey; } + + if (originalGeminiKey === undefined) { + delete process.env.GOOGLE_GENERATIVE_AI_API_KEY; + } else { + process.env.GOOGLE_GENERATIVE_AI_API_KEY = originalGeminiKey; + } }); - it('uses stored user keys and ignores process env fallback', async () => { + it('uses stored user keys for provider credential env maps', async () => { (UserApiKeyService.getDecryptedKey as jest.Mock).mockImplementation(async (_userId: string, provider: string) => { if (provider === 'gemini') { return 'user-gemini-key'; @@ -142,6 +150,22 @@ describe('AgentProviderRegistry credential resolution', () => { }); }); + it('uses shared provider env keys when no user key is stored', async () => { + process.env.ANTHROPIC_API_KEY = 'shared-anthropic-key'; + + await expect( + AgentProviderRegistry.resolveCredentialEnvMap({ + repoFullName: 'example-org/example-repo', + userIdentity: { + userId: 'sample-user', + githubUsername: 'sample-user', + }, + }) + ).resolves.toEqual({ + ANTHROPIC_API_KEY: 'shared-anthropic-key', + }); + }); + it('normalizes google provider configs to gemini for AI SDK sessions', async () => { mockGetEffectiveConfig.mockResolvedValueOnce({ providers: [ @@ -205,9 +229,24 @@ describe('AgentProviderRegistry credential resolution', () => { }); }); - it('throws when the requested provider has no stored user key', async () => { + it('uses the shared provider env key when the requested provider has no stored user key', async () => { + process.env.ANTHROPIC_API_KEY = 'shared-anthropic-key'; + + await expect( + AgentProviderRegistry.getRequiredProviderApiKey({ + provider: 'anthropic', + userIdentity: { + userId: 'sample-user', + githubUsername: 'sample-user', + }, + repoFullName: 'example-org/example-repo', + }) + ).resolves.toBe('shared-anthropic-key'); + }); + + it('throws when the requested provider has no user or shared key', async () => { await expect( - AgentProviderRegistry.getRequiredStoredApiKey({ + AgentProviderRegistry.getRequiredProviderApiKey({ provider: 'anthropic', userIdentity: { userId: 'sample-user', @@ -272,4 +311,44 @@ describe('AgentProviderRegistry credential resolution', () => { }, ]); }); + + it('lists models backed by shared provider env keys', async () => { + process.env.ANTHROPIC_API_KEY = 'shared-anthropic-key'; + mockGetEffectiveConfig.mockResolvedValueOnce({ + providers: [ + { + name: 'anthropic', + enabled: true, + apiKeyEnvVar: 'ANTHROPIC_API_KEY', + models: [ + { + id: 'claude-sonnet-4-5', + displayName: 'Claude Sonnet 4.5', + enabled: true, + default: true, + maxTokens: 8192, + }, + ], + }, + ], + }); + + await expect( + AgentProviderRegistry.listAvailableModelsForUser({ + repoFullName: 'example-org/example-repo', + userIdentity: { + userId: 'sample-user', + githubUsername: 'sample-user', + }, + }) + ).resolves.toEqual([ + { + provider: 'anthropic', + modelId: 'claude-sonnet-4-5', + displayName: 'Claude Sonnet 4.5', + default: true, + maxTokens: 8192, + }, + ]); + }); }); diff --git a/src/server/services/agent/__tests__/SettingsService.test.ts b/src/server/services/agent/__tests__/SettingsService.test.ts index 3c4553f..71253c8 100644 --- a/src/server/services/agent/__tests__/SettingsService.test.ts +++ b/src/server/services/agent/__tests__/SettingsService.test.ts @@ -50,8 +50,11 @@ jest.mock('server/services/agentRuntime/mcp/config', () => ({ import AgentSettingsService from 'server/services/agent/SettingsService'; describe('AgentSettingsService', () => { + const originalOpenAiKey = process.env.OPENAI_API_KEY; + beforeEach(() => { jest.clearAllMocks(); + delete process.env.OPENAI_API_KEY; mockListEnabledConnectionsForUser.mockResolvedValue([ { slug: 'sample-connector', @@ -81,6 +84,14 @@ describe('AgentSettingsService', () => { ]); }); + afterAll(() => { + if (originalOpenAiKey === undefined) { + delete process.env.OPENAI_API_KEY; + } else { + process.env.OPENAI_API_KEY = originalOpenAiKey; + } + }); + it('returns provider states and per-user MCP connection state for the current user', async () => { const result = await AgentSettingsService.getSettingsSnapshot( { @@ -119,4 +130,36 @@ describe('AgentSettingsService', () => { ], }); }); + + it('marks providers backed by shared env keys as connected', async () => { + process.env.OPENAI_API_KEY = 'shared-openai-key'; + + const result = await AgentSettingsService.getProviderCredentialStates( + { + userId: 'sample-user', + githubUsername: 'sample-user', + preferredUsername: 'sample-user', + email: 'sample-user@example.com', + firstName: 'Sample', + lastName: 'User', + displayName: 'Sample User', + gitUserName: 'Sample User', + gitUserEmail: 'sample-user@example.com', + }, + 'example-org/example-repo' + ); + + expect(result).toEqual([ + { + provider: 'anthropic', + hasKey: true, + maskedKey: 'sk-ant...1234', + updatedAt: '2026-04-05T12:00:00.000Z', + }, + { + provider: 'openai', + hasKey: true, + }, + ]); + }); }); diff --git a/src/server/services/agentSession.ts b/src/server/services/agentSession.ts index a80698d..c641635 100644 --- a/src/server/services/agentSession.ts +++ b/src/server/services/agentSession.ts @@ -118,7 +118,7 @@ const SESSION_REDIS_PREFIX = 'lifecycle:agent:session:'; const ACTIVE_ENVIRONMENT_SESSION_UNIQUE_INDEX = 'agent_sessions_active_environment_build_unique'; const DEV_MODE_REDEPLOY_GRAPH = '[deployable.[repository], repository, service, build.[pullRequest.[repository]]]'; const SESSION_DEPLOY_GRAPH = '[deployable, repository, service]'; -const AGENT_RUN_TERMINAL_STATUSES = ['completed', 'failed', 'cancelled']; +export const AGENT_RUN_TERMINAL_STATUSES = ['completed', 'failed', 'cancelled']; export class ActiveAgentRunSuspensionError extends Error { constructor() { @@ -1303,9 +1303,10 @@ export default class AgentSessionService { resolvedModelId = selection.modelId; const resolvedServiceNames = (resolvedServices || []).map((service) => service.name); const [, providerApiKeys, sessionPodServers, resolvedCompatiblePrewarm, forwardedAgentEnv] = await Promise.all([ - AgentProviderRegistry.getRequiredStoredApiKey({ + AgentProviderRegistry.getRequiredProviderApiKey({ provider: selection.provider, userIdentity: providerUserIdentity, + repoFullName: primaryWorkspaceRepo?.repo, }), AgentProviderRegistry.resolveCredentialEnvMap({ repoFullName: primaryWorkspaceRepo?.repo,