mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
fix: load AI Gateway session provider filter without AIProvider read (#28966)
> 🤖 This PR was modified by Coder Agents on behalf of Jake Howell.
The Provider filter on `/ai-gateway` called the owner-only
provider-config API (`GET /api/v2/ai/providers`). Site auditors can read
sessions but not that config, so the dropdown was empty, and typing
`provider_name:…` failed the whole sessions list because search looked
up the name with the same permission.
- Add `GET /api/v2/ai-gateway/providers`, returning display metadata
only (`name`, `type`, `display_name`, `icon`) from `ai_providers`,
including disabled and soft-deleted rows that past sessions still
reference. When a name was reused, the live row wins.
- Authorize it in `dbauthz` on `aibridge_interception` read rather than
`ai_provider` read, so owners and auditors get labels and icons without
access to configuration. Members get 404.
- Match `provider_name` as a plain string in session search instead of
looking up `ai_providers`. Unknown names return an empty page, not a
validation error.
- Point the Provider dropdown at the new endpoint; filter by name, show
`display_name`, and render the icon from `type`/`icon`.
The endpoint reads the small `ai_providers` table instead of grouping
`aibridge_interceptions`, so it needs no new index and does not inherit
the scan cost noted for the models/clients lists. It is registered only
under `/ai-gateway`, not the legacy `/aibridge` alias.
This commit is contained in:
Generated
+45
@@ -788,6 +788,34 @@ const docTemplate = `{
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v2/ai-gateway/providers": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"AI Gateway"
|
||||
],
|
||||
"summary": "List AI Gateway providers",
|
||||
"operationId": "list-ai-gateway-providers",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.AIBridgeProvider"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v2/ai-gateway/serve": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -17663,6 +17691,23 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.AIBridgeProvider": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"display_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"icon": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "#/definitions/codersdk.AIProviderType"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.AIBridgeProxyConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
Generated
+41
@@ -691,6 +691,30 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v2/ai-gateway/providers": {
|
||||
"get": {
|
||||
"produces": ["application/json"],
|
||||
"tags": ["AI Gateway"],
|
||||
"summary": "List AI Gateway providers",
|
||||
"operationId": "list-ai-gateway-providers",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.AIBridgeProvider"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v2/ai-gateway/serve": {
|
||||
"get": {
|
||||
"tags": ["Enterprise"],
|
||||
@@ -15737,6 +15761,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.AIBridgeProvider": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"display_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"icon": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "#/definitions/codersdk.AIProviderType"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.AIBridgeProxyConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -2954,6 +2954,18 @@ func (q *querier) GetAIProviderByName(ctx context.Context, name string) (databas
|
||||
return q.db.GetAIProviderByName(ctx, name)
|
||||
}
|
||||
|
||||
func (q *querier) GetAIProviderFilterOptions(ctx context.Context) ([]database.GetAIProviderFilterOptionsRow, error) {
|
||||
// This query returns display metadata only (name, type, display name,
|
||||
// icon), never configuration or secrets. It exists so callers who can
|
||||
// read AI Gateway interceptions deployment-wide (owners and auditors)
|
||||
// can label the provider_name values they already see on those
|
||||
// interceptions, so it is gated on interception read, not AIProvider read.
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAibridgeInterception); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.db.GetAIProviderFilterOptions(ctx)
|
||||
}
|
||||
|
||||
func (q *querier) GetAIProviderKeyByID(ctx context.Context, id uuid.UUID) (database.AIProviderKey, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAIProvider); err != nil {
|
||||
return database.AIProviderKey{}, err
|
||||
|
||||
@@ -7254,6 +7254,13 @@ func (s *MethodTestSuite) TestAIBridge() {
|
||||
dbm.EXPECT().GetAIProviderByName(gomock.Any(), provider.Name).Return(provider, nil).AnyTimes()
|
||||
check.Args(provider.Name).Asserts(rbac.ResourceAIProvider, policy.ActionRead).Returns(provider)
|
||||
}))
|
||||
s.Run("GetAIProviderFilterOptions", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
options := []database.GetAIProviderFilterOptionsRow{
|
||||
testutil.Fake(s.T(), faker, database.GetAIProviderFilterOptionsRow{}),
|
||||
}
|
||||
dbm.EXPECT().GetAIProviderFilterOptions(gomock.Any()).Return(options, nil).AnyTimes()
|
||||
check.Args().Asserts(rbac.ResourceAibridgeInterception, policy.ActionRead).Returns(options)
|
||||
}))
|
||||
s.Run("GetAIProviders", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
providerA := testutil.Fake(s.T(), faker, database.AIProvider{})
|
||||
providerB := testutil.Fake(s.T(), faker, database.AIProvider{})
|
||||
|
||||
+8
@@ -1201,6 +1201,14 @@ func (m queryMetricsStore) GetAIProviderByName(ctx context.Context, name string)
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetAIProviderFilterOptions(ctx context.Context) ([]database.GetAIProviderFilterOptionsRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetAIProviderFilterOptions(ctx)
|
||||
m.queryLatencies.WithLabelValues("GetAIProviderFilterOptions").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetAIProviderFilterOptions").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetAIProviderKeyByID(ctx context.Context, id uuid.UUID) (database.AIProviderKey, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetAIProviderKeyByID(ctx, id)
|
||||
|
||||
Generated
+15
@@ -2100,6 +2100,21 @@ func (mr *MockStoreMockRecorder) GetAIProviderByName(ctx, name any) *gomock.Call
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAIProviderByName", reflect.TypeOf((*MockStore)(nil).GetAIProviderByName), ctx, name)
|
||||
}
|
||||
|
||||
// GetAIProviderFilterOptions mocks base method.
|
||||
func (m *MockStore) GetAIProviderFilterOptions(ctx context.Context) ([]database.GetAIProviderFilterOptionsRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetAIProviderFilterOptions", ctx)
|
||||
ret0, _ := ret[0].([]database.GetAIProviderFilterOptionsRow)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetAIProviderFilterOptions indicates an expected call of GetAIProviderFilterOptions.
|
||||
func (mr *MockStoreMockRecorder) GetAIProviderFilterOptions(ctx any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAIProviderFilterOptions", reflect.TypeOf((*MockStore)(nil).GetAIProviderFilterOptions), ctx)
|
||||
}
|
||||
|
||||
// GetAIProviderKeyByID mocks base method.
|
||||
func (m *MockStore) GetAIProviderKeyByID(ctx context.Context, id uuid.UUID) (database.AIProviderKey, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
Generated
+5
@@ -355,6 +355,11 @@ type sqlcQuerier interface {
|
||||
// between validation and writing the model config reference.
|
||||
GetAIProviderByIDForReferenceLock(ctx context.Context, id uuid.UUID) (AIProvider, error)
|
||||
GetAIProviderByName(ctx context.Context, name string) (AIProvider, error)
|
||||
// Returns the display metadata AI Gateway session viewers need to filter
|
||||
// interceptions by provider_name. Soft-deleted and disabled rows are
|
||||
// included because interceptions keep referencing them. When a name has
|
||||
// been reused, the live row wins so current metadata is shown.
|
||||
GetAIProviderFilterOptions(ctx context.Context) ([]GetAIProviderFilterOptionsRow, error)
|
||||
GetAIProviderKeyByID(ctx context.Context, id uuid.UUID) (AIProviderKey, error)
|
||||
// Returns the provider IDs that have at least one provider-scoped key.
|
||||
GetAIProviderKeyPresence(ctx context.Context, providerIds []uuid.UUID) ([]uuid.UUID, error)
|
||||
|
||||
Generated
+53
@@ -660,6 +660,59 @@ func (q *sqlQuerier) GetAIProviderByName(ctx context.Context, name string) (AIPr
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getAIProviderFilterOptions = `-- name: GetAIProviderFilterOptions :many
|
||||
SELECT DISTINCT ON (name)
|
||||
name,
|
||||
type,
|
||||
display_name,
|
||||
icon
|
||||
FROM
|
||||
ai_providers
|
||||
ORDER BY
|
||||
name ASC,
|
||||
deleted ASC,
|
||||
updated_at DESC
|
||||
`
|
||||
|
||||
type GetAIProviderFilterOptionsRow struct {
|
||||
Name string `db:"name" json:"name"`
|
||||
Type AIProviderType `db:"type" json:"type"`
|
||||
DisplayName sql.NullString `db:"display_name" json:"display_name"`
|
||||
Icon string `db:"icon" json:"icon"`
|
||||
}
|
||||
|
||||
// Returns the display metadata AI Gateway session viewers need to filter
|
||||
// interceptions by provider_name. Soft-deleted and disabled rows are
|
||||
// included because interceptions keep referencing them. When a name has
|
||||
// been reused, the live row wins so current metadata is shown.
|
||||
func (q *sqlQuerier) GetAIProviderFilterOptions(ctx context.Context) ([]GetAIProviderFilterOptionsRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getAIProviderFilterOptions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetAIProviderFilterOptionsRow
|
||||
for rows.Next() {
|
||||
var i GetAIProviderFilterOptionsRow
|
||||
if err := rows.Scan(
|
||||
&i.Name,
|
||||
&i.Type,
|
||||
&i.DisplayName,
|
||||
&i.Icon,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getAIProviders = `-- name: GetAIProviders :many
|
||||
SELECT
|
||||
id, type, name, display_name, enabled, deleted, base_url, settings, settings_key_id, created_at, updated_at, icon
|
||||
|
||||
@@ -39,6 +39,23 @@ WHERE
|
||||
ORDER BY
|
||||
name ASC;
|
||||
|
||||
-- name: GetAIProviderFilterOptions :many
|
||||
-- Returns the display metadata AI Gateway session viewers need to filter
|
||||
-- interceptions by provider_name. Soft-deleted and disabled rows are
|
||||
-- included because interceptions keep referencing them. When a name has
|
||||
-- been reused, the live row wins so current metadata is shown.
|
||||
SELECT DISTINCT ON (name)
|
||||
name,
|
||||
type,
|
||||
display_name,
|
||||
icon
|
||||
FROM
|
||||
ai_providers
|
||||
ORDER BY
|
||||
name ASC,
|
||||
deleted ASC,
|
||||
updated_at DESC;
|
||||
|
||||
-- name: InsertAIProvider :one
|
||||
INSERT INTO ai_providers (
|
||||
id,
|
||||
|
||||
@@ -415,7 +415,9 @@ func AIBridgeSessions(ctx context.Context, db database.Store, query string, page
|
||||
parser := httpapi.NewQueryParamParser()
|
||||
filter.InitiatorID = parseUser(ctx, db, parser, values, "initiator", actorID)
|
||||
filter.Provider = parser.String(values, "", "provider")
|
||||
filter.ProviderName = parseAIProviderName(ctx, db, parser, values)
|
||||
// Match interceptions by name. Do not look up ai_providers: that requires
|
||||
// AIProvider read, which session viewers do not have.
|
||||
filter.ProviderName = parser.String(values, "", "provider_name")
|
||||
filter.Model = parser.String(values, "", "model")
|
||||
filter.Client = parser.String(values, "", "client")
|
||||
filter.SessionID = parser.String(values, "", "session_id")
|
||||
@@ -706,24 +708,6 @@ func parseOrganization(ctx context.Context, db database.Store, parser *httpapi.Q
|
||||
})
|
||||
}
|
||||
|
||||
// parseAIProviderName resolves a "provider_name" filter param against
|
||||
// ai_providers.name. Unknown names produce a validation error so typos
|
||||
// surface immediately rather than returning a silently-empty result set.
|
||||
func parseAIProviderName(ctx context.Context, db database.Store, parser *httpapi.QueryParamParser, vals url.Values) string {
|
||||
name := parser.String(vals, "", "provider_name")
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
if _, err := db.GetAIProviderByName(ctx, name); err != nil {
|
||||
parser.Errors = append(parser.Errors, codersdk.ValidationError{
|
||||
Field: "provider_name",
|
||||
Detail: `Query param "provider_name" has invalid value: provider not found or unauthorized`,
|
||||
})
|
||||
return ""
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func parseUser(ctx context.Context, db database.Store, parser *httpapi.QueryParamParser, vals url.Values, queryParam string, actorID uuid.UUID) uuid.UUID {
|
||||
return httpapi.ParseCustom(parser, vals, uuid.Nil, queryParam, func(v string) (uuid.UUID, error) {
|
||||
if v == "" {
|
||||
|
||||
@@ -1678,3 +1678,14 @@ func TestSearchGroups(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIBridgeSessions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
page := codersdk.Pagination{Limit: 25}
|
||||
|
||||
filter, errs := searchquery.AIBridgeSessions(context.Background(), db, `provider_name:acme-openai`, page, uuid.Nil, "")
|
||||
require.Empty(t, errs)
|
||||
require.Equal(t, "acme-openai", filter.ProviderName)
|
||||
}
|
||||
|
||||
@@ -410,6 +410,32 @@ func (c *Client) AIBridgeListClients(ctx context.Context) ([]string, error) {
|
||||
return clients, ReadBodyAsJSON(res, &clients)
|
||||
}
|
||||
|
||||
// AIBridgeProvider is the display metadata for a configured AI provider,
|
||||
// used to filter AI Gateway sessions by provider_name. It carries no
|
||||
// configuration so it can be served to anyone who can read sessions.
|
||||
type AIBridgeProvider struct {
|
||||
Name string `json:"name"`
|
||||
Type AIProviderType `json:"type"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Icon string `json:"icon"`
|
||||
}
|
||||
|
||||
// AIBridgeListProviders returns the providers available for filtering AI
|
||||
// Gateway sessions, including disabled and deleted ones that past sessions
|
||||
// may still reference.
|
||||
func (c *Client) AIBridgeListProviders(ctx context.Context) ([]AIBridgeProvider, error) {
|
||||
res, err := c.Request(ctx, http.MethodGet, "/api/v2/ai-gateway/providers", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, ReadBodyAsError(res)
|
||||
}
|
||||
var providers []AIBridgeProvider
|
||||
return providers, ReadBodyAsJSON(res, &providers)
|
||||
}
|
||||
|
||||
// OrganizationAISpendFilter narrows the organization per-user AI spend
|
||||
// report. Zero values apply no filter: the period falls back to the current
|
||||
// budget period on the server, and an empty dimension matches all usage.
|
||||
|
||||
Generated
+54
@@ -75,6 +75,60 @@ Alias: also available at /api/v2/aibridge/models for backward compatibility.
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## List AI Gateway providers
|
||||
|
||||
### Code samples
|
||||
|
||||
```sh
|
||||
# Example request using curl
|
||||
curl -X GET http://coder-server:8080/api/v2/ai-gateway/providers \
|
||||
-H 'Accept: application/json' \
|
||||
-H 'Coder-Session-Token: API_KEY'
|
||||
```
|
||||
|
||||
`GET /api/v2/ai-gateway/providers`
|
||||
|
||||
### Example responses
|
||||
|
||||
> 200 Response
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"display_name": "string",
|
||||
"icon": "string",
|
||||
"name": "string",
|
||||
"type": "openai"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|---------------------------------------------------------|-------------|---------------------------------------------------------------------------|
|
||||
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.AIBridgeProvider](schemas.md#codersdkaibridgeprovider) |
|
||||
|
||||
<h3 id="list-ai-gateway-providers-responseschema">Response Schema</h3>
|
||||
|
||||
Status Code **200**
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|------------------|--------------------------------------------------------------|----------|--------------|-------------|
|
||||
| `[array item]` | array | false | | |
|
||||
| `» display_name` | string | false | | |
|
||||
| `» icon` | string | false | | |
|
||||
| `» name` | string | false | | |
|
||||
| `» type` | [codersdk.AIProviderType](schemas.md#codersdkaiprovidertype) | false | | |
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
| Property | Value(s) |
|
||||
|----------|---------------------------------------------------------------------------------------------------------|
|
||||
| `type` | `anthropic`, `azure`, `bedrock`, `copilot`, `google`, `openai`, `openai-compat`, `openrouter`, `vercel` |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## List AI Gateway sessions
|
||||
|
||||
### Code samples
|
||||
|
||||
Generated
+20
@@ -474,6 +474,26 @@ title: Schemas
|
||||
|--------|--------|----------|--------------|-------------|
|
||||
| `text` | string | false | | |
|
||||
|
||||
## codersdk.AIBridgeProvider
|
||||
|
||||
```json
|
||||
{
|
||||
"display_name": "string",
|
||||
"icon": "string",
|
||||
"name": "string",
|
||||
"type": "openai"
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|----------------|----------------------------------------------------|----------|--------------|-------------|
|
||||
| `display_name` | string | false | | |
|
||||
| `icon` | string | false | | |
|
||||
| `name` | string | false | | |
|
||||
| `type` | [codersdk.AIProviderType](#codersdkaiprovidertype) | false | | |
|
||||
|
||||
## codersdk.AIBridgeProxyConfig
|
||||
|
||||
```json
|
||||
|
||||
@@ -609,6 +609,48 @@ func (api *API) aiBridgeListClients(rw http.ResponseWriter, r *http.Request) {
|
||||
httpapi.Write(ctx, rw, http.StatusOK, clients)
|
||||
}
|
||||
|
||||
// aiBridgeListProviders returns the provider metadata used to filter AI
|
||||
// Gateway sessions by provider_name. It reads ai_providers rather than
|
||||
// scanning interceptions so the response stays cheap on large deployments,
|
||||
// and is authorized on interception read (see dbauthz), so session viewers
|
||||
// get labels and icons without access to provider configuration.
|
||||
//
|
||||
// @Summary List AI Gateway providers
|
||||
// @ID list-ai-gateway-providers
|
||||
// @Security CoderSessionToken
|
||||
// @Produce json
|
||||
// @Tags AI Gateway
|
||||
// @Success 200 {array} codersdk.AIBridgeProvider
|
||||
// @Router /api/v2/ai-gateway/providers [get]
|
||||
func (api *API) aiBridgeListProviders(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
rows, err := api.Database.GetAIProviderFilterOptions(ctx)
|
||||
if httpapi.Is404Error(err) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error getting AI Gateway providers.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
providers := make([]codersdk.AIBridgeProvider, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
providers = append(providers, codersdk.AIBridgeProvider{
|
||||
Name: row.Name,
|
||||
Type: codersdk.AIProviderType(row.Type),
|
||||
DisplayName: row.DisplayName.String,
|
||||
Icon: row.Icon,
|
||||
})
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, providers)
|
||||
}
|
||||
|
||||
// validateInterceptionCursor checks that a pagination cursor refers to an
|
||||
// existing interception. When sessionID is non-empty the interception must
|
||||
// also belong to that session. Returns errInvalidCursor on failure so
|
||||
|
||||
@@ -475,20 +475,22 @@ func TestAIBridgeListSessions(t *testing.T) {
|
||||
// Session from user1 with provider "anthropic" and client "claude-code".
|
||||
s1EndedAt := now.Add(time.Minute)
|
||||
s1 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
||||
InitiatorID: firstUser.UserID,
|
||||
Provider: "anthropic",
|
||||
Model: "claude-4",
|
||||
StartedAt: now,
|
||||
Client: sql.NullString{String: "claude-code", Valid: true},
|
||||
InitiatorID: firstUser.UserID,
|
||||
Provider: "anthropic",
|
||||
ProviderName: "anthropic-prod",
|
||||
Model: "claude-4",
|
||||
StartedAt: now,
|
||||
Client: sql.NullString{String: "claude-code", Valid: true},
|
||||
}, &s1EndedAt)
|
||||
|
||||
// Session from user2 with provider "openai".
|
||||
s2EndedAt := now.Add(-time.Hour + time.Minute)
|
||||
s2 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
||||
InitiatorID: user2.ID,
|
||||
Provider: "openai",
|
||||
Model: "gpt-4",
|
||||
StartedAt: now.Add(-time.Hour),
|
||||
InitiatorID: user2.ID,
|
||||
Provider: "openai",
|
||||
ProviderName: "openai-prod",
|
||||
Model: "gpt-4",
|
||||
StartedAt: now.Add(-time.Hour),
|
||||
}, &s2EndedAt)
|
||||
|
||||
// Filter by initiator.
|
||||
@@ -508,6 +510,22 @@ func TestAIBridgeListSessions(t *testing.T) {
|
||||
require.EqualValues(t, 1, res.Count)
|
||||
require.Equal(t, s1.ID.String(), res.Sessions[0].ID)
|
||||
|
||||
// Filter by provider_name. Unknown names return an empty page,
|
||||
// not a validation error.
|
||||
res, err = client.AIBridgeListSessions(ctx, codersdk.AIBridgeListSessionsFilter{
|
||||
ProviderName: "anthropic-prod",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, res.Count)
|
||||
require.Equal(t, s1.ID.String(), res.Sessions[0].ID)
|
||||
|
||||
res, err = client.AIBridgeListSessions(ctx, codersdk.AIBridgeListSessionsFilter{
|
||||
ProviderName: "does-not-exist",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 0, res.Count)
|
||||
require.Empty(t, res.Sessions)
|
||||
|
||||
// Filter by model.
|
||||
res, err = client.AIBridgeListSessions(ctx, codersdk.AIBridgeListSessionsFilter{
|
||||
Model: "gpt-4",
|
||||
@@ -1366,6 +1384,166 @@ func TestAIBridgeListClients(t *testing.T) {
|
||||
}, clients)
|
||||
}
|
||||
|
||||
func TestAIBridgeListProviders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("RequiresLicenseFeature", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dv := coderdtest.DeploymentValues(t)
|
||||
client, _ := coderdenttest.New(t, &coderdenttest.Options{
|
||||
Options: &coderdtest.Options{
|
||||
DeploymentValues: dv,
|
||||
},
|
||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||
Features: license.Features{},
|
||||
},
|
||||
})
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
//nolint:gocritic // Owner role is irrelevant here.
|
||||
_, err := client.AIBridgeListProviders(ctx)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("NotAliasedUnderAIBridge", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, _ := coderdenttest.New(t, aibridgeOpts(t))
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
//nolint:gocritic // Owner role is irrelevant here.
|
||||
res, err := client.Request(ctx, http.MethodGet, "/api/v2/aibridge/providers", nil)
|
||||
require.NoError(t, err)
|
||||
defer res.Body.Close()
|
||||
require.Equal(t, http.StatusNotFound, res.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("ReturnsMetadataIncludingDeletedAndDisabled", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, db, _ := coderdenttest.NewWithDatabase(t, aibridgeOpts(t))
|
||||
|
||||
dbgen.AIProvider(t, db, database.AIProvider{
|
||||
Type: database.AIProviderTypeAnthropic,
|
||||
Name: "anthropic-prod",
|
||||
DisplayName: sql.NullString{String: "Anthropic (prod)", Valid: true},
|
||||
Icon: "/icon/custom.svg",
|
||||
})
|
||||
dbgen.AIProvider(t, db, database.AIProvider{
|
||||
Type: database.AIProviderTypeOpenai,
|
||||
Name: "openai-disabled",
|
||||
Enabled: false,
|
||||
})
|
||||
deleted := dbgen.AIProvider(t, db, database.AIProvider{
|
||||
Type: database.AIProviderTypeOpenai,
|
||||
Name: "openai-old",
|
||||
})
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
require.NoError(t, db.DeleteAIProviderByID(dbauthz.AsSystemRestricted(ctx), deleted.ID))
|
||||
|
||||
providers, err := client.AIBridgeListProviders(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []codersdk.AIBridgeProvider{
|
||||
{Name: "anthropic-prod", Type: codersdk.AIProviderTypeAnthropic, DisplayName: "Anthropic (prod)", Icon: "/icon/custom.svg"},
|
||||
{Name: "openai-disabled", Type: codersdk.AIProviderTypeOpenAI, DisplayName: "openai-disabled"},
|
||||
{Name: "openai-old", Type: codersdk.AIProviderTypeOpenAI, DisplayName: "openai-old"},
|
||||
}, providers)
|
||||
})
|
||||
|
||||
t.Run("ReusedNamePrefersLiveRow", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, db, _ := coderdenttest.NewWithDatabase(t, aibridgeOpts(t))
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
old := dbgen.AIProvider(t, db, database.AIProvider{
|
||||
Type: database.AIProviderTypeOpenai,
|
||||
Name: "shared",
|
||||
DisplayName: sql.NullString{String: "Old", Valid: true},
|
||||
})
|
||||
require.NoError(t, db.DeleteAIProviderByID(dbauthz.AsSystemRestricted(ctx), old.ID))
|
||||
dbgen.AIProvider(t, db, database.AIProvider{
|
||||
Type: database.AIProviderTypeAnthropic,
|
||||
Name: "shared",
|
||||
DisplayName: sql.NullString{String: "New", Valid: true},
|
||||
})
|
||||
|
||||
providers, err := client.AIBridgeListProviders(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []codersdk.AIBridgeProvider{
|
||||
{Name: "shared", Type: codersdk.AIProviderTypeAnthropic, DisplayName: "New"},
|
||||
}, providers)
|
||||
})
|
||||
|
||||
t.Run("EmptyIsArray", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, _ := coderdenttest.New(t, aibridgeOpts(t))
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
//nolint:gocritic // Owner role is irrelevant here.
|
||||
res, err := client.Request(ctx, http.MethodGet, "/api/v2/ai-gateway/providers", nil)
|
||||
require.NoError(t, err)
|
||||
defer res.Body.Close()
|
||||
require.Equal(t, http.StatusOK, res.StatusCode)
|
||||
body, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, "[]", string(body))
|
||||
})
|
||||
|
||||
t.Run("AuditorWithoutAIProviderRead", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adminClient, db, firstUser := coderdenttest.NewWithDatabase(t, aibridgeOpts(t))
|
||||
auditorClient, auditorUser := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID, rbac.RoleAuditor())
|
||||
|
||||
dbgen.AIProvider(t, db, database.AIProvider{
|
||||
Type: database.AIProviderTypeAnthropic,
|
||||
Name: "anthropic-prod",
|
||||
})
|
||||
now := dbtime.Now()
|
||||
endedAt := now.Add(time.Minute)
|
||||
dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
||||
InitiatorID: auditorUser.ID,
|
||||
Provider: "anthropic",
|
||||
ProviderName: "anthropic-prod",
|
||||
StartedAt: now,
|
||||
}, &endedAt)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
// Auditors cannot read provider configuration.
|
||||
_, err := auditorClient.AIProviders(ctx)
|
||||
require.Error(t, err)
|
||||
|
||||
providers, err := auditorClient.AIBridgeListProviders(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, providers, 1)
|
||||
require.Equal(t, "anthropic-prod", providers[0].Name)
|
||||
|
||||
sessions, err := auditorClient.AIBridgeListSessions(ctx, codersdk.AIBridgeListSessionsFilter{
|
||||
ProviderName: "anthropic-prod",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, sessions.Count)
|
||||
})
|
||||
|
||||
t.Run("MemberDenied", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adminClient, firstUser := coderdenttest.New(t, aibridgeOpts(t))
|
||||
memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
_, err := memberClient.AIBridgeListProviders(ctx)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
||||
})
|
||||
}
|
||||
|
||||
func TestAIBridgeRouting(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -315,6 +315,16 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
|
||||
r.Route("/ai-gateway/proxy", aiGatewayProxyHTTPHandler(api, apiKeyMiddleware))
|
||||
})
|
||||
|
||||
api.AGPL.APIHandler.Group(func(r chi.Router) {
|
||||
r.Route("/ai-gateway/providers", func(r chi.Router) {
|
||||
r.Use(
|
||||
apiKeyMiddleware,
|
||||
api.RequireFeatureMW(codersdk.FeatureAIBridge),
|
||||
)
|
||||
r.Get("/", api.aiBridgeListProviders)
|
||||
})
|
||||
})
|
||||
|
||||
api.AGPL.APIHandler.Group(func(r chi.Router) {
|
||||
r.Route("/ai-gateway/keys", func(r chi.Router) {
|
||||
r.Use(
|
||||
|
||||
@@ -3067,6 +3067,13 @@ class ApiMethods {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
getAIBridgeProviders = async () => {
|
||||
const response = await this.axios.get<TypesGen.AIBridgeProvider[]>(
|
||||
`${aiGatewayPath}/providers`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
getAIBridgeSessionList = async (options: SearchParamOptions) => {
|
||||
const url = getURLWithSearchParams(`${aiGatewayPath}/sessions`, options);
|
||||
const response =
|
||||
|
||||
Generated
+13
@@ -72,6 +72,19 @@ export interface AIBridgeModelThought {
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
// From codersdk/aibridge.go
|
||||
/**
|
||||
* AIBridgeProvider is the display metadata for a configured AI provider,
|
||||
* used to filter AI Gateway sessions by provider_name. It carries no
|
||||
* configuration so it can be served to anyone who can read sessions.
|
||||
*/
|
||||
export interface AIBridgeProvider {
|
||||
readonly name: string;
|
||||
readonly type: AIProviderType;
|
||||
readonly display_name: string;
|
||||
readonly icon: string;
|
||||
}
|
||||
|
||||
// From codersdk/deployment.go
|
||||
export interface AIBridgeProxyConfig {
|
||||
readonly enabled: boolean;
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
expect,
|
||||
screen,
|
||||
spyOn,
|
||||
userEvent,
|
||||
waitFor,
|
||||
within,
|
||||
} from "storybook/test";
|
||||
import { API } from "#/api/api";
|
||||
import type { AIBridgeProvider } from "#/api/typesGenerated";
|
||||
import { withDesktopViewport } from "#/testHelpers/storybook";
|
||||
import { ProviderFilter, useProviderFilterMenu } from "./ProviderFilter";
|
||||
|
||||
const providers: AIBridgeProvider[] = [
|
||||
{
|
||||
name: "anthropic-prod",
|
||||
type: "anthropic",
|
||||
display_name: "Anthropic (prod)",
|
||||
icon: "",
|
||||
},
|
||||
{
|
||||
name: "openai-prod",
|
||||
type: "openai",
|
||||
display_name: "",
|
||||
icon: "",
|
||||
},
|
||||
{
|
||||
name: "acme-gateway",
|
||||
type: "openai-compat",
|
||||
display_name: "Acme Gateway",
|
||||
icon: "/emojis/1f9ea.png",
|
||||
},
|
||||
];
|
||||
|
||||
function ProviderFilterWithMenu({ value: initialValue }: { value?: string }) {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const menu = useProviderFilterMenu({
|
||||
value,
|
||||
onChange: (option) => setValue(option?.value),
|
||||
});
|
||||
return <ProviderFilter menu={menu} />;
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "pages/AIBridgePage/ProviderFilter",
|
||||
component: ProviderFilterWithMenu,
|
||||
decorators: [withDesktopViewport],
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getAIBridgeProviders").mockResolvedValue(providers);
|
||||
spyOn(API.experimental, "listAIProviders").mockRejectedValue(
|
||||
new Error("listAIProviders should not be called"),
|
||||
);
|
||||
},
|
||||
} satisfies Meta<typeof ProviderFilterWithMenu>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ProviderFilterWithMenu>;
|
||||
|
||||
export const Open: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Select provider" }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
// Display name wins, and falls back to the configured name.
|
||||
expect(
|
||||
screen.getByRole("option", { name: /Anthropic \(prod\)/ }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("option", { name: /openai-prod/ }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("option", { name: /Acme Gateway/ }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(API.getAIBridgeProviders).toHaveBeenCalled();
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getAIBridgeProviders").mockResolvedValue([]);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Select provider" }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No providers found")).toBeVisible();
|
||||
});
|
||||
expect(screen.queryByRole("option")).toBeNull();
|
||||
},
|
||||
};
|
||||
|
||||
export const Preselected: Story = {
|
||||
args: { value: "anthropic-prod" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
canvas.getByRole("button", { name: "Select provider" }),
|
||||
).toHaveTextContent("Anthropic (prod)");
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const SelectingOption: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const button = canvas.getByRole("button", { name: "Select provider" });
|
||||
await userEvent.click(button);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("option", { name: /openai-prod/ }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
await userEvent.click(screen.getByRole("option", { name: /openai-prod/ }));
|
||||
await waitFor(() => {
|
||||
expect(button).toHaveTextContent("openai-prod");
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const Searching: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Select provider" }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("option", { name: /openai-prod/ }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
await userEvent.type(
|
||||
screen.getByPlaceholderText("Search provider..."),
|
||||
"acme",
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("option", { name: /Acme Gateway/ }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole("option", { name: /openai-prod/ })).toBeNull();
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FC } from "react";
|
||||
import { API } from "#/api/api";
|
||||
import type { AIProvider } from "#/api/typesGenerated";
|
||||
import type { AIBridgeProvider } from "#/api/typesGenerated";
|
||||
import { ComboboxInput } from "#/components/Combobox/Combobox";
|
||||
import {
|
||||
type UseFilterMenuOptions,
|
||||
useFilterMenu,
|
||||
@@ -9,14 +10,12 @@ import {
|
||||
SelectFilter,
|
||||
type SelectFilterOption,
|
||||
} from "#/components/Filter/SelectFilter";
|
||||
import { AIBridgeProviderIcon } from "../icons/AIBridgeProviderIcon";
|
||||
import { ProviderIcon } from "#/modules/aiModels/ProviderIcon";
|
||||
|
||||
const toFilterOption = (provider: AIProvider): SelectFilterOption => ({
|
||||
const toFilterOption = (provider: AIBridgeProvider): SelectFilterOption => ({
|
||||
value: provider.name,
|
||||
label: provider.display_name || provider.name,
|
||||
startIcon: (
|
||||
<AIBridgeProviderIcon provider={provider.type} className="size-icon-sm" />
|
||||
),
|
||||
startIcon: <ProviderIcon provider={provider.type} icon={provider.icon} />,
|
||||
});
|
||||
|
||||
export const useProviderFilterMenu = ({
|
||||
@@ -30,12 +29,14 @@ export const useProviderFilterMenu = ({
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const providers = await API.experimental.listAIProviders();
|
||||
const providers = await API.getAIBridgeProviders();
|
||||
const match = providers.find((p) => p.name === value);
|
||||
return match ? toFilterOption(match) : null;
|
||||
},
|
||||
// The provider list is small and useFilterMenu filters options
|
||||
// client-side by label and value, so the query is not sent upstream.
|
||||
getOptions: async () => {
|
||||
const providers = await API.experimental.listAIProviders();
|
||||
const providers = await API.getAIBridgeProviders();
|
||||
return providers.map(toFilterOption);
|
||||
},
|
||||
value,
|
||||
@@ -61,6 +62,13 @@ export const ProviderFilter: FC<ProviderFilterProps> = ({ menu, width }) => {
|
||||
onSelect={(option) => menu.selectOption(option)}
|
||||
selectedOption={menu.selectedOption ?? undefined}
|
||||
width={width}
|
||||
selectFilterSearch={
|
||||
<ComboboxInput
|
||||
placeholder="Search provider..."
|
||||
value={menu.query}
|
||||
onValueChange={menu.setQuery}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user