feat(site): add AI provider API client and query layer (#25580)

> 🤖 This PR was written by Coder Agents on behalf of Jake Howell.

Linear: [DEVEX-355](https://linear.app/coder/issue/DEVEX-355)

Second PR in a 5-PR stack splitting #25328. Adds the frontend layer that
talks to the existing `/api/v2/ai/providers` endpoints already shipped
on `main`:

- API client: `getAIProviders`, `getAIProvider`, `createAIProvider`,
`updateAIProvider`, `deleteAIProvider`.
- React Query wrappers in `queries/aiProviders.ts` with a shared key
helper and matching cache invalidations.
- Mock fixtures for OpenAI, Anthropic, and Bedrock providers in
`testHelpers/entities.ts` for stories and unit tests.
- `viewAnyAIProvider` registered in `permissions.json` so the existing
permissions hook can read it.
- `viewAnyAIProvider` added to `canViewDeploymentSettings` so admins who
can only manage providers still see the deployment dropdown.

The `aiProviders` query module and the per-provider mocks are
temporarily added to the `knip` ignore list / annotated with
`@lintignore`; the next PRs in the stack consume them and remove the
exclusions.

<details>
<summary>Stack</summary>

1. #25579 jakehwll/DEVEX-355/01-primitives, primitives
2. **jakehwll/DEVEX-355/02-api, API client and query layer (this PR)**
3. jakehwll/DEVEX-355/03-components, provider form components
4. jakehwll/DEVEX-355/04-pages, pages and routes
5. jakehwll/DEVEX-355/05-section, section reshuffle

Replaces #25328 once the stack lands.
</details>
This commit is contained in:
Jake Howell
2026-05-26 16:13:11 +00:00
committed by GitHub
parent 8ae732000c
commit 5d39c833f8
6 changed files with 189 additions and 15 deletions
+4
View File
@@ -11,6 +11,10 @@
"ignore": [
"**/*Generated.ts",
"src/api/chatModelOptions.ts",
// TODO(ai-settings): aiProviders.ts queries are staged in PR 2 of the
// AI settings stack; they are consumed by the provider pages in PR 4.
// Remove this exclusion once those pages land.
"src/api/queries/aiProviders.ts",
// TODO(devtools): debugPanelUtils.ts is staged in PR 7; its exports are
// consumed by the Debug panel components in PRs 8 and 9. Remove this
// exclusion once the panel components land.
+4
View File
@@ -103,6 +103,10 @@
"object": { "resource_type": "aibridge_interception", "any_org": true },
"action": "read"
},
"viewAnyAIProvider": {
"object": { "resource_type": "ai_provider" },
"action": "read"
},
"createOAuth2App": {
"object": { "resource_type": "oauth2_app" },
"action": "create"
+55 -14
View File
@@ -3090,6 +3090,47 @@ class ApiMethods {
const response = await this.axios.get<string[]>(url);
return response.data;
};
getAIProviders = async (): Promise<TypesGen.AIProvider[]> => {
const response = await this.axios.get<TypesGen.AIProvider[]>(
"/api/v2/ai/providers",
);
return response.data;
};
getAIProvider = async (idOrName: string): Promise<TypesGen.AIProvider> => {
const response = await this.axios.get<TypesGen.AIProvider>(
`/api/v2/ai/providers/${encodeURIComponent(idOrName)}`,
);
return response.data;
};
createAIProvider = async (
req: TypesGen.CreateAIProviderRequest,
): Promise<TypesGen.AIProvider> => {
const response = await this.axios.post<TypesGen.AIProvider>(
"/api/v2/ai/providers",
req,
);
return response.data;
};
updateAIProvider = async (
idOrName: string,
req: TypesGen.UpdateAIProviderRequest,
): Promise<TypesGen.AIProvider> => {
const response = await this.axios.patch<TypesGen.AIProvider>(
`/api/v2/ai/providers/${encodeURIComponent(idOrName)}`,
req,
);
return response.data;
};
deleteAIProvider = async (idOrName: string): Promise<void> => {
await this.axios.delete(
`/api/v2/ai/providers/${encodeURIComponent(idOrName)}`,
);
};
}
export type TaskFeedbackRating = "good" | "okay" | "bad";
@@ -3162,6 +3203,20 @@ class ExperimentalApiMethods {
};
// Chat API methods
getChatACL = async (chatId: string): Promise<TypesGen.ChatACL> => {
const response = await this.axios.get<TypesGen.ChatACL>(
`/api/experimental/chats/${chatId}/acl`,
);
return response.data;
};
updateChatACL = async (
chatId: string,
req: TypesGen.UpdateChatACL,
): Promise<void> => {
await this.axios.patch(`/api/experimental/chats/${chatId}/acl`, req);
};
getChats = async (req?: {
after_id?: string;
limit?: number;
@@ -3179,20 +3234,6 @@ class ExperimentalApiMethods {
);
return response.data;
};
getChatACL = async (chatId: string): Promise<TypesGen.ChatACL> => {
const response = await this.axios.get<TypesGen.ChatACL>(
`/api/experimental/chats/${chatId}/acl`,
);
return response.data;
};
updateChatACL = async (
chatId: string,
req: TypesGen.UpdateChatACL,
): Promise<void> => {
await this.axios.patch(`/api/experimental/chats/${chatId}/acl`, req);
};
getChatMessages = async (
chatId: string,
opts?: { before_id?: number; after_id?: number; limit?: number },
+55
View File
@@ -0,0 +1,55 @@
import type { QueryClient } from "react-query";
import { API } from "#/api/api";
import type {
AIProvider,
CreateAIProviderRequest,
UpdateAIProviderRequest,
} from "#/api/typesGenerated";
const aiProvidersListKey = ["ai", "providers"] as const;
const aiProviderKeyFor = (idOrName: string) =>
[...aiProvidersListKey, idOrName] as const;
export const aiProvidersList = () => ({
queryKey: aiProvidersListKey,
queryFn: (): Promise<AIProvider[]> => API.getAIProviders(),
});
export const aiProvider = (idOrName: string) => ({
queryKey: aiProviderKeyFor(idOrName),
queryFn: (): Promise<AIProvider> => API.getAIProvider(idOrName),
});
export const createAIProviderMutation = (queryClient: QueryClient) => ({
mutationFn: (request: CreateAIProviderRequest): Promise<AIProvider> =>
API.createAIProvider(request),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: aiProvidersListKey });
},
});
export const updateAIProviderMutation = (
queryClient: QueryClient,
idOrName: string,
) => ({
mutationFn: (request: UpdateAIProviderRequest): Promise<AIProvider> =>
API.updateAIProvider(idOrName, request),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: aiProvidersListKey });
await queryClient.invalidateQueries({
queryKey: aiProviderKeyFor(idOrName),
});
},
});
export const deleteAIProviderMutation = (
queryClient: QueryClient,
idOrName: string,
) => ({
mutationFn: () => API.deleteAIProvider(idOrName),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: aiProvidersListKey });
queryClient.removeQueries({ queryKey: aiProviderKeyFor(idOrName) });
},
});
+2 -1
View File
@@ -25,7 +25,8 @@ export const canViewDeploymentSettings = (
permissions.viewAllUsers ||
permissions.viewAnyGroup ||
permissions.viewNotificationTemplate ||
permissions.viewOrganizationIDPSyncSettings)
permissions.viewOrganizationIDPSyncSettings ||
permissions.viewAnyAIProvider)
);
};
+69
View File
@@ -3298,6 +3298,7 @@ export const MockPermissions: Permissions = {
viewAnyIdpSyncSettings: true,
viewAnyMembers: true,
viewAnyAIBridgeInterception: true,
viewAnyAIProvider: true,
createOAuth2App: true,
editOAuth2App: true,
deleteOAuth2App: true,
@@ -3332,6 +3333,7 @@ export const MockNoPermissions: Permissions = {
viewAnyIdpSyncSettings: false,
viewAnyMembers: false,
viewAnyAIBridgeInterception: true,
viewAnyAIProvider: false,
createOAuth2App: false,
editOAuth2App: false,
deleteOAuth2App: false,
@@ -5515,3 +5517,70 @@ export const MockSession: TypesGen.AIBridgeSession = {
last_prompt: "But *can* I really fix it?",
last_active_at: "2026-03-09T10:28:15.03152Z",
};
/** @lintignore Consumed by component stories landing in the next PR of the AI settings stack. */
export const MockAIProviderOpenAI: TypesGen.AIProvider = {
id: "7a5d6b6a-5f02-4a9c-9c4e-2b3e2a3d2f01",
type: "openai",
name: "openai",
display_name: "OpenAI",
base_url: "https://api.openai.com",
enabled: false,
api_keys: [
{
id: "6d7c1f3a-1f0b-4a12-a1b5-0fb1f8e72e01",
masked: "sk-***\u2026***ABCD",
created_at: "2026-05-14T10:00:00Z",
},
],
settings: null as unknown as TypesGen.AIProviderSettings,
created_at: "2026-05-14T10:00:00Z",
updated_at: "2026-05-14T10:00:00Z",
};
/** @lintignore Consumed by component stories landing in the next PR of the AI settings stack. */
export const MockAIProviderAnthropic: TypesGen.AIProvider = {
id: "4f81f1ee-37c1-4a37-a9d5-7e0c1c8c0c11",
type: "anthropic",
name: "anthropic",
display_name: "Anthropic",
base_url: "https://api.anthropic.com",
enabled: false,
api_keys: [],
settings: null as unknown as TypesGen.AIProviderSettings,
created_at: "2026-05-14T10:00:00Z",
updated_at: "2026-05-14T10:00:00Z",
};
/**
* Bedrock providers come over the wire with `type: "anthropic"` and a
* `settings._type: "bedrock"` discriminator. `isBedrockProvider` and the
* backend (see `coderd/ai_providers.go`) enforce this convention.
*
* @lintignore Consumed by component stories landing in the next PR of the AI settings stack.
*/
export const MockAIProviderBedrock: TypesGen.AIProvider = {
id: "9c2e3b41-2e9f-4c97-9a4f-2e1a3d8f9f21",
type: "anthropic",
name: "bedrock",
display_name: "Bedrock",
base_url: "https://bedrock-runtime.us-east-2.amazonaws.com",
enabled: true,
api_keys: [],
settings: {
_type: "bedrock",
_version: 1,
region: "us-east-2",
model: "anthropic.claude-opus-4-7",
small_fast_model: "anthropic.claude-haiku-4-5",
} as unknown as TypesGen.AIProviderSettings,
created_at: "2026-05-14T10:00:00Z",
updated_at: "2026-05-14T10:00:00Z",
};
/** @lintignore Consumed by page stories landing in PR 4 of the AI settings stack. */
export const MockAIProviders: TypesGen.AIProvider[] = [
MockAIProviderOpenAI,
MockAIProviderAnthropic,
MockAIProviderBedrock,
];