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, +];