From 5d39c833f82830dc3eb00b788f95cae879035d32 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Wed, 27 May 2026 02:13:11 +1000 Subject: [PATCH] feat(site): add AI provider API client and query layer (#25580) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > 🤖 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.
Stack 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.
--- site/.knip.jsonc | 4 ++ site/permissions.json | 4 ++ site/src/api/api.ts | 69 +++++++++++++++++++++------ site/src/api/queries/aiProviders.ts | 55 +++++++++++++++++++++ site/src/modules/permissions/index.ts | 3 +- site/src/testHelpers/entities.ts | 69 +++++++++++++++++++++++++++ 6 files changed, 189 insertions(+), 15 deletions(-) create mode 100644 site/src/api/queries/aiProviders.ts diff --git a/site/.knip.jsonc b/site/.knip.jsonc index d12174a6f9..dc6b31edbc 100644 --- a/site/.knip.jsonc +++ b/site/.knip.jsonc @@ -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. diff --git a/site/permissions.json b/site/permissions.json index 7ec8da4087..63c26797b5 100644 --- a/site/permissions.json +++ b/site/permissions.json @@ -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" diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 4fef6cbc16..8976bf901c 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -3090,6 +3090,47 @@ class ApiMethods { const response = await this.axios.get(url); return response.data; }; + + getAIProviders = async (): Promise => { + const response = await this.axios.get( + "/api/v2/ai/providers", + ); + return response.data; + }; + + getAIProvider = async (idOrName: string): Promise => { + const response = await this.axios.get( + `/api/v2/ai/providers/${encodeURIComponent(idOrName)}`, + ); + return response.data; + }; + + createAIProvider = async ( + req: TypesGen.CreateAIProviderRequest, + ): Promise => { + const response = await this.axios.post( + "/api/v2/ai/providers", + req, + ); + return response.data; + }; + + updateAIProvider = async ( + idOrName: string, + req: TypesGen.UpdateAIProviderRequest, + ): Promise => { + const response = await this.axios.patch( + `/api/v2/ai/providers/${encodeURIComponent(idOrName)}`, + req, + ); + return response.data; + }; + + deleteAIProvider = async (idOrName: string): Promise => { + 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 => { + const response = await this.axios.get( + `/api/experimental/chats/${chatId}/acl`, + ); + return response.data; + }; + + updateChatACL = async ( + chatId: string, + req: TypesGen.UpdateChatACL, + ): Promise => { + 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 => { - const response = await this.axios.get( - `/api/experimental/chats/${chatId}/acl`, - ); - return response.data; - }; - - updateChatACL = async ( - chatId: string, - req: TypesGen.UpdateChatACL, - ): Promise => { - await this.axios.patch(`/api/experimental/chats/${chatId}/acl`, req); - }; - getChatMessages = async ( chatId: string, opts?: { before_id?: number; after_id?: number; limit?: number }, diff --git a/site/src/api/queries/aiProviders.ts b/site/src/api/queries/aiProviders.ts new file mode 100644 index 0000000000..ad8d722352 --- /dev/null +++ b/site/src/api/queries/aiProviders.ts @@ -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 => API.getAIProviders(), +}); + +export const aiProvider = (idOrName: string) => ({ + queryKey: aiProviderKeyFor(idOrName), + queryFn: (): Promise => API.getAIProvider(idOrName), +}); + +export const createAIProviderMutation = (queryClient: QueryClient) => ({ + mutationFn: (request: CreateAIProviderRequest): Promise => + API.createAIProvider(request), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: aiProvidersListKey }); + }, +}); + +export const updateAIProviderMutation = ( + queryClient: QueryClient, + idOrName: string, +) => ({ + mutationFn: (request: UpdateAIProviderRequest): Promise => + 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) }); + }, +}); diff --git a/site/src/modules/permissions/index.ts b/site/src/modules/permissions/index.ts index 1dccce7b62..f192ae1a57 100644 --- a/site/src/modules/permissions/index.ts +++ b/site/src/modules/permissions/index.ts @@ -25,7 +25,8 @@ export const canViewDeploymentSettings = ( permissions.viewAllUsers || permissions.viewAnyGroup || permissions.viewNotificationTemplate || - permissions.viewOrganizationIDPSyncSettings) + permissions.viewOrganizationIDPSyncSettings || + permissions.viewAnyAIProvider) ); }; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 73348109b1..f28637b1c4 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -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, +];