-
Notifications
You must be signed in to change notification settings - Fork 1.5k
feat: add AIProvider types and client methods #24893
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
db3fa4d
feat(codersdk): add AIProvider types and client methods
dannykopping 17976f4
refactor(codersdk): split AI provider keys into a separate API
dannykopping 57d6c92
refactor(codersdk): make ai_providers.settings discriminated
dannykopping e59f8af
test(codersdk): tighten AIProviderSettings error assertions
dannykopping c76e3dc
test(codersdk): assert full JSON in AIProviderSettings marshal tests
dannykopping File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,291 @@ | ||
| package codersdk | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "context" | ||
| "encoding/json" | ||
| "fmt" | ||
| "net/http" | ||
| "time" | ||
|
|
||
| "github.com/google/uuid" | ||
| "golang.org/x/xerrors" | ||
| ) | ||
|
|
||
| // AIProviderType identifies the protocol Coder uses to communicate | ||
| // with an upstream AI provider. | ||
| type AIProviderType string | ||
|
|
||
| const ( | ||
| AIProviderTypeOpenAI AIProviderType = "openai" | ||
| AIProviderTypeAnthropic AIProviderType = "anthropic" | ||
| ) | ||
|
|
||
| // AIProviderSettings is the discriminated container for type-specific | ||
| // provider settings stored in ai_providers.settings. Providers that | ||
| // need no type-specific configuration (current OpenAI and standard | ||
| // Anthropic flows) leave every field nil; the wire form for those | ||
| // providers is JSON null. | ||
| // | ||
| // On the wire, settings serialize as a JSON object that always carries | ||
| // _type and _version discriminator keys alongside the type-specific | ||
| // fields. The custom (Un)MarshalJSON implementations on this type | ||
| // handle the routing automatically; callers should never marshal the | ||
| // concrete settings struct directly. | ||
| type AIProviderSettings struct { | ||
| // Bedrock, when set, indicates this provider authenticates against | ||
| // AWS Bedrock instead of api.anthropic.com. Only meaningful for | ||
| // AIProviderTypeAnthropic. | ||
| Bedrock *AIProviderBedrockSettings `json:"-"` | ||
| } | ||
|
|
||
| // IsZero reports whether the settings carry no type-specific data. | ||
| func (s AIProviderSettings) IsZero() bool { | ||
| return s.Bedrock == nil | ||
| } | ||
|
|
||
| // MarshalJSON emits the discriminated wire form. Empty settings encode | ||
| // as JSON null so the column round-trips cleanly through SQL NULL. | ||
| func (s AIProviderSettings) MarshalJSON() ([]byte, error) { | ||
| switch { | ||
| case s.Bedrock != nil: | ||
| return marshalSettings(*s.Bedrock) | ||
| default: | ||
| return []byte("null"), nil | ||
| } | ||
| } | ||
|
|
||
| // UnmarshalJSON inspects the _type discriminator and routes to the | ||
| // concrete settings struct that matches it. | ||
| func (s *AIProviderSettings) UnmarshalJSON(data []byte) error { | ||
| *s = AIProviderSettings{} | ||
| trimmed := bytes.TrimSpace(data) | ||
| if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { | ||
| return nil | ||
| } | ||
| var header aiProviderSettingsHeader | ||
| if err := json.Unmarshal(data, &header); err != nil { | ||
| return xerrors.Errorf("decode settings header: %w", err) | ||
| } | ||
| if header.Type == "" { | ||
| return xerrors.New("settings missing _type discriminator") | ||
| } | ||
| switch header.Type { | ||
| case AIProviderSettingsTypeBedrock: | ||
| // TODO: handle multiple versions; this will be implemented | ||
| // once needed. | ||
| if header.Version != AIProviderBedrockSettingsVersion { | ||
| return xerrors.Errorf("unsupported %q settings version %d (expected %d)", | ||
| header.Type, header.Version, AIProviderBedrockSettingsVersion) | ||
| } | ||
| var b AIProviderBedrockSettings | ||
| if err := json.Unmarshal(data, &b); err != nil { | ||
| return xerrors.Errorf("decode bedrock settings: %w", err) | ||
| } | ||
| s.Bedrock = &b | ||
| return nil | ||
| default: | ||
| return xerrors.Errorf("unknown settings type %q", header.Type) | ||
| } | ||
| } | ||
|
|
||
| // aiProviderSettingsHeader is the discriminator-only view of an | ||
| // encoded settings blob. | ||
| type aiProviderSettingsHeader struct { | ||
| Type string `json:"_type"` | ||
| Version int `json:"_version"` | ||
| } | ||
|
|
||
| // settingsTyped is implemented by concrete settings structs so that | ||
| // marshalSettings can inject the discriminator without type-asserting | ||
| // against every variant. | ||
| type settingsTyped interface { | ||
| settingsType() string | ||
| settingsVersion() int | ||
| } | ||
|
|
||
| // marshalSettings encodes a concrete settings struct and merges the | ||
| // _type and _version discriminator keys at the top level of the | ||
| // resulting JSON object. | ||
| func marshalSettings(s settingsTyped) ([]byte, error) { | ||
| raw, err := json.Marshal(s) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| var m map[string]json.RawMessage | ||
| if err := json.Unmarshal(raw, &m); err != nil { | ||
| return nil, err | ||
| } | ||
| if m == nil { | ||
| m = make(map[string]json.RawMessage) | ||
| } | ||
| typeRaw, err := json.Marshal(s.settingsType()) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| versRaw, err := json.Marshal(s.settingsVersion()) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| m["_type"] = typeRaw | ||
| m["_version"] = versRaw | ||
| return json.Marshal(m) | ||
| } | ||
|
|
||
| // AIProvider represents an AI provider configuration row as returned | ||
| // by the API. API keys are stored in a separate ai_provider_keys | ||
| // table and managed via the keys sub-endpoints; secret fields on | ||
| // Settings are never included in responses. | ||
| type AIProvider struct { | ||
| ID uuid.UUID `json:"id" format:"uuid"` | ||
| Type AIProviderType `json:"type"` | ||
| Name string `json:"name"` | ||
| DisplayName string `json:"display_name"` | ||
| Enabled bool `json:"enabled"` | ||
| BaseURL string `json:"base_url"` | ||
| Settings AIProviderSettings `json:"settings"` | ||
| CreatedAt time.Time `json:"created_at" format:"date-time"` | ||
| UpdatedAt time.Time `json:"updated_at" format:"date-time"` | ||
| } | ||
|
|
||
| // CreateAIProviderRequest is the payload for creating a new AI | ||
| // provider. Name, Type, and BaseURL are required. API keys for | ||
| // OpenAI/Anthropic providers are added via the keys sub-endpoint | ||
| // after the provider is created; Bedrock providers carry their | ||
| // credentials in Settings and do not use the keys sub-endpoint. | ||
| type CreateAIProviderRequest struct { | ||
| Type AIProviderType `json:"type"` | ||
| Name string `json:"name"` | ||
| DisplayName string `json:"display_name,omitempty"` | ||
| Enabled bool `json:"enabled"` | ||
| BaseURL string `json:"base_url"` | ||
| Settings AIProviderSettings `json:"settings,omitzero"` | ||
| } | ||
|
|
||
| // UpdateAIProviderRequest is the payload for partially updating an | ||
| // AI provider. At least one field must be non-nil. Pointer fields | ||
| // distinguish "not sent" (nil) from "set to empty/zero" (a pointer | ||
| // to the zero value). | ||
| type UpdateAIProviderRequest struct { | ||
| DisplayName *string `json:"display_name,omitempty"` | ||
| Enabled *bool `json:"enabled,omitempty"` | ||
| BaseURL *string `json:"base_url,omitempty"` | ||
| Settings *AIProviderSettings `json:"settings,omitempty"` | ||
| } | ||
|
|
||
| // AIProviderKey represents a single API key registered against an | ||
| // AI provider, as returned by the API. The plaintext APIKey is | ||
| // write-only and never included in responses. | ||
| type AIProviderKey struct { | ||
| ID uuid.UUID `json:"id" format:"uuid"` | ||
| ProviderID uuid.UUID `json:"provider_id" format:"uuid"` | ||
| CreatedAt time.Time `json:"created_at" format:"date-time"` | ||
| UpdatedAt time.Time `json:"updated_at" format:"date-time"` | ||
| } | ||
|
|
||
| // CreateAIProviderKeyRequest is the payload for adding an API key to | ||
| // an AI provider. Only meaningful for openai and anthropic providers; | ||
| // Bedrock providers reject this call because they use the access | ||
| // credentials stored in Settings. | ||
| type CreateAIProviderKeyRequest struct { | ||
| APIKey string `json:"api_key"` | ||
| } | ||
|
|
||
| // AIProviders lists all (non-deleted) AI providers. | ||
| func (c *Client) AIProviders(ctx context.Context) ([]AIProvider, error) { | ||
| res, err := c.Request(ctx, http.MethodGet, "/api/v2/ai/providers", nil) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| defer res.Body.Close() | ||
| if res.StatusCode != http.StatusOK { | ||
| return nil, ReadBodyAsError(res) | ||
| } | ||
| var providers []AIProvider | ||
| return providers, json.NewDecoder(res.Body).Decode(&providers) | ||
| } | ||
|
|
||
| // AIProvider fetches a single AI provider by ID or name. | ||
| func (c *Client) AIProvider(ctx context.Context, idOrName string) (AIProvider, error) { | ||
| res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/ai/providers/%s", idOrName), nil) | ||
| if err != nil { | ||
| return AIProvider{}, err | ||
| } | ||
| defer res.Body.Close() | ||
| if res.StatusCode != http.StatusOK { | ||
| return AIProvider{}, ReadBodyAsError(res) | ||
| } | ||
| var provider AIProvider | ||
| return provider, json.NewDecoder(res.Body).Decode(&provider) | ||
| } | ||
|
|
||
| // CreateAIProvider creates a new AI provider. | ||
| func (c *Client) CreateAIProvider(ctx context.Context, req CreateAIProviderRequest) (AIProvider, error) { | ||
| res, err := c.Request(ctx, http.MethodPost, "/api/v2/ai/providers", req) | ||
| if err != nil { | ||
| return AIProvider{}, err | ||
| } | ||
| defer res.Body.Close() | ||
| if res.StatusCode != http.StatusCreated { | ||
| return AIProvider{}, ReadBodyAsError(res) | ||
| } | ||
| var provider AIProvider | ||
| return provider, json.NewDecoder(res.Body).Decode(&provider) | ||
| } | ||
|
|
||
| // UpdateAIProvider partially updates an AI provider identified by | ||
| // ID or name. | ||
| func (c *Client) UpdateAIProvider(ctx context.Context, idOrName string, req UpdateAIProviderRequest) (AIProvider, error) { | ||
| res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/v2/ai/providers/%s", idOrName), req) | ||
| if err != nil { | ||
| return AIProvider{}, err | ||
| } | ||
| defer res.Body.Close() | ||
| if res.StatusCode != http.StatusOK { | ||
| return AIProvider{}, ReadBodyAsError(res) | ||
| } | ||
| var provider AIProvider | ||
| return provider, json.NewDecoder(res.Body).Decode(&provider) | ||
| } | ||
|
|
||
| // DeleteAIProvider soft-deletes an AI provider identified by ID or | ||
| // name. The row is preserved for audit/FK history. | ||
| func (c *Client) DeleteAIProvider(ctx context.Context, idOrName string) error { | ||
| res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/ai/providers/%s", idOrName), nil) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| defer res.Body.Close() | ||
| if res.StatusCode != http.StatusNoContent { | ||
| return ReadBodyAsError(res) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // CreateAIProviderKey registers a new API key against an AI | ||
| // provider identified by ID or name. | ||
| func (c *Client) CreateAIProviderKey(ctx context.Context, idOrName string, req CreateAIProviderKeyRequest) (AIProviderKey, error) { | ||
|
dannykopping marked this conversation as resolved.
|
||
| res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/ai/providers/%s/keys", idOrName), req) | ||
| if err != nil { | ||
| return AIProviderKey{}, err | ||
| } | ||
| defer res.Body.Close() | ||
| if res.StatusCode != http.StatusCreated { | ||
| return AIProviderKey{}, ReadBodyAsError(res) | ||
| } | ||
| var key AIProviderKey | ||
| return key, json.NewDecoder(res.Body).Decode(&key) | ||
| } | ||
|
|
||
| // DeleteAIProviderKey removes a single API key from an AI provider. | ||
| func (c *Client) DeleteAIProviderKey(ctx context.Context, idOrName string, keyID uuid.UUID) error { | ||
| res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/ai/providers/%s/keys/%s", idOrName, keyID), nil) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| defer res.Body.Close() | ||
| if res.StatusCode != http.StatusNoContent { | ||
| return ReadBodyAsError(res) | ||
| } | ||
| return nil | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| package codersdk | ||
|
|
||
| // AIProviderSettingsTypeBedrock is the _type discriminator value for | ||
| // AIProviderBedrockSettings. | ||
| const AIProviderSettingsTypeBedrock = "bedrock" | ||
|
|
||
| // AIProviderBedrockSettingsVersion is the current schema version of | ||
| // AIProviderBedrockSettings. | ||
| const AIProviderBedrockSettingsVersion = 1 | ||
|
|
||
| // AIProviderBedrockSettings configures providers that authenticate | ||
| // against AWS Bedrock. AccessKey and AccessKeySecret are write-only: | ||
| // servers strip them from GET and list responses. | ||
| type AIProviderBedrockSettings struct { | ||
| // Region is the AWS region used to construct the Bedrock endpoint | ||
| // URL when BaseURL is not set on the parent provider. | ||
| Region string `json:"region,omitempty"` | ||
| // Model is the AWS Bedrock model identifier used for primary | ||
| // requests. | ||
| Model string `json:"model,omitempty"` | ||
| // SmallFastModel is the AWS Bedrock model identifier used for | ||
| // background tasks (e.g. Claude Code's haiku-class model). | ||
| SmallFastModel string `json:"small_fast_model,omitempty"` | ||
| // AccessKey is the AWS access key ID used to authenticate against | ||
| // Bedrock. Write-only. | ||
| AccessKey string `json:"access_key,omitempty"` | ||
| // AccessKeySecret is the AWS secret access key paired with | ||
| // AccessKey. Write-only. | ||
| AccessKeySecret string `json:"access_key_secret,omitempty"` | ||
| } | ||
|
|
||
| func (AIProviderBedrockSettings) settingsType() string { | ||
| return AIProviderSettingsTypeBedrock | ||
| } | ||
|
|
||
| func (AIProviderBedrockSettings) settingsVersion() int { | ||
| return AIProviderBedrockSettingsVersion | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.