diff --git a/.changeset/model-provider-key-save-validation.md b/.changeset/model-provider-key-save-validation.md new file mode 100644 index 000000000..fd105dcf4 --- /dev/null +++ b/.changeset/model-provider-key-save-validation.md @@ -0,0 +1,9 @@ +--- +'@roomote/web': patch +--- + +Verify a model provider API key with the provider before saving it. Connecting a hosted provider used to persist whatever was typed without ever authenticating it: the save path only made a network call for the four providers that discover their models from an endpoint, so a typo, a revoked key, or a key pasted into the wrong provider saved cleanly and reported the provider connected. The first symptom was a task failing at run time, which reads as a Roomote fault rather than a rejected credential. + +Saving `anthropic`, `openai`, `google`, `xai`, `moonshotai`, `openrouter`, or `togetherai` from the setup wizard or Models settings now makes one bounded authenticated request to that provider first, and the save fails with the provider's own rejection quoted against the key field. Nothing is written when the key is rejected, so a failed save no longer leaves a bad credential behind. Only a rejection from the provider blocks the save: a timeout, a rate limit, or a provider outage is reported as unverified and lets the save through. + +Providers that resolve an operator-supplied endpoint (LiteLLM, Ollama, vLLM, OpenAI-compatible), the OAuth providers, and Bedrock/Azure are unchanged. diff --git a/apps/web/src/trpc/commands/setup-new/index.ts b/apps/web/src/trpc/commands/setup-new/index.ts index 120e0488d..f9771689a 100644 --- a/apps/web/src/trpc/commands/setup-new/index.ts +++ b/apps/web/src/trpc/commands/setup-new/index.ts @@ -166,6 +166,7 @@ import { buildAutoAddedTaskModelSettings, collectConnectedTaskModelProviderIds, } from '../task-models/auto-add-models'; +import { assertModelProviderApiKeyAuthenticates } from '../task-models/provider-credential-check'; import { triggerTaskSuggestionsCommand } from '../task-suggestions'; type PersistedSetupNewState = ReturnType; @@ -1546,6 +1547,12 @@ export async function saveSetupNewModelConfigCommand( ); } + // Prove the key authenticates before anything is written, so the wizard + // cannot report a provider connected on a credential the provider rejects. + if (!isOauthProvider) { + await assertModelProviderApiKeyAuthenticates({ provider, apiKey }); + } + return db.transaction(async (tx) => { const [currentState, persistedEnvVarNames, persistedTaskModelSettings] = await Promise.all([ diff --git a/apps/web/src/trpc/commands/task-models/index.test.ts b/apps/web/src/trpc/commands/task-models/index.test.ts index 8f7e86542..833b97b8c 100644 --- a/apps/web/src/trpc/commands/task-models/index.test.ts +++ b/apps/web/src/trpc/commands/task-models/index.test.ts @@ -15,6 +15,7 @@ const { mockIsChatGptSubscriptionConnected, mockIsGitHubCopilotSubscriptionConnected, mockIsXaiSubscriptionConnected, + mockAssertModelProviderApiKeyAuthenticates, } = vi.hoisted(() => ({ mockFindDeploymentSettings: vi.fn(), mockInsertDeploymentSettings: vi.fn(), @@ -28,6 +29,7 @@ const { mockIsChatGptSubscriptionConnected: vi.fn(), mockIsGitHubCopilotSubscriptionConnected: vi.fn(), mockIsXaiSubscriptionConnected: vi.fn(), + mockAssertModelProviderApiKeyAuthenticates: vi.fn(), })); vi.mock('@roomote/db/server', () => ({ @@ -61,6 +63,11 @@ vi.mock('@roomote/db/server', () => ({ isNull: vi.fn((column) => ({ isNull: column })), })); +vi.mock('./provider-credential-check', () => ({ + assertModelProviderApiKeyAuthenticates: + mockAssertModelProviderApiKeyAuthenticates, +})); + vi.mock('../environment-variables', () => ({ getPersistedEnvironmentVariableNames: mockGetPersistedEnvironmentVariableNames, @@ -1207,6 +1214,7 @@ describe('task model provider commands', () => { mockIsChatGptSubscriptionConnected.mockResolvedValue(false); mockIsGitHubCopilotSubscriptionConnected.mockResolvedValue(false); mockIsXaiSubscriptionConnected.mockResolvedValue(false); + mockAssertModelProviderApiKeyAuthenticates.mockResolvedValue(undefined); mockPersistedSetupNewState({}); }); @@ -1418,6 +1426,28 @@ describe('task model provider commands', () => { expect(txInsert).not.toHaveBeenCalled(); }); + it('writes nothing when the provider rejects the API key', async () => { + mockAssertModelProviderApiKeyAuthenticates.mockRejectedValue( + new Error( + 'Anthropic rejected the API key (ANTHROPIC_API_KEY), status 401: “invalid x-api-key” Check the value and save it again.', + ), + ); + + await expect( + saveTaskModelProviderCommand(buildMockAuth(), { + provider: 'anthropic', + apiKey: 'sk-ant-revoked', + }), + ).rejects.toThrow('Anthropic rejected the API key'); + + expect(mockAssertModelProviderApiKeyAuthenticates).toHaveBeenCalledWith({ + provider: expect.objectContaining({ id: 'anthropic' }), + apiKey: 'sk-ant-revoked', + }); + expect(mockUpsertDeploymentEnvironmentVariables).not.toHaveBeenCalled(); + expect(txInsert).not.toHaveBeenCalled(); + }); + it('saves the API key and seeds the recommended models for a newly connected provider', async () => { mockGetPersistedEnvironmentVariableNames .mockResolvedValueOnce([]) diff --git a/apps/web/src/trpc/commands/task-models/index.ts b/apps/web/src/trpc/commands/task-models/index.ts index 771a9c5d5..8c5e7659b 100644 --- a/apps/web/src/trpc/commands/task-models/index.ts +++ b/apps/web/src/trpc/commands/task-models/index.ts @@ -68,6 +68,7 @@ import { buildAutoAddedTaskModelSettings, collectConnectedTaskModelProviderIds, } from './auto-add-models'; +import { assertModelProviderApiKeyAuthenticates } from './provider-credential-check'; import { discoverProviderModels, getLocalTaskModelProviderIdFromModelId, @@ -624,6 +625,14 @@ export async function saveTaskModelProviderCommand( ); } + // Prove the key authenticates before anything is written. A rejected key + // used to save cleanly and only surface as a failed task run later, which + // read as a Roomote fault rather than a bad credential. + await assertModelProviderApiKeyAuthenticates({ + provider, + apiKey: input.apiKey, + }); + // When remapping a newly named OpenAI-compatible connection,, rewrite the // primary base URL key by treating apiKey as the template primary value and // collecting against the named descriptor. diff --git a/apps/web/src/trpc/commands/task-models/provider-credential-check.test.ts b/apps/web/src/trpc/commands/task-models/provider-credential-check.test.ts new file mode 100644 index 000000000..0d8618b2e --- /dev/null +++ b/apps/web/src/trpc/commands/task-models/provider-credential-check.test.ts @@ -0,0 +1,287 @@ +import { getSetupModelProvider } from '@roomote/types'; + +const { mockGetPersistedEnvironmentVariableValues } = vi.hoisted(() => ({ + mockGetPersistedEnvironmentVariableValues: vi.fn(), +})); + +vi.mock('../environment-variables', () => ({ + getPersistedEnvironmentVariableValues: + mockGetPersistedEnvironmentVariableValues, +})); + +import { + assertModelProviderApiKeyAuthenticates, + canValidateModelProviderApiKey, + validateModelProviderApiKey, +} from './provider-credential-check'; + +function jsonResponse(status: number, body: unknown) { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +function buildFetch(response: Response | Error) { + return vi.fn(async () => { + if (response instanceof Error) { + throw response; + } + + return response; + }) as unknown as typeof fetch; +} + +function getRequest(fetchImpl: typeof fetch) { + const [url, init] = (fetchImpl as unknown as ReturnType).mock + .calls[0] as [string, RequestInit]; + + return { url, headers: init.headers as Record }; +} + +describe('validateModelProviderApiKey', () => { + const originalAnthropicKey = process.env.ANTHROPIC_API_KEY; + + beforeEach(() => { + vi.clearAllMocks(); + mockGetPersistedEnvironmentVariableValues.mockResolvedValue({}); + delete process.env.ANTHROPIC_API_KEY; + }); + + afterEach(() => { + if (originalAnthropicKey === undefined) { + delete process.env.ANTHROPIC_API_KEY; + } else { + process.env.ANTHROPIC_API_KEY = originalAnthropicKey; + } + }); + + it('probes Anthropic with the key and the version header', async () => { + const fetchImpl = buildFetch(jsonResponse(200, { data: [] })); + + await expect( + validateModelProviderApiKey({ + provider: getSetupModelProvider('anthropic'), + apiKey: 'sk-ant-good', + fetchImpl, + }), + ).resolves.toEqual({ status: 'valid' }); + + const request = getRequest(fetchImpl); + expect(request.url).toBe('https://api.anthropic.com/v1/models?limit=1'); + expect(request.headers['x-api-key']).toBe('sk-ant-good'); + expect(request.headers['anthropic-version']).toBe('2023-06-01'); + }); + + it('sends bearer keys to the provider upstream base', async () => { + const fetchImpl = buildFetch(jsonResponse(200, { data: [] })); + + await validateModelProviderApiKey({ + provider: getSetupModelProvider('openai'), + apiKey: 'sk-openai', + fetchImpl, + }); + + const request = getRequest(fetchImpl); + expect(request.url).toBe('https://api.openai.com/v1/models'); + expect(request.headers.authorization).toBe('Bearer sk-openai'); + }); + + it('probes an OpenRouter endpoint that actually requires the key', async () => { + const fetchImpl = buildFetch(jsonResponse(200, { data: {} })); + + await validateModelProviderApiKey({ + provider: getSetupModelProvider('openrouter'), + apiKey: 'sk-or-good', + fetchImpl, + }); + + // `/api/v1/models` is public, so it would call any string a valid key. + expect(getRequest(fetchImpl).url).toBe('https://openrouter.ai/api/v1/key'); + }); + + it('sends the Google key in its own header', async () => { + const fetchImpl = buildFetch(jsonResponse(200, { models: [] })); + + await validateModelProviderApiKey({ + provider: getSetupModelProvider('google'), + apiKey: 'gemini-good', + fetchImpl, + }); + + const request = getRequest(fetchImpl); + expect(request.url).toBe( + 'https://generativelanguage.googleapis.com/v1beta/models', + ); + expect(request.headers['x-goog-api-key']).toBe('gemini-good'); + }); + + it('reports a rejected key with the provider message and the field', async () => { + const result = await validateModelProviderApiKey({ + provider: getSetupModelProvider('anthropic'), + apiKey: 'sk-ant-bad', + fetchImpl: buildFetch( + jsonResponse(401, { + type: 'error', + error: { type: 'authentication_error', message: 'invalid x-api-key' }, + }), + ), + }); + + expect(result).toEqual({ + status: 'invalid', + error: + 'Anthropic rejected the API key (ANTHROPIC_API_KEY), status 401: “invalid x-api-key” Check the value and save it again.', + }); + }); + + it('treats a Google 400 as a rejected key', async () => { + const result = await validateModelProviderApiKey({ + provider: getSetupModelProvider('google'), + apiKey: 'gemini-bad', + fetchImpl: buildFetch( + jsonResponse(400, { + error: { message: 'API key not valid. Please pass a valid API key.' }, + }), + ), + }); + + expect(result.status).toBe('invalid'); + expect(result.status === 'invalid' && result.error).toContain( + 'API key not valid.', + ); + }); + + it('treats an xAI 400 as a rejected key', async () => { + const result = await validateModelProviderApiKey({ + provider: getSetupModelProvider('xai'), + apiKey: 'xai-bad', + fetchImpl: buildFetch( + jsonResponse(400, { + code: 'invalid-argument', + error: 'Incorrect API key provided.', + }), + ), + }); + + expect(result).toEqual({ + status: 'invalid', + error: + 'xAI rejected the API key (XAI_API_KEY), status 400: “Incorrect API key provided.” Check the value and save it again.', + }); + }); + + it('does not call a rate limited provider a rejection', async () => { + const result = await validateModelProviderApiKey({ + provider: getSetupModelProvider('openai'), + apiKey: 'sk-openai', + fetchImpl: buildFetch(jsonResponse(429, { error: { message: 'slow' } })), + }); + + expect(result.status).toBe('unknown'); + }); + + it('does not call an unreachable provider a rejection', async () => { + const result = await validateModelProviderApiKey({ + provider: getSetupModelProvider('openai'), + apiKey: 'sk-openai', + fetchImpl: buildFetch(new Error('connect ECONNREFUSED')), + }); + + expect(result.status).toBe('unknown'); + expect(result.status === 'unknown' && result.error).toContain( + 'connect ECONNREFUSED', + ); + }); + + it('leaves endpoint providers on their existing discovery path', async () => { + const fetchImpl = buildFetch(jsonResponse(401, {})); + + expect(canValidateModelProviderApiKey('litellm')).toBe(false); + await expect( + validateModelProviderApiKey({ + provider: getSetupModelProvider('litellm'), + apiKey: 'anything', + fetchImpl, + }), + ).resolves.toEqual({ status: 'valid' }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); +}); + +describe('assertModelProviderApiKeyAuthenticates', () => { + const originalAnthropicKey = process.env.ANTHROPIC_API_KEY; + + beforeEach(() => { + vi.clearAllMocks(); + mockGetPersistedEnvironmentVariableValues.mockResolvedValue({}); + delete process.env.ANTHROPIC_API_KEY; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + + if (originalAnthropicKey === undefined) { + delete process.env.ANTHROPIC_API_KEY; + } else { + process.env.ANTHROPIC_API_KEY = originalAnthropicKey; + } + }); + + it('fails the save when the provider rejects the key', async () => { + vi.stubGlobal( + 'fetch', + buildFetch( + jsonResponse(401, { error: { message: 'invalid x-api-key' } }), + ), + ); + + await expect( + assertModelProviderApiKeyAuthenticates({ + provider: getSetupModelProvider('anthropic'), + apiKey: 'sk-ant-bad', + }), + ).rejects.toThrow('Anthropic rejected the API key'); + }); + + it('allows the save when the provider could not be reached', async () => { + vi.stubGlobal('fetch', buildFetch(new Error('network down'))); + + await expect( + assertModelProviderApiKeyAuthenticates({ + provider: getSetupModelProvider('anthropic'), + apiKey: 'sk-ant-good', + }), + ).resolves.toBeUndefined(); + }); + + it('validates the stored key when the form submits a blank field', async () => { + const fetchImpl = buildFetch(jsonResponse(200, { data: [] })); + vi.stubGlobal('fetch', fetchImpl); + mockGetPersistedEnvironmentVariableValues.mockResolvedValue({ + ANTHROPIC_API_KEY: 'sk-ant-saved', + }); + + await assertModelProviderApiKeyAuthenticates({ + provider: getSetupModelProvider('anthropic'), + apiKey: '', + }); + + expect(mockGetPersistedEnvironmentVariableValues).toHaveBeenCalledWith([ + 'ANTHROPIC_API_KEY', + ]); + expect(getRequest(fetchImpl).headers['x-api-key']).toBe('sk-ant-saved'); + }); + + it('leaves a missing key to the required-value check', async () => { + const fetchImpl = buildFetch(jsonResponse(401, {})); + vi.stubGlobal('fetch', fetchImpl); + + await expect( + assertModelProviderApiKeyAuthenticates({ + provider: getSetupModelProvider('anthropic'), + }), + ).resolves.toBeUndefined(); + expect(fetchImpl).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/trpc/commands/task-models/provider-credential-check.ts b/apps/web/src/trpc/commands/task-models/provider-credential-check.ts new file mode 100644 index 000000000..cce8a86d1 --- /dev/null +++ b/apps/web/src/trpc/commands/task-models/provider-credential-check.ts @@ -0,0 +1,345 @@ +import { + getInferenceGatewayProvider, + type SetupModelProviderDescriptor, + type SetupModelProviderId, +} from '@roomote/types'; + +import { getPersistedEnvironmentVariableValues } from '../environment-variables'; + +/** + * Saves block on this check, so keep it short enough that a wedged network + * does not look like a hung form. + */ +const SAVE_VALIDATION_TIMEOUT_MS = 8_000; + +const PROVIDER_MESSAGE_MAX_CHARS = 240; + +/** Statuses every provider uses to say "this credential is not valid". */ +const DEFAULT_REJECTION_STATUSES: readonly number[] = [401, 403]; + +type ModelProviderKeyProbe = { + /** + * Path appended to the provider's upstream API base. Must be an endpoint + * that requires the API key: a route the provider serves unauthenticated + * (OpenRouter's `/v1/models`, for one) would accept any string as valid. + */ + path: string; + /** Headers the endpoint needs beyond the provider's auth header. */ + headers?: Readonly>; + /** + * Statuses this provider uses to reject a credential beyond 401/403. Only + * for providers that answer a well-formed request with something else; + * anything not listed stays "could not verify" and never blocks a save. + */ + rejectionStatuses?: readonly number[]; +}; + +/** + * Hosted API-key providers Roomote authenticates before saving the key, keyed + * by catalog id. Each probe is a cheap authenticated GET against the same + * upstream base the inference gateway forwards to, so the endpoints stay in + * one place as the catalog grows. + * + * Deliberately partial. Providers that resolve an operator-supplied endpoint + * (`litellm`, `ollama`, `vllm`, `openai-compatible`), OAuth providers + * (`github-copilot`, `chatgpt`), and providers whose credential is not a + * single bearer-style key (Bedrock, Azure) keep their existing behavior; + * adding one here is a table entry, not a code change. + */ +const MODEL_PROVIDER_KEY_PROBES = { + anthropic: { + path: '/v1/models?limit=1', + headers: { 'anthropic-version': '2023-06-01' }, + }, + openai: { path: '/v1/models' }, + // Google and xAI answer an unusable key with 400, not 401. Each probe is a + // fixed GET with no body, so a 400 there is about the key, not the request. + google: { path: '/v1beta/models', rejectionStatuses: [400] }, + xai: { path: '/v1/models', rejectionStatuses: [400] }, + moonshotai: { path: '/v1/models' }, + // `/api/v1/models` is public on OpenRouter; `/api/v1/key` is the key check. + openrouter: { path: '/v1/key' }, + togetherai: { path: '/v1/models' }, +} as const satisfies Partial< + Record +>; + +type ModelProviderKeyValidationResult = + | { status: 'valid' } + | { status: 'invalid'; error: string } + /** Roomote could not get an answer. Not a verdict on the key. */ + | { status: 'unknown'; error: string }; + +type ModelProviderKeyProbeTarget = { + url: string; + headers: Record; + rejectionStatuses: readonly number[]; +}; + +function getModelProviderKeyProbe( + providerId: string, +): ModelProviderKeyProbe | null { + return ( + ( + MODEL_PROVIDER_KEY_PROBES as Partial< + Record + > + )[providerId] ?? null + ); +} + +/** + * Build the probe request from the provider's inference-gateway descriptor: + * the upstream base URL and the auth header shape the gateway already uses to + * reach that provider. Templated bases (`{region}`, `{resource}`) resolve + * per-request at the gateway and have no single save-time value, so a provider + * carrying one is left unvalidated rather than guessed at. + */ +function buildModelProviderKeyProbeTarget({ + providerId, + apiKey, +}: { + providerId: string; + apiKey: string; +}): ModelProviderKeyProbeTarget | null { + const probe = getModelProviderKeyProbe(providerId); + const gateway = getInferenceGatewayProvider(providerId); + const baseUrl = gateway?.upstreamBaseUrl; + const authHeader = gateway?.authHeader; + + if (!probe || !baseUrl || !authHeader || baseUrl.includes('{')) { + return null; + } + + return { + url: `${baseUrl.replace(/\/+$/u, '')}${probe.path}`, + headers: { + Accept: 'application/json', + [authHeader.name]: + authHeader.scheme === 'bearer' ? `Bearer ${apiKey}` : apiKey, + ...probe.headers, + }, + rejectionStatuses: probe.rejectionStatuses + ? [...DEFAULT_REJECTION_STATUSES, ...probe.rejectionStatuses] + : DEFAULT_REJECTION_STATUSES, + }; +} + +/** True when Roomote can authenticate this provider's key before saving it. */ +export function canValidateModelProviderApiKey(providerId: string): boolean { + return getModelProviderKeyProbe(providerId) !== null; +} + +/** + * Providers answer rejections with their own JSON shapes. Take the first + * message-bearing field and clip it: the operator needs the provider's own + * words, not its whole error envelope. + */ +function readProviderErrorMessage(body: string): string | null { + let message: unknown = null; + + try { + const parsed: unknown = JSON.parse(body); + + if (typeof parsed === 'object' && parsed !== null) { + const envelope = parsed as { error?: unknown; message?: unknown }; + const error = envelope.error; + + message = + typeof error === 'string' + ? error + : typeof error === 'object' && error !== null + ? (error as { message?: unknown }).message + : envelope.message; + } + } catch { + // Gateways and proxies answer with HTML; there is nothing to quote. + return null; + } + + if (typeof message !== 'string') { + return null; + } + + const collapsed = message.trim().replace(/\s+/gu, ' '); + + if (!collapsed) { + return null; + } + + return collapsed.length > PROVIDER_MESSAGE_MAX_CHARS + ? `${collapsed.slice(0, PROVIDER_MESSAGE_MAX_CHARS)}…` + : collapsed; +} + +function describeCredentialField( + provider: SetupModelProviderDescriptor, +): string { + const label = provider.envVarLabel ?? 'API key'; + + return provider.envVarName ? `${label} (${provider.envVarName})` : label; +} + +function buildRejectionMessage({ + provider, + status, + providerMessage, +}: { + provider: SetupModelProviderDescriptor; + status: number; + providerMessage: string | null; +}): string { + const quote = providerMessage ? `: “${providerMessage}”` : '.'; + + return `${provider.label} rejected the ${describeCredentialField( + provider, + )}, status ${status}${quote} Check the value and save it again.`; +} + +/** + * Prove an API key authenticates by making the cheapest authenticated call the + * provider offers. Returns `invalid` only when the provider itself rejected + * the key; a timeout, an outage, or an unexpected status is `unknown`, so a + * provider having a bad day never blocks a save. + */ +export async function validateModelProviderApiKey({ + provider, + apiKey, + fetchImpl = fetch, + timeoutMs = SAVE_VALIDATION_TIMEOUT_MS, +}: { + provider: SetupModelProviderDescriptor; + apiKey: string; + fetchImpl?: typeof fetch; + timeoutMs?: number; +}): Promise { + const target = buildModelProviderKeyProbeTarget({ + providerId: provider.id, + apiKey, + }); + + if (!target) { + return { status: 'valid' }; + } + + let response: Response; + + try { + response = await fetchImpl(target.url, { + method: 'GET', + headers: target.headers, + signal: AbortSignal.timeout(timeoutMs), + }); + } catch (error) { + return { + status: 'unknown', + error: `Could not reach ${provider.label} to verify the ${describeCredentialField( + provider, + )}: ${error instanceof Error ? error.message : String(error)}`, + }; + } + + if (target.rejectionStatuses.includes(response.status)) { + return { + status: 'invalid', + error: buildRejectionMessage({ + provider, + status: response.status, + providerMessage: readProviderErrorMessage( + await response.text().catch(() => ''), + ), + }), + }; + } + + if (!response.ok) { + // Nothing here says the key is bad: rate limits, outages, and endpoints a + // provider has moved all land in this branch. + await response.body?.cancel().catch(() => {}); + + return { + status: 'unknown', + error: `Could not verify the ${provider.label} ${describeCredentialField( + provider, + )}: ${provider.label} returned HTTP ${response.status}.`, + }; + } + + await response.body?.cancel().catch(() => {}); + + return { status: 'valid' }; +} + +/** + * Resolve the key the save is about to produce, the way the connected/ + * satisfied checks resolve it: the submitted value, then a runtime env var, + * then what is already stored. + * + * `||`, not `??`: the settings form submits an empty string for a field + * already satisfied by a runtime env var, and `??` would keep that empty + * string and skip the probe entirely. + */ +async function resolvePendingModelProviderApiKey( + provider: SetupModelProviderDescriptor, + submittedApiKey: string | undefined, +): Promise { + const { envVarName } = provider; + + if (!envVarName) { + return null; + } + + const submitted = submittedApiKey?.trim(); + + if (submitted) { + return submitted; + } + + const runtime = process.env[envVarName]?.trim(); + + if (runtime) { + return runtime; + } + + const persisted = await getPersistedEnvironmentVariableValues([envVarName]); + + return persisted[envVarName]?.trim() || null; +} + +/** + * Fail a model-provider save when the provider rejects the API key. Presence + * of a non-empty value used to be enough to report the provider connected, + * which left a typo or a revoked key to surface as a failed task run hours + * later, attributed to Roomote rather than to the credential. + */ +export async function assertModelProviderApiKeyAuthenticates({ + provider, + apiKey, +}: { + provider: SetupModelProviderDescriptor; + apiKey?: string; +}): Promise { + if (!canValidateModelProviderApiKey(provider.id)) { + return; + } + + const resolvedApiKey = await resolvePendingModelProviderApiKey( + provider, + apiKey, + ); + + if (!resolvedApiKey) { + // The per-field required-value check reports a missing key with copy that + // points at the empty field. + return; + } + + const result = await validateModelProviderApiKey({ + provider, + apiKey: resolvedApiKey, + }); + + if (result.status === 'invalid') { + throw new Error(result.error); + } +}