diff --git a/coderd/aibridge/keys/keys.go b/coderd/aibridge/keys/keys.go new file mode 100644 index 0000000000..7b9545d3d1 --- /dev/null +++ b/coderd/aibridge/keys/keys.go @@ -0,0 +1,43 @@ +package keys + +import ( + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/apikey" + "github.com/coder/coder/v2/coderd/database" +) + +const ( + privateSuffixLength = 32 + + // KeyPrefixLength is the total length of the visible key prefix. + KeyPrefixLength = 11 + + // KeyLength is the total length of the plaintext key returned to + // the user on Create. + KeyLength = KeyPrefixLength + privateSuffixLength +) + +// New generates an AI Gateway key used for authenticating standalone replicas. +// Returns InsertParams ready for the database query. +func New(name string) (database.InsertAIGatewayKeyParams, string, error) { + secret, hashed, err := apikey.GenerateSecret(KeyLength) + if err != nil { + return database.InsertAIGatewayKeyParams{}, "", xerrors.Errorf("generate secret: %w", err) + } + if len(secret) != KeyLength { + return database.InsertAIGatewayKeyParams{}, "", xerrors.Errorf("generated secret has unexpected length: got %d, want %d", len(secret), KeyLength) + } + if KeyLength < KeyPrefixLength { + return database.InsertAIGatewayKeyParams{}, "", xerrors.Errorf("KeyLength (%d) must be >= KeyPrefixLength (%d)", KeyLength, KeyPrefixLength) + } + visiblePrefix := secret[:KeyPrefixLength] + + return database.InsertAIGatewayKeyParams{ + ID: uuid.New(), + Name: name, + SecretPrefix: visiblePrefix, + HashedSecret: hashed, + }, secret, nil +} diff --git a/coderd/aibridge/keys/keys_test.go b/coderd/aibridge/keys/keys_test.go new file mode 100644 index 0000000000..c6ad3bc033 --- /dev/null +++ b/coderd/aibridge/keys/keys_test.go @@ -0,0 +1,22 @@ +package keys_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/aibridge/keys" + "github.com/coder/coder/v2/coderd/apikey" +) + +func TestNew(t *testing.T) { + t.Parallel() + + params, key, err := keys.New("test-key") + require.NoError(t, err) + require.Len(t, key, keys.KeyLength) + require.Len(t, params.SecretPrefix, keys.KeyPrefixLength) + require.Equal(t, key[:keys.KeyPrefixLength], params.SecretPrefix) + require.True(t, apikey.ValidateHash(params.HashedSecret, key)) + require.False(t, apikey.ValidateHash(params.HashedSecret, key[keys.KeyPrefixLength:])) +} diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 7aca308f9d..f4c6ce20fb 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1474,6 +1474,100 @@ const docTemplate = `{ ] } }, + "/api/v2/aibridge/keys": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "List AI Gateway keys", + "operationId": "list-ai-gateway-keys", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.AIGatewayKey" + } + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Create AI Gateway key", + "operationId": "create-ai-gateway-key", + "parameters": [ + { + "description": "Create AI Gateway key request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.CreateAIGatewayKeyRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/codersdk.CreateAIGatewayKeyResponse" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, + "/api/v2/aibridge/keys/{key}": { + "delete": { + "tags": [ + "Enterprise" + ], + "summary": "Delete AI Gateway key", + "operationId": "delete-ai-gateway-key", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Key ID", + "name": "key", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/v2/aibridge/models": { "get": { "produces": [ @@ -15048,6 +15142,29 @@ const docTemplate = `{ } } }, + "codersdk.AIGatewayKey": { + "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.CreateAIGatewayKeyRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + } + }, + "codersdk.CreateAIGatewayKeyResponse": { + "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..029b0dec8c 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1303,6 +1303,88 @@ ] } }, + "/api/v2/aibridge/keys": { + "get": { + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "List AI Gateway keys", + "operationId": "list-ai-gateway-keys", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.AIGatewayKey" + } + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + }, + "post": { + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Create AI Gateway key", + "operationId": "create-ai-gateway-key", + "parameters": [ + { + "description": "Create AI Gateway key request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.CreateAIGatewayKeyRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/codersdk.CreateAIGatewayKeyResponse" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, + "/api/v2/aibridge/keys/{key}": { + "delete": { + "tags": ["Enterprise"], + "summary": "Delete AI Gateway key", + "operationId": "delete-ai-gateway-key", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Key ID", + "name": "key", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/v2/aibridge/models": { "get": { "produces": ["application/json"], @@ -13440,6 +13522,29 @@ } } }, + "codersdk.AIGatewayKey": { + "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.CreateAIGatewayKeyRequest": { + "type": "object", + "required": ["name"], + "properties": { + "name": { + "type": "string" + } + } + }, + "codersdk.CreateAIGatewayKeyResponse": { + "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/database/querier_test.go b/coderd/database/querier_test.go index 12db95ad9d..3a6e2ac639 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -14740,13 +14740,13 @@ func TestAIGatewayKeysTableConstraints(t *testing.T) { db, _ := dbtestutil.NewDB(t) ctx := testutil.Context(t, testutil.WaitMedium) - preExsiting := database.InsertAIGatewayKeyParams{ + preExisting := database.InsertAIGatewayKeyParams{ ID: uuid.New(), Name: "name", - SecretPrefix: "cgw_test__1", + SecretPrefix: "key_test__1", HashedSecret: []byte("first-secret"), } - _, err := db.InsertAIGatewayKey(ctx, preExsiting) + _, err := db.InsertAIGatewayKey(ctx, preExisting) require.NoError(t, err) tests := []struct { @@ -14757,67 +14757,67 @@ func TestAIGatewayKeysTableConstraints(t *testing.T) { }{ { name: "duplicate name", - params: aiGatewayKeyParams(preExsiting.Name, "cgw_test002"), + params: aiGatewayKeyParams(preExisting.Name, "key_test002"), expectUniqueErr: database.UniqueAiGatewayKeysNameIndex, }, { name: "duplicate secret prefix", - params: aiGatewayKeyParams("different-key", preExsiting.SecretPrefix), + params: aiGatewayKeyParams("different-key", preExisting.SecretPrefix), expectUniqueErr: database.UniqueAiGatewayKeysSecretPrefixIndex, }, { name: "duplicate hashed secret", - params: database.InsertAIGatewayKeyParams{ID: uuid.New(), Name: "other-name", SecretPrefix: "cgw_1234567", HashedSecret: preExsiting.HashedSecret}, + params: database.InsertAIGatewayKeyParams{ID: uuid.New(), Name: "other-name", SecretPrefix: "key_1234567", HashedSecret: preExisting.HashedSecret}, expectUniqueErr: database.UniqueAiGatewayKeysHashedSecretIndex, }, { name: "empty name", - params: aiGatewayKeyParams("", "cgw_1234567"), + params: aiGatewayKeyParams("", "key_empty__"), expectCheckErr: database.CheckAiGatewayKeysNameCheck, }, { name: "name with trailing dash", - params: aiGatewayKeyParams("other-name-", "cgw_1234567"), + params: aiGatewayKeyParams("other-name-", "key_trail__"), expectCheckErr: database.CheckAiGatewayKeysNameCheck, }, { name: "name with consecutive dashes", - params: aiGatewayKeyParams("other--name", "cgw_1234567"), + params: aiGatewayKeyParams("other--name", "key_consec_"), expectCheckErr: database.CheckAiGatewayKeysNameCheck, }, { name: "name with underscore", - params: aiGatewayKeyParams("other_name", "cgw_1234567"), + params: aiGatewayKeyParams("other_name", "key_undersc"), expectCheckErr: database.CheckAiGatewayKeysNameCheck, }, { name: "name with space", - params: aiGatewayKeyParams("other name", "cgw_1234567"), + params: aiGatewayKeyParams("other name", "key_spacen_"), expectCheckErr: database.CheckAiGatewayKeysNameCheck, }, { name: "name with leading dash", - params: aiGatewayKeyParams("-other-name", "cgw_1234567"), + params: aiGatewayKeyParams("-other-name", "key_leadng_"), expectCheckErr: database.CheckAiGatewayKeysNameCheck, }, { name: "name longer than 64 characters", - params: aiGatewayKeyParams(strings.Repeat("a", 65), "cgw_1234567"), + params: aiGatewayKeyParams(strings.Repeat("a", 65), "key_longna_"), expectCheckErr: database.CheckAiGatewayKeysNameCheck, }, { name: "empty secret prefix", - params: aiGatewayKeyParams("other-name", ""), + params: aiGatewayKeyParams("check-empty-pfx", ""), expectCheckErr: database.CheckAiGatewayKeysSecretPrefixCheck, }, { name: "invalid secret prefix length", - params: aiGatewayKeyParams("other-name", "cgw_short"), + params: aiGatewayKeyParams("check-short-pfx", "key_short"), expectCheckErr: database.CheckAiGatewayKeysSecretPrefixCheck, }, { name: "empty hashed secret", - params: database.InsertAIGatewayKeyParams{ID: uuid.New(), Name: "other-name", SecretPrefix: "cgw_1234567"}, + params: database.InsertAIGatewayKeyParams{ID: uuid.New(), Name: "check-empty-hash", SecretPrefix: "key_ehash__", HashedSecret: []byte{}}, expectCheckErr: database.CheckAiGatewayKeysHashedSecretCheck, }, } @@ -14841,8 +14841,8 @@ func TestAIGatewayKeysQueries(t *testing.T) { db, _ := dbtestutil.NewDB(t) ctx := testutil.Context(t, testutil.WaitLong) - first := aiGatewayKeyParams("first-key", "cgw_first__") - second := aiGatewayKeyParams("second-key", "cgw_second_") + first := aiGatewayKeyParams("first-key", "key_first__") + second := aiGatewayKeyParams("second-key", "key_second_") second.HashedSecret = []byte("second-secret") firstRow, err := db.InsertAIGatewayKey(ctx, first) @@ -14889,7 +14889,7 @@ func aiGatewayKeyParams(name string, secretPrefix string) database.InsertAIGatew ID: uuid.New(), Name: name, SecretPrefix: secretPrefix, - HashedSecret: []byte("secret"), + HashedSecret: []byte("secret-" + name + "-" + secretPrefix), } } diff --git a/codersdk/aigatewaykeys.go b/codersdk/aigatewaykeys.go new file mode 100644 index 0000000000..7c4eb1c7a1 --- /dev/null +++ b/codersdk/aigatewaykeys.go @@ -0,0 +1,82 @@ +package codersdk + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/google/uuid" + "golang.org/x/xerrors" +) + +// AIGatewayKey is a shared secret used by a standalone AI Gateway +// to authenticate into coderd. +type AIGatewayKey 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"` +} + +// CreateAIGatewayKeyRequest requests a new AI Gateway key. +type CreateAIGatewayKeyRequest struct { + Name string `json:"name" validate:"required"` +} + +// CreateAIGatewayKeyResponse returns all key information. +// Key value is only returned here and cannot be recovered afterwards. +type CreateAIGatewayKeyResponse 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"` +} + +// CreateAIGatewayKey creates a new AI Gateway key. +func (c *Client) CreateAIGatewayKey(ctx context.Context, req CreateAIGatewayKeyRequest) (CreateAIGatewayKeyResponse, error) { + res, err := c.Request(ctx, http.MethodPost, "/api/v2/aibridge/keys", req) + if err != nil { + return CreateAIGatewayKeyResponse{}, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusCreated { + return CreateAIGatewayKeyResponse{}, ReadBodyAsError(res) + } + var resp CreateAIGatewayKeyResponse + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + +// ListAIGatewayKeys lists all AI Gateway keys. +func (c *Client) ListAIGatewayKeys(ctx context.Context) ([]AIGatewayKey, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/v2/aibridge/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 []AIGatewayKey + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + +// DeleteAIGatewayKey deletes an AI Gateway key by ID. +func (c *Client) DeleteAIGatewayKey(ctx context.Context, id uuid.UUID) error { + res, err := c.Request(ctx, http.MethodDelete, + fmt.Sprintf("/api/v2/aibridge/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..c2d193aa32 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 keys + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/aibridge/keys \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /api/v2/aibridge/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.AIGatewayKey](schemas.md#codersdkaigatewaykey) | + +

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 key + +### Code samples + +```shell +# Example request using curl +curl -X POST http://coder-server:8080/api/v2/aibridge/keys \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`POST /api/v2/aibridge/keys` + +> Body parameter + +```json +{ + "name": "string" +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +|--------|------|------------------------------------------------------------------------------------|----------|-------------------------------| +| `body` | body | [codersdk.CreateAIGatewayKeyRequest](schemas.md#codersdkcreateaigatewaykeyrequest) | true | Create AI Gateway 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.CreateAIGatewayKeyResponse](schemas.md#codersdkcreateaigatewaykeyresponse) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Delete AI Gateway key + +### Code samples + +```shell +# Example request using curl +curl -X DELETE http://coder-server:8080/api/v2/aibridge/keys/{key} \ + -H 'Coder-Session-Token: API_KEY' +``` + +`DELETE /api/v2/aibridge/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..91db75c177 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.AIGatewayKey + +```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.CreateAIGatewayKeyRequest + +```json +{ + "name": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|--------|--------|----------|--------------|-------------| +| `name` | string | true | | | + +## codersdk.CreateAIGatewayKeyResponse + +```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/aigatewaykeys.go b/enterprise/coderd/aigatewaykeys.go new file mode 100644 index 0000000000..0e81f7d7dc --- /dev/null +++ b/enterprise/coderd/aigatewaykeys.go @@ -0,0 +1,212 @@ +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/aibridge/keys" + "github.com/coder/coder/v2/coderd/audit" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" +) + +// 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 key +// @ID create-ai-gateway-key +// @Security CoderSessionToken +// @Accept json +// @Produce json +// @Tags Enterprise +// @Param request body codersdk.CreateAIGatewayKeyRequest true "Create AI Gateway key request" +// @Success 201 {object} codersdk.CreateAIGatewayKeyResponse +// @Router /api/v2/aibridge/keys [post] +func (api *API) postAIGatewayKey(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + auditor = api.AGPL.Auditor.Load() + aReq, commitAudit = audit.InitRequest[database.AIGatewayKey](rw, &audit.RequestParams{ + Audit: *auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionCreate, + }) + ) + defer commitAudit() + + var req codersdk.CreateAIGatewayKeyRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + row, secret, err := api.generateAndInsertKey(ctx, req.Name) + if err != nil { + writeKeyInsertError(ctx, rw, err) + return + } + + aReq.New = database.AIGatewayKey{ + ID: row.ID, + Name: row.Name, + SecretPrefix: row.SecretPrefix, + CreatedAt: row.CreatedAt, + } + + httpapi.Write(ctx, rw, http.StatusCreated, codersdk.CreateAIGatewayKeyResponse{ + 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.InsertAIGatewayKeyRow, string, error) { + params, key, err := keys.New(name) + if err != nil { + return database.InsertAIGatewayKeyRow{}, "", err + } + row, err := api.Database.InsertAIGatewayKey(ctx, params) + if err != nil { + return database.InsertAIGatewayKeyRow{}, "", err + } + return row, key, nil +} + +// 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.CheckAiGatewayKeysNameCheck): + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid key name.", + Validations: []codersdk.ValidationError{ + {Field: "name", Detail: nameFormatDetail}, + }, + }) + case database.IsUniqueViolation(err, database.UniqueAiGatewayKeysNameIndex): + 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: + // Secret collisions (hashed_secret or secret_prefix unique + // violations, should not happen in practice) and other unexpected errors + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to create key. Please retry.", + }) + } +} + +// @Summary List AI Gateway keys +// @ID list-ai-gateway-keys +// @Security CoderSessionToken +// @Produce json +// @Tags Enterprise +// @Success 200 {array} codersdk.AIGatewayKey +// @Router /api/v2/aibridge/keys [get] +func (api *API) aiGatewayKeys(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + rows, err := api.Database.ListAIGatewayKeys(ctx) + if httpapi.IsUnauthorizedError(err) { + httpapi.Forbidden(rw) + return + } + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to list keys.", + }) + return + } + + out := make([]codersdk.AIGatewayKey, 0, len(rows)) + for _, row := range rows { + out = append(out, convertAIGatewayKey(row)) + } + + httpapi.Write(ctx, rw, http.StatusOK, out) +} + +// @Summary Delete AI Gateway key +// @ID delete-ai-gateway-key +// @Security CoderSessionToken +// @Tags Enterprise +// @Param key path string true "Key ID" format(uuid) +// @Success 204 +// @Router /api/v2/aibridge/keys/{key} [delete] +func (api *API) deleteAIGatewayKey(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + auditor = api.AGPL.Auditor.Load() + aReq, commitAudit = audit.InitRequest[database.AIGatewayKey](rw, &audit.RequestParams{ + Audit: *auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionDelete, + }) + ) + defer commitAudit() + + 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 + } + + deleted, err := api.Database.DeleteAIGatewayKey(ctx, id) + if err != nil { + if httpapi.IsUnauthorizedError(err) { + httpapi.Forbidden(rw) + return + } + if errors.Is(err, sql.ErrNoRows) { + httpapi.ResourceNotFound(rw) + return + } + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to delete key.", + }) + return + } + + aReq.Old = database.AIGatewayKey{ + ID: deleted.ID, + Name: deleted.Name, + SecretPrefix: deleted.SecretPrefix, + CreatedAt: deleted.CreatedAt, + LastUsedAt: deleted.LastUsedAt, + } + + rw.WriteHeader(http.StatusNoContent) +} + +func convertAIGatewayKey(row database.ListAIGatewayKeysRow) codersdk.AIGatewayKey { + var lastUsed *time.Time + if row.LastUsedAt.Valid { + t := row.LastUsedAt.Time + lastUsed = &t + } + return codersdk.AIGatewayKey{ + ID: row.ID, + Name: row.Name, + KeyPrefix: row.SecretPrefix, + CreatedAt: row.CreatedAt, + LastUsedAt: lastUsed, + } +} diff --git a/enterprise/coderd/aigatewaykeys_test.go b/enterprise/coderd/aigatewaykeys_test.go new file mode 100644 index 0000000000..7afc138e4e --- /dev/null +++ b/enterprise/coderd/aigatewaykeys_test.go @@ -0,0 +1,387 @@ +package coderd_test + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" + + aibridgekeys "github.com/coder/coder/v2/coderd/aibridge/keys" + "github.com/coder/coder/v2/coderd/audit" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/codersdk" + entaudit "github.com/coder/coder/v2/enterprise/audit" + "github.com/coder/coder/v2/enterprise/audit/backends" + "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" + "github.com/coder/coder/v2/enterprise/coderd/license" + "github.com/coder/coder/v2/testutil" +) + +func TestAIGatewayKeys(t *testing.T) { + t.Parallel() + + t.Run("CRUD", func(t *testing.T) { + t.Parallel() + + ownerClient, _ := coderdenttest.New(t, aibridgeOpts(t)) + ctx := testutil.Context(t, testutil.WaitLong) + + //nolint:gocritic // Managing AI Gateway keys is owner-only. + keys, err := ownerClient.ListAIGatewayKeys(ctx) + require.NoError(t, err) + require.Empty(t, keys) + + name := uniqueName(t, "happy") + + created, err := ownerClient.CreateAIGatewayKey(ctx, codersdk.CreateAIGatewayKeyRequest{Name: name}) + require.NoError(t, err) + require.NotEqual(t, uuid.Nil, created.ID) + require.Equal(t, name, created.Name) + require.Len(t, created.KeyPrefix, aibridgekeys.KeyPrefixLength) + require.Len(t, created.Key, aibridgekeys.KeyLength) + 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.ListAIGatewayKeys(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.DeleteAIGatewayKey(ctx, created.ID)) + + keys, err = ownerClient.ListAIGatewayKeys(ctx) + require.NoError(t, err) + require.Empty(t, keys) + }) + + t.Run("ListResponseDoesNotLeakSecrets", func(t *testing.T) { + t.Parallel() + + ownerClient, _ := coderdenttest.New(t, aibridgeOpts(t)) + ctx := testutil.Context(t, testutil.WaitLong) + + //nolint:gocritic // Managing AI Gateway keys is owner-only. + created, err := ownerClient.CreateAIGatewayKey(ctx, codersdk.CreateAIGatewayKeyRequest{ + Name: uniqueName(t, "leak"), + }) + require.NoError(t, err) + fullKey := created.Key + + resp, err := ownerClient.Request(ctx, http.MethodGet, "/api/v2/aibridge/keys", nil) + require.NoError(t, err) + t.Cleanup(func() { _ = resp.Body.Close() }) + require.Equal(t, http.StatusOK, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + require.NotContains(t, string(body), fullKey, "LIST response leaked full key") + }) + + t.Run("CreateValidation", func(t *testing.T) { + t.Parallel() + + ownerClient, _ := coderdenttest.New(t, aibridgeOpts(t)) + ctx := testutil.Context(t, testutil.WaitLong) + + // Empty name -> 400 (validate:"required" on request struct). + //nolint:gocritic // Managing AI Gateway keys is owner-only. + _, err := ownerClient.CreateAIGatewayKey(ctx, codersdk.CreateAIGatewayKeyRequest{Name: ""}) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.ErrorContains(t, err, "Validation failed") + + // >64 char name -> 400 (DB check constraint). + longName := strings.Repeat("a", 65) + _, err = ownerClient.CreateAIGatewayKey(ctx, codersdk.CreateAIGatewayKeyRequest{Name: longName}) + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.ErrorContains(t, err, "Invalid key name") + + // Uppercase name -> 400 (DB check constraint rejects non-lowercase). + _, err = ownerClient.CreateAIGatewayKey(ctx, codersdk.CreateAIGatewayKeyRequest{Name: "UPPER-CASE"}) + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.ErrorContains(t, err, "Invalid key name") + + // Duplicate name -> 400. + name := uniqueName(t, "dup") + _, err = ownerClient.CreateAIGatewayKey(ctx, codersdk.CreateAIGatewayKeyRequest{Name: name}) + require.NoError(t, err) + _, err = ownerClient.CreateAIGatewayKey(ctx, codersdk.CreateAIGatewayKeyRequest{Name: name}) + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.ErrorContains(t, err, "must be unique") + }) + + t.Run("DeleteValidation", func(t *testing.T) { + t.Parallel() + + ownerClient, _ := coderdenttest.New(t, aibridgeOpts(t)) + ctx := testutil.Context(t, testutil.WaitLong) + + // Invalid UUID -> 400 (raw request; SDK method accepts uuid.UUID). + //nolint:gocritic // Managing AI Gateway keys is owner-only. + resp, err := ownerClient.Request(ctx, http.MethodDelete, "/api/v2/aibridge/keys/not-a-uuid", nil) + require.NoError(t, err) + t.Cleanup(func() { _ = resp.Body.Close() }) + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + + // Existing id -> 204. + created, err := ownerClient.CreateAIGatewayKey(ctx, codersdk.CreateAIGatewayKeyRequest{ + Name: uniqueName(t, "del"), + }) + require.NoError(t, err) + // SDK returns no code on success, using raw request to check for 204. + delResp, err := ownerClient.Request(ctx, http.MethodDelete, "/api/v2/aibridge/keys/"+created.ID.String(), nil) + require.NoError(t, err) + defer delResp.Body.Close() + require.Equal(t, http.StatusNoContent, delResp.StatusCode) + + // Not existing id -> 404. + err = ownerClient.DeleteAIGatewayKey(ctx, uuid.New()) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) + + t.Run("ReturnsForbiddenForNonOwners", func(t *testing.T) { + t.Parallel() + + ownerClient, owner := coderdenttest.New(t, aibridgeOpts(t)) + ctx := testutil.Context(t, testutil.WaitLong) + member, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + + _, err := member.CreateAIGatewayKey(ctx, codersdk.CreateAIGatewayKeyRequest{ + Name: uniqueName(t, "denied"), + }) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) + + _, err = member.ListAIGatewayKeys(ctx) + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) + + err = member.DeleteAIGatewayKey(ctx, uuid.New()) + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) + }) + + t.Run("LicenseEntitlement", func(t *testing.T) { + t.Parallel() + + ownerClient, _ := coderdenttest.New(t, &coderdenttest.Options{ + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{}, + }, + }) + ctx := testutil.Context(t, testutil.WaitLong) + + //nolint:gocritic // Managing AI Gateway keys is owner-only. + _, err := ownerClient.ListAIGatewayKeys(ctx) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) + require.Contains(t, sdkErr.Message, "AI Gateway is a Premium feature") + }) +} + +func TestAIGatewayKeyAudit(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + auditor := entaudit.NewAuditor( + db, + entaudit.DefaultFilter, + backends.NewPostgres(db, true), + ) + opts := aibridgeOpts(t) + opts.AuditLogging = true + opts.Options.Database = db + opts.Options.Pubsub = ps + opts.Options.Auditor = auditor + opts.LicenseOptions.Features[codersdk.FeatureAuditLog] = 1 + + ownerClient, _ := coderdenttest.New(t, opts) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium) + defer cancel() + + name := uniqueName(t, "audit") + //nolint:gocritic // Managing AI Gateway coderd keys is owner-only. + created, err := ownerClient.CreateAIGatewayKey(ctx, codersdk.CreateAIGatewayKeyRequest{Name: name}) + require.NoError(t, err) + //nolint:gocritic // Managing AI Gateway coderd keys is owner-only. + require.NoError(t, ownerClient.DeleteAIGatewayKey(ctx, created.ID)) + + rows, err := db.GetAuditLogsOffset( + dbauthz.AsSystemRestricted(ctx), + database.GetAuditLogsOffsetParams{ + ResourceType: string(database.ResourceTypeAIGatewayKey), + LimitOpt: 10, + }, + ) + require.NoError(t, err) + require.Len(t, rows, 2, "expected one create and one delete audit row") + + var createLog, deleteLog database.AuditLog + for _, row := range rows { + log := row.AuditLog + switch log.Action { + case database.AuditActionCreate: + createLog = log + case database.AuditActionDelete: + deleteLog = log + default: + require.Failf(t, "unexpected audit action", "action: %s", log.Action) + } + } + require.Equal(t, database.AuditActionCreate, createLog.Action) + require.Equal(t, database.AuditActionDelete, deleteLog.Action) + require.Equal(t, http.StatusCreated, int(createLog.StatusCode)) + require.Equal(t, http.StatusNoContent, int(deleteLog.StatusCode)) + + for _, log := range []database.AuditLog{createLog, deleteLog} { + require.Equal(t, database.ResourceTypeAIGatewayKey, log.ResourceType) + require.Equal(t, created.ID, log.ResourceID) + require.Equal(t, name, log.ResourceTarget) + } + + var createDiff audit.Map + require.NoError(t, json.Unmarshal(createLog.Diff, &createDiff)) + require.Contains(t, createDiff, "name") + require.Equal(t, "", createDiff["name"].Old) + require.Equal(t, name, createDiff["name"].New) + require.Contains(t, createDiff, "secret_prefix") + require.Equal(t, "", createDiff["secret_prefix"].Old) + require.Equal(t, created.KeyPrefix, createDiff["secret_prefix"].New) + require.NotContains(t, createDiff, "hashed_secret") + + var deleteDiff audit.Map + require.NoError(t, json.Unmarshal(deleteLog.Diff, &deleteDiff)) + require.Contains(t, deleteDiff, "name") + require.Equal(t, name, deleteDiff["name"].Old) + require.Equal(t, "", deleteDiff["name"].New) + require.NotContains(t, deleteDiff, "hashed_secret") +} + +func uniqueName(t *testing.T, prefix string) string { + t.Helper() + return strings.ToLower(fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano())) +} + +// aiGatewayKeyErrorStore wraps a database.Store and forces specific +// methods to return errors, allowing tests to exercise error paths. +type aiGatewayKeyErrorStore struct { + database.Store + insertErr error + listErr error + deleteErr error +} + +func (s *aiGatewayKeyErrorStore) InsertAIGatewayKey(ctx context.Context, arg database.InsertAIGatewayKeyParams) (database.InsertAIGatewayKeyRow, error) { + if s.insertErr != nil { + return database.InsertAIGatewayKeyRow{}, s.insertErr + } + return s.Store.InsertAIGatewayKey(ctx, arg) +} + +func (s *aiGatewayKeyErrorStore) ListAIGatewayKeys(ctx context.Context) ([]database.ListAIGatewayKeysRow, error) { + if s.listErr != nil { + return nil, s.listErr + } + return s.Store.ListAIGatewayKeys(ctx) +} + +func (s *aiGatewayKeyErrorStore) DeleteAIGatewayKey(ctx context.Context, id uuid.UUID) (database.DeleteAIGatewayKeyRow, error) { + if s.deleteErr != nil { + return database.DeleteAIGatewayKeyRow{}, s.deleteErr + } + return s.Store.DeleteAIGatewayKey(ctx, id) +} + +func TestAIGatewayKeysDatabaseErrors(t *testing.T) { + t.Parallel() + + dbErr := xerrors.New("internal db failure") + + tests := []struct { + name string + errStore aiGatewayKeyErrorStore + method string + path string + body any + wantStatus int + wantMsg string + }{ + { + name: "CreateDBError", + errStore: aiGatewayKeyErrorStore{insertErr: dbErr}, + method: http.MethodPost, + path: "/api/v2/aibridge/keys", + body: codersdk.CreateAIGatewayKeyRequest{Name: "db-err-create"}, + wantStatus: http.StatusInternalServerError, + wantMsg: "Failed to create key. Please retry.", + }, + { + name: "ListDBError", + errStore: aiGatewayKeyErrorStore{listErr: dbErr}, + method: http.MethodGet, + path: "/api/v2/aibridge/keys", + wantStatus: http.StatusInternalServerError, + wantMsg: "Failed to list keys.", + }, + { + name: "DeleteDBError", + errStore: aiGatewayKeyErrorStore{deleteErr: dbErr}, + method: http.MethodDelete, + path: "/api/v2/aibridge/keys/" + uuid.New().String(), + wantStatus: http.StatusInternalServerError, + wantMsg: "Failed to delete key.", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + errStore := tc.errStore + errStore.Store = db + + opts := aibridgeOpts(t) + opts.Options.Database = &errStore + opts.Options.Pubsub = ps + + ownerClient, _ := coderdenttest.New(t, opts) + ctx := testutil.Context(t, testutil.WaitLong) + + //nolint:gocritic // Managing AI Gateway keys is owner-only. + resp, err := ownerClient.Request(ctx, tc.method, tc.path, tc.body) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, tc.wantStatus, resp.StatusCode) + + var sdkResp codersdk.Response + require.NoError(t, json.NewDecoder(resp.Body).Decode(&sdkResp)) + require.Equal(t, tc.wantMsg, sdkResp.Message) + require.Empty(t, sdkResp.Detail, "response must not leak internal error details") + }) + } +} diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 33732eea3d..092314a973 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/keys", func(r chi.Router) { + r.Use( + apiKeyMiddleware, + api.RequireFeatureMW(codersdk.FeatureAIBridge), + ) + r.Get("/", api.aiGatewayKeys) + r.Post("/", api.postAIGatewayKey) + r.Delete("/{key}", api.deleteAIGatewayKey) + }) + }) + 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..5c0cf7c24d 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/aigatewaykeys.go +/** + * AIGatewayKey is a shared secret used by a standalone AI Gateway + * to authenticate into coderd. + */ +export interface AIGatewayKey { + 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,27 @@ export interface ConvertLoginRequest { readonly password: string; } +// From codersdk/aigatewaykeys.go +/** + * CreateAIGatewayKeyRequest requests a new AI Gateway key. + */ +export interface CreateAIGatewayKeyRequest { + readonly name: string; +} + +// From codersdk/aigatewaykeys.go +/** + * CreateAIGatewayKeyResponse returns all key information. + * Key value is only returned here and cannot be recovered afterwards. + */ +export interface CreateAIGatewayKeyResponse { + 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