diff --git a/coderd/aigatewaycoderdkey/aigatewaycoderdkey.go b/coderd/aigatewaycoderdkey/aigatewaycoderdkey.go
new file mode 100644
index 0000000000..fbe229de29
--- /dev/null
+++ b/coderd/aigatewaycoderdkey/aigatewaycoderdkey.go
@@ -0,0 +1,45 @@
+package aigatewaycoderdkey
+
+import (
+ "github.com/google/uuid"
+ "golang.org/x/xerrors"
+
+ "github.com/coder/coder/v2/coderd/apikey"
+ "github.com/coder/coder/v2/coderd/database"
+)
+
+const (
+ visiblePrefixLength = 7
+ privateSuffixLength = 32
+
+ // KeyTypePrefix marks a key as belonging to the Coder AI Gateway.
+ KeyTypePrefix = "cgw_"
+
+ // KeyPrefixLength is the total length of the visible key prefix.
+ KeyPrefixLength = len(KeyTypePrefix) + visiblePrefixLength
+
+ // KeyLength is the total length of the plaintext key returned to
+ // the user on Create.
+ KeyLength = KeyPrefixLength + privateSuffixLength
+)
+
+// New generates an AI Gateway Coderd key. Returns InsertParams ready
+// for the database query.
+//
+// Key shape: "cgw_" + 7 random chars + 32 random chars = 43 chars total.
+func New(name string) (database.InsertAIGatewayCoderdKeyParams, string, error) {
+ secret, hashed, err := apikey.GenerateSecret(KeyLength)
+ if err != nil {
+ return database.InsertAIGatewayCoderdKeyParams{}, "", xerrors.Errorf("generate secret: %w", err)
+ }
+
+ secret = KeyTypePrefix + secret
+ visiblePrefix := secret[:KeyPrefixLength]
+
+ return database.InsertAIGatewayCoderdKeyParams{
+ ID: uuid.New(),
+ Name: name,
+ SecretPrefix: visiblePrefix,
+ HashedSecret: hashed,
+ }, secret, nil
+}
diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go
index 7aca308f9d..4793cdd487 100644
--- a/coderd/apidoc/docs.go
+++ b/coderd/apidoc/docs.go
@@ -64,6 +64,100 @@ const docTemplate = `{
}
}
},
+ "/aibridge/coderd-keys": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Enterprise"
+ ],
+ "summary": "List AI Gateway coderd keys",
+ "operationId": "list-ai-gateway-coderd-keys",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/codersdk.AIGatewayCoderdKey"
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ]
+ },
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Enterprise"
+ ],
+ "summary": "Create AI Gateway coderd key",
+ "operationId": "create-ai-gateway-coderd-key",
+ "parameters": [
+ {
+ "description": "Create AI Gateway coderd key request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/codersdk.CreateAIGatewayCoderdKeyRequest"
+ }
+ }
+ ],
+ "responses": {
+ "201": {
+ "description": "Created",
+ "schema": {
+ "$ref": "#/definitions/codersdk.CreateAIGatewayCoderdKeyResponse"
+ }
+ }
+ },
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ]
+ }
+ },
+ "/aibridge/coderd-keys/{key}": {
+ "delete": {
+ "tags": [
+ "Enterprise"
+ ],
+ "summary": "Delete AI Gateway coderd key",
+ "operationId": "delete-ai-gateway-coderd-key",
+ "parameters": [
+ {
+ "type": "string",
+ "format": "uuid",
+ "description": "Key ID",
+ "name": "key",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "No Content"
+ }
+ },
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ]
+ }
+ },
"/api/experimental/chats": {
"get": {
"description": "Experimental: this endpoint is subject to change.",
@@ -15048,6 +15142,29 @@ const docTemplate = `{
}
}
},
+ "codersdk.AIGatewayCoderdKey": {
+ "type": "object",
+ "properties": {
+ "created_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "key_prefix": {
+ "type": "string"
+ },
+ "last_used_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "name": {
+ "type": "string"
+ }
+ }
+ },
"codersdk.AIProvider": {
"type": "object",
"properties": {
@@ -17582,6 +17699,39 @@ const docTemplate = `{
}
}
},
+ "codersdk.CreateAIGatewayCoderdKeyRequest": {
+ "type": "object",
+ "required": [
+ "name"
+ ],
+ "properties": {
+ "name": {
+ "type": "string"
+ }
+ }
+ },
+ "codersdk.CreateAIGatewayCoderdKeyResponse": {
+ "type": "object",
+ "properties": {
+ "created_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "key": {
+ "type": "string"
+ },
+ "key_prefix": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ }
+ },
"codersdk.CreateAIProviderRequest": {
"type": "object",
"properties": {
diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json
index 842eac0c08..665bc1f211 100644
--- a/coderd/apidoc/swagger.json
+++ b/coderd/apidoc/swagger.json
@@ -49,6 +49,88 @@
}
}
},
+ "/aibridge/coderd-keys": {
+ "get": {
+ "produces": ["application/json"],
+ "tags": ["Enterprise"],
+ "summary": "List AI Gateway coderd keys",
+ "operationId": "list-ai-gateway-coderd-keys",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/codersdk.AIGatewayCoderdKey"
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ]
+ },
+ "post": {
+ "consumes": ["application/json"],
+ "produces": ["application/json"],
+ "tags": ["Enterprise"],
+ "summary": "Create AI Gateway coderd key",
+ "operationId": "create-ai-gateway-coderd-key",
+ "parameters": [
+ {
+ "description": "Create AI Gateway coderd key request",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/codersdk.CreateAIGatewayCoderdKeyRequest"
+ }
+ }
+ ],
+ "responses": {
+ "201": {
+ "description": "Created",
+ "schema": {
+ "$ref": "#/definitions/codersdk.CreateAIGatewayCoderdKeyResponse"
+ }
+ }
+ },
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ]
+ }
+ },
+ "/aibridge/coderd-keys/{key}": {
+ "delete": {
+ "tags": ["Enterprise"],
+ "summary": "Delete AI Gateway coderd key",
+ "operationId": "delete-ai-gateway-coderd-key",
+ "parameters": [
+ {
+ "type": "string",
+ "format": "uuid",
+ "description": "Key ID",
+ "name": "key",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "No Content"
+ }
+ },
+ "security": [
+ {
+ "CoderSessionToken": []
+ }
+ ]
+ }
+ },
"/api/experimental/chats": {
"get": {
"description": "Experimental: this endpoint is subject to change.",
@@ -13440,6 +13522,29 @@
}
}
},
+ "codersdk.AIGatewayCoderdKey": {
+ "type": "object",
+ "properties": {
+ "created_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "key_prefix": {
+ "type": "string"
+ },
+ "last_used_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "name": {
+ "type": "string"
+ }
+ }
+ },
"codersdk.AIProvider": {
"type": "object",
"properties": {
@@ -15887,6 +15992,37 @@
}
}
},
+ "codersdk.CreateAIGatewayCoderdKeyRequest": {
+ "type": "object",
+ "required": ["name"],
+ "properties": {
+ "name": {
+ "type": "string"
+ }
+ }
+ },
+ "codersdk.CreateAIGatewayCoderdKeyResponse": {
+ "type": "object",
+ "properties": {
+ "created_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "key": {
+ "type": "string"
+ },
+ "key_prefix": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ }
+ },
"codersdk.CreateAIProviderRequest": {
"type": "object",
"properties": {
diff --git a/codersdk/aigatewaycoderdkeys.go b/codersdk/aigatewaycoderdkeys.go
new file mode 100644
index 0000000000..2b33052bbf
--- /dev/null
+++ b/codersdk/aigatewaycoderdkeys.go
@@ -0,0 +1,81 @@
+package codersdk
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/google/uuid"
+ "golang.org/x/xerrors"
+)
+
+// AIGatewayCoderdKey is a shared secret used by an standalone AI Gateway
+// to authenticate into coderd.
+type AIGatewayCoderdKey struct {
+ ID uuid.UUID `json:"id" format:"uuid"`
+ Name string `json:"name"`
+ KeyPrefix string `json:"key_prefix"`
+ CreatedAt time.Time `json:"created_at" format:"date-time"`
+ LastUsedAt *time.Time `json:"last_used_at,omitempty" format:"date-time"`
+}
+
+type CreateAIGatewayCoderdKeyRequest struct {
+ Name string `json:"name" validate:"required"`
+}
+
+// CreateAIGatewayCoderdKeyResponse returns all key information.
+// Key value is only returned here and cannot be recovered afterwards.
+type CreateAIGatewayCoderdKeyResponse struct {
+ ID uuid.UUID `json:"id" format:"uuid"`
+ Name string `json:"name"`
+ Key string `json:"key"`
+ KeyPrefix string `json:"key_prefix"`
+ CreatedAt time.Time `json:"created_at" format:"date-time"`
+}
+
+// CreateAIGatewayCoderdKey creates a new AI Gateway coderd key.
+func (c *Client) CreateAIGatewayCoderdKey(ctx context.Context, req CreateAIGatewayCoderdKeyRequest) (CreateAIGatewayCoderdKeyResponse, error) {
+ res, err := c.Request(ctx, http.MethodPost, "/api/v2/aibridge/coderd-keys", req)
+ if err != nil {
+ return CreateAIGatewayCoderdKeyResponse{}, xerrors.Errorf("make request: %w", err)
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != http.StatusCreated {
+ return CreateAIGatewayCoderdKeyResponse{}, ReadBodyAsError(res)
+ }
+ var resp CreateAIGatewayCoderdKeyResponse
+ return resp, json.NewDecoder(res.Body).Decode(&resp)
+}
+
+// ListAIGatewayCoderdKeys lists all AI Gateway coderd keys.
+func (c *Client) ListAIGatewayCoderdKeys(ctx context.Context) ([]AIGatewayCoderdKey, error) {
+ res, err := c.Request(ctx, http.MethodGet, "/api/v2/aibridge/coderd-keys", nil)
+ if err != nil {
+ return nil, xerrors.Errorf("make request: %w", err)
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != http.StatusOK {
+ return nil, ReadBodyAsError(res)
+ }
+ var resp []AIGatewayCoderdKey
+ return resp, json.NewDecoder(res.Body).Decode(&resp)
+}
+
+// DeleteAIGatewayCoderdKey deletes an AI Gateway coderd key by ID.
+func (c *Client) DeleteAIGatewayCoderdKey(ctx context.Context, id uuid.UUID) error {
+ res, err := c.Request(ctx, http.MethodDelete,
+ fmt.Sprintf("/api/v2/aibridge/coderd-keys/%s", id.String()), nil)
+ if err != nil {
+ return xerrors.Errorf("make request: %w", err)
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != http.StatusNoContent {
+ return ReadBodyAsError(res)
+ }
+ return nil
+}
diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md
index ed1ce268e7..a49dc15c48 100644
--- a/docs/reference/api/enterprise.md
+++ b/docs/reference/api/enterprise.md
@@ -84,6 +84,132 @@ curl -X GET http://coder-server:8080/.well-known/oauth-protected-resource \
|--------|---------------------------------------------------------|-------------|------------------------------------------------------------------------------------------------|
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.OAuth2ProtectedResourceMetadata](schemas.md#codersdkoauth2protectedresourcemetadata) |
+## List AI Gateway coderd keys
+
+### Code samples
+
+```shell
+# Example request using curl
+curl -X GET http://coder-server:8080/aibridge/coderd-keys \
+ -H 'Accept: application/json' \
+ -H 'Coder-Session-Token: API_KEY'
+```
+
+`GET /aibridge/coderd-keys`
+
+### Example responses
+
+> 200 Response
+
+```json
+[
+ {
+ "created_at": "2019-08-24T14:15:22Z",
+ "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
+ "key_prefix": "string",
+ "last_used_at": "2019-08-24T14:15:22Z",
+ "name": "string"
+ }
+]
+```
+
+### Responses
+
+| Status | Meaning | Description | Schema |
+|--------|---------------------------------------------------------|-------------|-------------------------------------------------------------------------------|
+| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.AIGatewayCoderdKey](schemas.md#codersdkaigatewaycoderdkey) |
+
+
Response Schema
+
+Status Code **200**
+
+| Name | Type | Required | Restrictions | Description |
+|------------------|-------------------|----------|--------------|-------------|
+| `[array item]` | array | false | | |
+| `» created_at` | string(date-time) | false | | |
+| `» id` | string(uuid) | false | | |
+| `» key_prefix` | string | false | | |
+| `» last_used_at` | string(date-time) | false | | |
+| `» name` | string | false | | |
+
+To perform this operation, you must be authenticated. [Learn more](authentication.md).
+
+## Create AI Gateway coderd key
+
+### Code samples
+
+```shell
+# Example request using curl
+curl -X POST http://coder-server:8080/aibridge/coderd-keys \
+ -H 'Content-Type: application/json' \
+ -H 'Accept: application/json' \
+ -H 'Coder-Session-Token: API_KEY'
+```
+
+`POST /aibridge/coderd-keys`
+
+> Body parameter
+
+```json
+{
+ "name": "string"
+}
+```
+
+### Parameters
+
+| Name | In | Type | Required | Description |
+|--------|------|------------------------------------------------------------------------------------------------|----------|--------------------------------------|
+| `body` | body | [codersdk.CreateAIGatewayCoderdKeyRequest](schemas.md#codersdkcreateaigatewaycoderdkeyrequest) | true | Create AI Gateway coderd key request |
+
+### Example responses
+
+> 201 Response
+
+```json
+{
+ "created_at": "2019-08-24T14:15:22Z",
+ "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
+ "key": "string",
+ "key_prefix": "string",
+ "name": "string"
+}
+```
+
+### Responses
+
+| Status | Meaning | Description | Schema |
+|--------|--------------------------------------------------------------|-------------|--------------------------------------------------------------------------------------------------|
+| 201 | [Created](https://tools.ietf.org/html/rfc7231#section-6.3.2) | Created | [codersdk.CreateAIGatewayCoderdKeyResponse](schemas.md#codersdkcreateaigatewaycoderdkeyresponse) |
+
+To perform this operation, you must be authenticated. [Learn more](authentication.md).
+
+## Delete AI Gateway coderd key
+
+### Code samples
+
+```shell
+# Example request using curl
+curl -X DELETE http://coder-server:8080/aibridge/coderd-keys/{key} \
+ -H 'Coder-Session-Token: API_KEY'
+```
+
+`DELETE /aibridge/coderd-keys/{key}`
+
+### Parameters
+
+| Name | In | Type | Required | Description |
+|-------|------|--------------|----------|-------------|
+| `key` | path | string(uuid) | true | Key ID |
+
+### Responses
+
+| Status | Meaning | Description | Schema |
+|--------|-----------------------------------------------------------------|-------------|--------|
+| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | |
+
+To perform this operation, you must be authenticated. [Learn more](authentication.md).
+
## Get appearance
### Code samples
diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md
index 47268ba974..56a0bf6454 100644
--- a/docs/reference/api/schemas.md
+++ b/docs/reference/api/schemas.md
@@ -1248,6 +1248,28 @@
| `bridge` | [codersdk.AIBridgeConfig](#codersdkaibridgeconfig) | false | | |
| `chat` | [codersdk.ChatConfig](#codersdkchatconfig) | false | | |
+## codersdk.AIGatewayCoderdKey
+
+```json
+{
+ "created_at": "2019-08-24T14:15:22Z",
+ "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
+ "key_prefix": "string",
+ "last_used_at": "2019-08-24T14:15:22Z",
+ "name": "string"
+}
+```
+
+### Properties
+
+| Name | Type | Required | Restrictions | Description |
+|----------------|--------|----------|--------------|-------------|
+| `created_at` | string | false | | |
+| `id` | string | false | | |
+| `key_prefix` | string | false | | |
+| `last_used_at` | string | false | | |
+| `name` | string | false | | |
+
## codersdk.AIProvider
```json
@@ -4406,6 +4428,42 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `password` | string | true | | |
| `to_type` | [codersdk.LoginType](#codersdklogintype) | true | | To type is the login type to convert to. |
+## codersdk.CreateAIGatewayCoderdKeyRequest
+
+```json
+{
+ "name": "string"
+}
+```
+
+### Properties
+
+| Name | Type | Required | Restrictions | Description |
+|--------|--------|----------|--------------|-------------|
+| `name` | string | true | | |
+
+## codersdk.CreateAIGatewayCoderdKeyResponse
+
+```json
+{
+ "created_at": "2019-08-24T14:15:22Z",
+ "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
+ "key": "string",
+ "key_prefix": "string",
+ "name": "string"
+}
+```
+
+### Properties
+
+| Name | Type | Required | Restrictions | Description |
+|--------------|--------|----------|--------------|-------------|
+| `created_at` | string | false | | |
+| `id` | string | false | | |
+| `key` | string | false | | |
+| `key_prefix` | string | false | | |
+| `name` | string | false | | |
+
## codersdk.CreateAIProviderRequest
```json
diff --git a/enterprise/coderd/aigatewaycoderdkeys.go b/enterprise/coderd/aigatewaycoderdkeys.go
new file mode 100644
index 0000000000..898c14b409
--- /dev/null
+++ b/enterprise/coderd/aigatewaycoderdkeys.go
@@ -0,0 +1,182 @@
+package coderd
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "net/http"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/google/uuid"
+
+ "github.com/coder/coder/v2/coderd/aigatewaycoderdkey"
+ "github.com/coder/coder/v2/coderd/database"
+ "github.com/coder/coder/v2/coderd/httpapi"
+ "github.com/coder/coder/v2/codersdk"
+)
+
+// maxKeyInsertAttempts caps retries when a generated secret collides.
+// Collisions are astronomically unlikely; this is a safety net.
+const maxKeyInsertAttempts = 7
+
+// nameFormatDetail is the human-readable description of valid key names.
+const nameFormatDetail = "Must be 64 characters or fewer, lowercase letters, numbers, and non-consecutive hyphens, cannot start or end with a hyphen."
+
+// @Summary Create AI Gateway coderd key
+// @ID create-ai-gateway-coderd-key
+// @Security CoderSessionToken
+// @Accept json
+// @Produce json
+// @Tags Enterprise
+// @Param request body codersdk.CreateAIGatewayCoderdKeyRequest true "Create AI Gateway coderd key request"
+// @Success 201 {object} codersdk.CreateAIGatewayCoderdKeyResponse
+// @Router /aibridge/coderd-keys [post]
+func (api *API) postAIGatewayCoderdKey(rw http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+
+ var req codersdk.CreateAIGatewayCoderdKeyRequest
+ if !httpapi.Read(ctx, rw, r, &req) {
+ return
+ }
+
+ row, secret, err := api.generateAndInsertKey(ctx, req.Name)
+ for attempt := 1; isRetryableKeyInsertErr(err) && attempt < maxKeyInsertAttempts; attempt++ {
+ row, secret, err = api.generateAndInsertKey(ctx, req.Name)
+ }
+ if err != nil {
+ writeKeyInsertError(ctx, rw, err)
+ return
+ }
+
+ httpapi.Write(ctx, rw, http.StatusCreated, codersdk.CreateAIGatewayCoderdKeyResponse{
+ ID: row.ID,
+ Name: row.Name,
+ KeyPrefix: row.SecretPrefix,
+ CreatedAt: row.CreatedAt,
+ Key: secret,
+ })
+}
+
+// generateAndInsertKey creates fresh key material and attempts an insert.
+func (api *API) generateAndInsertKey(ctx context.Context, name string) (database.InsertAIGatewayCoderdKeyRow, string, error) {
+ params, key, err := aigatewaycoderdkey.New(name)
+ if err != nil {
+ return database.InsertAIGatewayCoderdKeyRow{}, "", err
+ }
+ row, err := api.Database.InsertAIGatewayCoderdKey(ctx, params)
+ if err != nil {
+ return database.InsertAIGatewayCoderdKeyRow{}, "", err
+ }
+ return row, key, nil
+}
+
+// isRetryableKeyInsertErr returns true for generated-secret collisions.
+func isRetryableKeyInsertErr(err error) bool {
+ return database.IsUniqueViolation(err,
+ database.UniqueAiGatewayCoderdKeysSecretPrefixIndex,
+ database.UniqueAiGatewayCoderdKeysHashedSecretIndex,
+ )
+}
+
+// writeKeyInsertError maps insert errors to HTTP responses.
+func writeKeyInsertError(ctx context.Context, rw http.ResponseWriter, err error) {
+ switch {
+ case httpapi.IsUnauthorizedError(err):
+ httpapi.Forbidden(rw)
+ case database.IsCheckViolation(err, database.CheckAiGatewayCoderdKeysNameCheck):
+ httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
+ Message: "Invalid key name.",
+ Validations: []codersdk.ValidationError{
+ {Field: "name", Detail: nameFormatDetail},
+ },
+ })
+ case database.IsUniqueViolation(err, database.UniqueAiGatewayCoderdKeysNameIndex):
+ httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
+ Message: "Key name must be unique.",
+ Validations: []codersdk.ValidationError{
+ {Field: "name", Detail: "A key with this name already exists."},
+ },
+ })
+ default:
+ httpapi.InternalServerError(rw, err)
+ }
+}
+
+// @Summary List AI Gateway coderd keys
+// @ID list-ai-gateway-coderd-keys
+// @Security CoderSessionToken
+// @Produce json
+// @Tags Enterprise
+// @Success 200 {array} codersdk.AIGatewayCoderdKey
+// @Router /aibridge/coderd-keys [get]
+func (api *API) aiGatewayCoderdKeys(rw http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+
+ rows, err := api.Database.ListAIGatewayCoderdKeys(ctx)
+ if httpapi.IsUnauthorizedError(err) {
+ httpapi.Forbidden(rw)
+ return
+ }
+ if err != nil {
+ httpapi.InternalServerError(rw, err)
+ return
+ }
+
+ out := make([]codersdk.AIGatewayCoderdKey, 0, len(rows))
+ for _, row := range rows {
+ out = append(out, convertAIGatewayCoderdKey(row))
+ }
+
+ httpapi.Write(ctx, rw, http.StatusOK, out)
+}
+
+// @Summary Delete AI Gateway coderd key
+// @ID delete-ai-gateway-coderd-key
+// @Security CoderSessionToken
+// @Tags Enterprise
+// @Param key path string true "Key ID" format(uuid)
+// @Success 204
+// @Router /aibridge/coderd-keys/{key} [delete]
+func (api *API) deleteAIGatewayCoderdKey(rw http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+
+ id, err := uuid.Parse(chi.URLParam(r, "key"))
+ if err != nil {
+ httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
+ Message: "Invalid key ID",
+ Detail: err.Error(),
+ })
+ return
+ }
+
+ if _, err := api.Database.DeleteAIGatewayCoderdKey(ctx, id); err != nil {
+ if httpapi.IsUnauthorizedError(err) {
+ httpapi.Forbidden(rw)
+ return
+ }
+ if errors.Is(err, sql.ErrNoRows) {
+ httpapi.ResourceNotFound(rw)
+ return
+ }
+ httpapi.InternalServerError(rw, err)
+ return
+ }
+
+ httpapi.Write(ctx, rw, http.StatusNoContent, nil)
+}
+
+func convertAIGatewayCoderdKey(row database.ListAIGatewayCoderdKeysRow) codersdk.AIGatewayCoderdKey {
+ var lastUsed *time.Time
+ if row.LastUsedAt.Valid {
+ t := row.LastUsedAt.Time
+ lastUsed = &t
+ }
+ return codersdk.AIGatewayCoderdKey{
+ ID: row.ID,
+ Name: row.Name,
+ KeyPrefix: row.SecretPrefix,
+ CreatedAt: row.CreatedAt,
+ LastUsedAt: lastUsed,
+ }
+}
diff --git a/enterprise/coderd/aigatewaycoderdkeys_test.go b/enterprise/coderd/aigatewaycoderdkeys_test.go
new file mode 100644
index 0000000000..9114f6476a
--- /dev/null
+++ b/enterprise/coderd/aigatewaycoderdkeys_test.go
@@ -0,0 +1,191 @@
+package coderd_test
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/stretchr/testify/require"
+
+ "github.com/coder/coder/v2/coderd/aigatewaycoderdkey"
+ "github.com/coder/coder/v2/coderd/coderdtest"
+ "github.com/coder/coder/v2/codersdk"
+ "github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
+ "github.com/coder/coder/v2/enterprise/coderd/license"
+ "github.com/coder/coder/v2/testutil"
+ "github.com/coder/serpent"
+)
+
+// aibridgeEnabledOpts returns coderdenttest options that fully enable AI
+// Bridge: feature entitlement + deployment config flag.
+func aibridgeEnabledOpts(t *testing.T) *coderdenttest.Options {
+ t.Helper()
+ dv := coderdtest.DeploymentValues(t)
+ dv.AI.BridgeConfig.Enabled = serpent.Bool(true)
+ return &coderdenttest.Options{
+ Options: &coderdtest.Options{DeploymentValues: dv},
+ LicenseOptions: &coderdenttest.LicenseOptions{
+ Features: license.Features{codersdk.FeatureAIBridge: 1},
+ },
+ }
+}
+
+func TestAIGatewayCoderdKeys(t *testing.T) {
+ t.Parallel()
+
+ // Single instance shared by all subtests (except FeatureGate).
+ // Subtests run sequentially because they share server state.
+ ctx := testutil.Context(t, testutil.WaitLong)
+ ownerClient, owner := coderdenttest.New(t, aibridgeEnabledOpts(t))
+
+ //nolint:paralleltest // Subtests share a single coderdenttest instance.
+ t.Run("CRUD", func(t *testing.T) {
+ keys, err := ownerClient.ListAIGatewayCoderdKeys(ctx)
+ require.NoError(t, err)
+ require.Empty(t, keys)
+
+ name := uniqueName(t, "happy")
+
+ created, err := ownerClient.CreateAIGatewayCoderdKey(ctx, codersdk.CreateAIGatewayCoderdKeyRequest{Name: name})
+ require.NoError(t, err)
+ require.NotEqual(t, uuid.Nil, created.ID)
+ require.Equal(t, name, created.Name)
+ require.Len(t, created.KeyPrefix, aigatewaycoderdkey.KeyPrefixLength)
+ require.Len(t, created.Key, aigatewaycoderdkey.KeyLength)
+ require.True(t, strings.HasPrefix(created.KeyPrefix, aigatewaycoderdkey.KeyTypePrefix), "key_prefix must start with %q, got %q", aigatewaycoderdkey.KeyTypePrefix, created.KeyPrefix)
+ require.True(t, strings.HasPrefix(created.Key, created.KeyPrefix), "key must begin with key_prefix")
+ require.WithinDuration(t, time.Now(), created.CreatedAt, time.Minute)
+
+ keys, err = ownerClient.ListAIGatewayCoderdKeys(ctx)
+ require.NoError(t, err)
+ require.Len(t, keys, 1)
+ require.Equal(t, created.ID, keys[0].ID)
+ require.Equal(t, created.Name, keys[0].Name)
+ require.Equal(t, created.KeyPrefix, keys[0].KeyPrefix)
+ require.Nil(t, keys[0].LastUsedAt)
+
+ require.NoError(t, ownerClient.DeleteAIGatewayCoderdKey(ctx, created.ID))
+
+ keys, err = ownerClient.ListAIGatewayCoderdKeys(ctx)
+ require.NoError(t, err)
+ require.Empty(t, keys)
+ })
+
+ //nolint:paralleltest // Subtests share a single coderdenttest instance.
+ t.Run("ListResponseDoesNotLeakSecrets", func(t *testing.T) {
+ created, err := ownerClient.CreateAIGatewayCoderdKey(ctx, codersdk.CreateAIGatewayCoderdKeyRequest{
+ Name: uniqueName(t, "leak"),
+ })
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ _ = ownerClient.DeleteAIGatewayCoderdKey(ctx, created.ID)
+ })
+
+ // Raw HTTP read of LIST to confirm the JSON shape.
+ resp, err := ownerClient.Request(ctx, http.MethodGet, "/api/v2/aibridge/coderd-keys", nil)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = resp.Body.Close() })
+ require.Equal(t, http.StatusOK, resp.StatusCode)
+
+ var raw []map[string]any
+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&raw))
+ require.NotEmpty(t, raw)
+ _, hasSecret := raw[0]["secret"]
+ _, hasHashed := raw[0]["hashed_secret"]
+ require.False(t, hasSecret, "LIST response leaked plaintext secret")
+ require.False(t, hasHashed, "LIST response leaked hashed_secret")
+ })
+
+ //nolint:paralleltest // Subtests share a single coderdenttest instance.
+ t.Run("CreateValidation", func(t *testing.T) {
+ // Empty name -> 400 (validate:"required" on request struct).
+ _, err := ownerClient.CreateAIGatewayCoderdKey(ctx, codersdk.CreateAIGatewayCoderdKeyRequest{Name: ""})
+ require.ErrorContains(t, err, "Validation failed")
+
+ // >64 char name -> 400 (DB check constraint).
+ longName := strings.Repeat("a", 65)
+ _, err = ownerClient.CreateAIGatewayCoderdKey(ctx, codersdk.CreateAIGatewayCoderdKeyRequest{Name: longName})
+ require.ErrorContains(t, err, "Invalid key name")
+
+ // Uppercase name -> 400 (DB check constraint rejects non-lowercase).
+ _, err = ownerClient.CreateAIGatewayCoderdKey(ctx, codersdk.CreateAIGatewayCoderdKeyRequest{Name: "UPPER-CASE"})
+ require.ErrorContains(t, err, "Invalid key name")
+
+ // Duplicate name -> 400.
+ name := uniqueName(t, "dup")
+ created, err := ownerClient.CreateAIGatewayCoderdKey(ctx, codersdk.CreateAIGatewayCoderdKeyRequest{Name: name})
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ _ = ownerClient.DeleteAIGatewayCoderdKey(ctx, created.ID)
+ })
+ _, err = ownerClient.CreateAIGatewayCoderdKey(ctx, codersdk.CreateAIGatewayCoderdKeyRequest{Name: name})
+ require.ErrorContains(t, err, "must be unique")
+ })
+
+ //nolint:paralleltest // Subtests share a single coderdenttest instance.
+ t.Run("DeleteValidation", func(t *testing.T) {
+ // Invalid UUID -> 400 (raw request; SDK method accepts uuid.UUID).
+ resp, err := ownerClient.Request(ctx, http.MethodDelete, "/api/v2/aibridge/coderd-keys/not-a-uuid", nil)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = resp.Body.Close() })
+ require.Equal(t, http.StatusBadRequest, resp.StatusCode)
+
+ // Delete existing key -> 204 (SDK returns nil error on 204).
+ created, err := ownerClient.CreateAIGatewayCoderdKey(ctx, codersdk.CreateAIGatewayCoderdKeyRequest{
+ Name: uniqueName(t, "del"),
+ })
+ require.NoError(t, err)
+ require.NoError(t, ownerClient.DeleteAIGatewayCoderdKey(ctx, created.ID))
+
+ // Unknown UUID -> 404.
+ err = ownerClient.DeleteAIGatewayCoderdKey(ctx, uuid.New())
+ var sdkErr *codersdk.Error
+ require.ErrorAs(t, err, &sdkErr)
+ require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
+ })
+
+ //nolint:paralleltest // Subtests share a single coderdenttest instance.
+ t.Run("ReturnsForbiddenForNonOwners", func(t *testing.T) {
+ member, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
+
+ _, err := member.CreateAIGatewayCoderdKey(ctx, codersdk.CreateAIGatewayCoderdKeyRequest{
+ Name: uniqueName(t, "denied"),
+ })
+ var sdkErr *codersdk.Error
+ require.ErrorAs(t, err, &sdkErr)
+ require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
+
+ _, err = member.ListAIGatewayCoderdKeys(ctx)
+ require.ErrorAs(t, err, &sdkErr)
+ require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
+
+ err = member.DeleteAIGatewayCoderdKey(ctx, uuid.New())
+ require.ErrorAs(t, err, &sdkErr)
+ require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
+ })
+
+ // FeatureGate needs a separate instance without the AI Bridge entitlement.
+ t.Run("FeatureGate", func(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t, testutil.WaitLong)
+ ownerClient, _ := coderdenttest.New(t, &coderdenttest.Options{
+ LicenseOptions: &coderdenttest.LicenseOptions{
+ Features: license.Features{},
+ },
+ })
+
+ //nolint:gocritic // Managing AI Gateway coderd keys is owner-only.
+ _, err := ownerClient.ListAIGatewayCoderdKeys(ctx)
+ require.Error(t, err)
+ })
+}
+
+func uniqueName(t *testing.T, prefix string) string {
+ t.Helper()
+ return strings.ToLower(fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano()))
+}
diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go
index 33732eea3d..e42bca4367 100644
--- a/enterprise/coderd/coderd.go
+++ b/enterprise/coderd/coderd.go
@@ -298,6 +298,18 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
r.Route("/aibridge/proxy", aibridgeproxyHandler(api, apiKeyMiddleware))
})
+ api.AGPL.APIHandler.Group(func(r chi.Router) {
+ r.Route("/aibridge/coderd-keys", func(r chi.Router) {
+ r.Use(
+ apiKeyMiddleware,
+ api.RequireFeatureMW(codersdk.FeatureAIBridge),
+ )
+ r.Get("/", api.aiGatewayCoderdKeys)
+ r.Post("/", api.postAIGatewayCoderdKey)
+ r.Delete("/{key}", api.deleteAIGatewayCoderdKey)
+ })
+ })
+
api.AGPL.APIHandler.Group(func(r chi.Router) {
r.Get("/entitlements", api.serveEntitlements)
// /regions overrides the AGPL /regions endpoint
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index a0aad00206..edb3de1a6a 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -304,6 +304,19 @@ export interface AIConfig {
readonly chat?: ChatConfig;
}
+// From codersdk/aigatewaycoderdkeys.go
+/**
+ * AIGatewayCoderdKey is a shared secret used by an standalone AI Gateway
+ * to authenticate into coderd.
+ */
+export interface AIGatewayCoderdKey {
+ readonly id: string;
+ readonly name: string;
+ readonly key_prefix: string;
+ readonly created_at: string;
+ readonly last_used_at?: string;
+}
+
// From codersdk/aiproviders.go
/**
* AIProvider represents an AI provider configuration row as returned
@@ -3244,6 +3257,24 @@ export interface ConvertLoginRequest {
readonly password: string;
}
+// From codersdk/aigatewaycoderdkeys.go
+export interface CreateAIGatewayCoderdKeyRequest {
+ readonly name: string;
+}
+
+// From codersdk/aigatewaycoderdkeys.go
+/**
+ * CreateAIGatewayCoderdKeyResponse returns all key information.
+ * Key value is only returned here and cannot be recovered afterwards.
+ */
+export interface CreateAIGatewayCoderdKeyResponse {
+ readonly id: string;
+ readonly name: string;
+ readonly key: string;
+ readonly key_prefix: string;
+ readonly created_at: string;
+}
+
// From codersdk/aiproviders.go
/**
* CreateAIProviderRequest is the payload for creating a new AI