mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: add AI Gateway coderd key CRUD endpoints
Adds POST / GET / DELETE handlers for /api/v2/aibridge/coderd-keys under the existing AI Bridge feature gate. Create returns the plaintext secret exactly once (cgw_<random>... format); list returns metadata plus a short non-secret token_prefix so admins can correlate keys with the daemons presenting them. Delete identifies keys by UUID in the path and is idempotent.
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
Generated
+150
@@ -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": {
|
"/api/experimental/chats": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Experimental: this endpoint is subject to change.",
|
"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": {
|
"codersdk.AIProvider": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"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": {
|
"codersdk.CreateAIProviderRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
Generated
+136
@@ -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": {
|
"/api/experimental/chats": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Experimental: this endpoint is subject to change.",
|
"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": {
|
"codersdk.AIProvider": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"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": {
|
"codersdk.CreateAIProviderRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Generated
+126
@@ -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) |
|
| 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) |
|
||||||
|
|
||||||
|
<h3 id="list-ai-gateway-coderd-keys-responseschema">Response Schema</h3>
|
||||||
|
|
||||||
|
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
|
## Get appearance
|
||||||
|
|
||||||
### Code samples
|
### Code samples
|
||||||
|
|||||||
Generated
+58
@@ -1248,6 +1248,28 @@
|
|||||||
| `bridge` | [codersdk.AIBridgeConfig](#codersdkaibridgeconfig) | false | | |
|
| `bridge` | [codersdk.AIBridgeConfig](#codersdkaibridgeconfig) | false | | |
|
||||||
| `chat` | [codersdk.ChatConfig](#codersdkchatconfig) | 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
|
## codersdk.AIProvider
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -4406,6 +4428,42 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
|||||||
| `password` | string | true | | |
|
| `password` | string | true | | |
|
||||||
| `to_type` | [codersdk.LoginType](#codersdklogintype) | true | | To type is the login type to convert to. |
|
| `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
|
## codersdk.CreateAIProviderRequest
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()))
|
||||||
|
}
|
||||||
@@ -298,6 +298,18 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
|
|||||||
r.Route("/aibridge/proxy", aibridgeproxyHandler(api, apiKeyMiddleware))
|
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) {
|
api.AGPL.APIHandler.Group(func(r chi.Router) {
|
||||||
r.Get("/entitlements", api.serveEntitlements)
|
r.Get("/entitlements", api.serveEntitlements)
|
||||||
// /regions overrides the AGPL /regions endpoint
|
// /regions overrides the AGPL /regions endpoint
|
||||||
|
|||||||
Generated
+31
@@ -304,6 +304,19 @@ export interface AIConfig {
|
|||||||
readonly chat?: ChatConfig;
|
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
|
// From codersdk/aiproviders.go
|
||||||
/**
|
/**
|
||||||
* AIProvider represents an AI provider configuration row as returned
|
* AIProvider represents an AI provider configuration row as returned
|
||||||
@@ -3244,6 +3257,24 @@ export interface ConvertLoginRequest {
|
|||||||
readonly password: string;
|
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
|
// From codersdk/aiproviders.go
|
||||||
/**
|
/**
|
||||||
* CreateAIProviderRequest is the payload for creating a new AI
|
* CreateAIProviderRequest is the payload for creating a new AI
|
||||||
|
|||||||
Reference in New Issue
Block a user