Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/model-provider-key-save-validation.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions apps/web/src/trpc/commands/setup-new/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createEmptySetupNewState>;
Expand Down Expand Up @@ -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([
Expand Down
30 changes: 30 additions & 0 deletions apps/web/src/trpc/commands/task-models/index.test.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions apps/web/src/trpc/commands/task-models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import {
buildAutoAddedTaskModelSettings,
collectConnectedTaskModelProviderIds,
} from './auto-add-models';
import { assertModelProviderApiKeyAuthenticates } from './provider-credential-check';
import {
discoverProviderModels,
getLocalTaskModelProviderIdFromModelId,
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading