diff --git a/cmd/access_lists.go b/cmd/access_lists.go new file mode 100644 index 0000000..255be0f --- /dev/null +++ b/cmd/access_lists.go @@ -0,0 +1,482 @@ +package cmd + +import ( + "errors" + "fmt" + "strconv" + "strings" + + "github.com/requestyai/cli/internal/client" + "github.com/requestyai/cli/internal/util" + "github.com/spf13/cobra" +) + +const ( + accessListNameFlag = "name" + accessListAutoApproveFlag = "auto-approve" + accessListYesFlag = "yes" +) + +func newAccessListsCommand(env environment) *cobra.Command { + cmd := &cobra.Command{ + Use: "access-lists", + Aliases: []string{"access-list"}, + Short: "Manage the access lists in your organization", + Long: "Manage the access lists in your organization.\n\n" + + "An access list names the models a group or API key may use. Only the models on\n" + + "the list are allowed, whatever their kind, so a list with only chat models\n" + + "blocks embeddings, images and audio too. Models are grouped by modality:\n" + + "chat, embedding, image, transcription or speech.", + } + + cmd.PersistentFlags().Bool(jsonFlag, false, "print JSON instead of a table") + cmd.AddCommand( + newAccessListsListCommand(env), + newAccessListsShowCommand(env), + newAccessListsCreateCommand(env), + newAccessListsSetCommand(env), + newAccessListsClearCommand(env), + newAccessListsDeleteCommand(env), + ) + + return cmd +} + +func newAccessListsListCommand(env environment) *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List the access lists in your organization", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + lists, err := env.apiv2Client.AccessLists(cmd.Context()) + if err != nil { + return err + } + + out := cmd.OutOrStdout() + if jsonOutput(cmd) { + return printJSON(out, lists) + } + if len(lists) == 0 { + _, err := fmt.Fprintln(out, "No access lists yet.") + return err + } + + headers := []string{"ID", "NAME"} + for _, modality := range client.Modalities { + headers = append(headers, strings.ToUpper(string(modality))) + } + headers = append(headers, "AUTO APPROVE") + + rows := make([][]string, 0, len(lists)) + for _, list := range lists { + row := []string{list.ID, list.Name} + // The table shows how many models each modality allows; the + // show command names them. + for _, modality := range client.Modalities { + row = append(row, strconv.Itoa(len(list.Models(modality)))) + } + row = append(row, strconv.FormatBool(list.AutoApprove)) + rows = append(rows, row) + } + + return writeTable(out, headers, rows) + }, + } +} + +func newAccessListsShowCommand(env environment) *cobra.Command { + return &cobra.Command{ + Use: "show ", + Short: "Show one access list and the models it allows", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := util.ParseAccessListID(args[0]) + if err != nil { + return err + } + + list, err := env.apiv2Client.AccessList(cmd.Context(), id) + if err != nil { + return err + } + + out := cmd.OutOrStdout() + if jsonOutput(cmd) { + return printJSON(out, list) + } + + rows := make([][]string, 0) + for _, modality := range client.Modalities { + for _, model := range list.Models(modality) { + rows = append(rows, []string{string(modality), model}) + } + } + + if err := writeFields(out, [][2]string{ + {"ID", list.ID}, + {"Name", list.Name}, + {"Organization", list.OrganizationID}, + {"Auto approve", strconv.FormatBool(list.AutoApprove)}, + {"Models", strconv.Itoa(len(rows))}, + }); err != nil { + return err + } + + if len(rows) == 0 { + return nil + } + + if _, err := fmt.Fprintln(out); err != nil { + return err + } + + return writeTable(out, []string{"MODALITY", "MODEL"}, rows) + }, + } +} + +func newAccessListsCreateCommand(env environment) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Create an access list", + Long: "Create an access list.\n\n" + + "Pass the models to allow with one flag per modality, either repeated or\n" + + "comma-separated. A modality left out allows no models of that kind. The list\n" + + "is not attached to anything yet; do that in the console.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + flags := cmd.Flags() + + name, err := flags.GetString(accessListNameFlag) + if err != nil { + return err + } + autoApprove, err := flags.GetBool(accessListAutoApproveFlag) + if err != nil { + return err + } + + input := client.CreateAccessListInput{Name: name, AutoApprove: autoApprove} + + models := make(map[client.Modality][]string, len(client.Modalities)) + for _, modality := range client.Modalities { + raw, err := flags.GetStringSlice(string(modality)) + if err != nil { + return err + } + if len(raw) == 0 { + continue + } + parsed, err := util.ParseModels(raw) + if err != nil { + return fmt.Errorf("--%s: %w", modality, err) + } + models[modality] = parsed + } + input.Chat = models[client.ModalityChat] + input.Embedding = models[client.ModalityEmbedding] + input.Image = models[client.ModalityImage] + input.Transcription = models[client.ModalityTranscription] + input.Speech = models[client.ModalitySpeech] + + created, err := env.apiv2Client.CreateAccessList(cmd.Context(), input) + if err != nil { + return err + } + + out := cmd.OutOrStdout() + if jsonOutput(cmd) { + return printJSON(out, created) + } + + _, err = fmt.Fprintf(out, "Created access list %s with id %s.\n", name, created.ID) + return err + }, + } + + flags := cmd.Flags() + flags.String(accessListNameFlag, "", "name for the new access list") + flags.Bool(accessListAutoApproveFlag, false, "approve new models matching the list automatically") + for _, modality := range client.Modalities { + flags.StringSlice(string(modality), nil, + fmt.Sprintf("%s models to allow, for example %s", modality, exampleModel(modality))) + } + if err := cmd.MarkFlagRequired(accessListNameFlag); err != nil { + panic(err) + } + + return cmd +} + +// exampleModel gives a plausible model id for a modality, to make the flag +// help concrete. +func exampleModel(modality client.Modality) string { + switch modality { + case client.ModalityEmbedding: + return "openai/text-embedding-3-small" + case client.ModalityImage: + return "openai/dall-e-3" + case client.ModalityTranscription: + return "openai/whisper-1" + case client.ModalitySpeech: + return "openai/tts-1" + default: + return "openai/gpt-4o" + } +} + +func newAccessListsSetCommand(env environment) *cobra.Command { + cmd := &cobra.Command{ + Use: "set", + Short: "Set a property on an access list", + } + cmd.AddCommand( + newAccessListsSetNameCommand(env), + newAccessListsSetAutoApproveCommand(env), + newAccessListsSetModelsCommand(env), + ) + + return cmd +} + +func newAccessListsSetNameCommand(env environment) *cobra.Command { + return &cobra.Command{ + Use: "name ", + Short: "Rename an access list", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := util.ParseAccessListID(args[0]) + if err != nil { + return err + } + + name := strings.TrimSpace(args[1]) + if name == "" { + return errors.New("missing name") + } + + if err := env.apiv2Client.UpdateAccessListName(cmd.Context(), id, name); err != nil { + return err + } + + return reportUpdate(cmd, id, "name", fmt.Sprintf("Access list %s is now named %s.", id, name)) + }, + } +} + +func newAccessListsSetAutoApproveCommand(env environment) *cobra.Command { + return &cobra.Command{ + Use: "auto-approve ", + Short: "Approve new models matching an access list automatically", + Long: "Approve new models matching an access list automatically.\n\n" + + "Run clear auto-approve to go back to adding models by hand.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := util.ParseAccessListID(args[0]) + if err != nil { + return err + } + + if err := env.apiv2Client.UpdateAccessListAutoApprove(cmd.Context(), id, true); err != nil { + return err + } + + return reportUpdate(cmd, id, "auto_approve", + fmt.Sprintf("Access list %s now approves matching new models automatically.", id)) + }, + } +} + +func newAccessListsSetModelsCommand(env environment) *cobra.Command { + return &cobra.Command{ + Use: "models ...", + Short: "Set the models of one modality on an access list", + Long: "Set the models of one modality on an access list.\n\n" + + "The modality is chat, embedding, image, transcription or speech. Its models are\n" + + "replaced wholesale: pass every model you want to keep. The other modalities are\n" + + "left as they are. Use clear models to remove every model of a modality.", + // A missing model would otherwise wipe the modality, so say what to run + // instead rather than reporting an argument count. + Args: func(_ *cobra.Command, args []string) error { + switch len(args) { + case 0: + return errors.New("missing access list id") + case 1: + return errors.New("missing modality: want chat, embedding, image, transcription or speech") + case 2: + return errors.New("no models given: pass their ids, or run clear models to remove them all") + default: + return nil + } + }, + RunE: func(cmd *cobra.Command, args []string) error { + id, err := util.ParseAccessListID(args[0]) + if err != nil { + return err + } + + modality, err := util.ParseModality(args[1]) + if err != nil { + return err + } + + models, err := util.ParseModels(args[2:]) + if err != nil { + return err + } + + err = env.apiv2Client.UpdateAccessListModels(cmd.Context(), id, map[client.Modality][]string{modality: models}) + if err != nil { + return err + } + + return reportUpdate(cmd, id, string(modality), + fmt.Sprintf("Access list %s now allows %d %s %s: %s.", + id, len(models), modality, pluralModels(len(models)), strings.Join(models, ", "))) + }, + } +} + +func newAccessListsClearCommand(env environment) *cobra.Command { + cmd := &cobra.Command{ + Use: "clear", + Short: "Clear a property on an access list", + } + cmd.AddCommand( + newAccessListsClearAutoApproveCommand(env), + newAccessListsClearModelsCommand(env), + ) + + return cmd +} + +func newAccessListsClearAutoApproveCommand(env environment) *cobra.Command { + return &cobra.Command{ + Use: "auto-approve ", + Short: "Stop approving new models on an access list automatically", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := util.ParseAccessListID(args[0]) + if err != nil { + return err + } + + if err := env.apiv2Client.UpdateAccessListAutoApprove(cmd.Context(), id, false); err != nil { + return err + } + + return reportUpdate(cmd, id, "auto_approve", + fmt.Sprintf("Access list %s no longer approves new models automatically.", id)) + }, + } +} + +func newAccessListsClearModelsCommand(env environment) *cobra.Command { + return &cobra.Command{ + Use: "models ...", + Short: "Remove every model of one or more modalities from an access list", + Long: "Remove every model of one or more modalities from an access list.\n\n" + + "Anything attached to the list can then use no models of those kinds at all,\n" + + "since only the models on the list are allowed.", + Args: func(_ *cobra.Command, args []string) error { + switch len(args) { + case 0: + return errors.New("missing access list id") + case 1: + return errors.New("missing modality: want chat, embedding, image, transcription or speech") + default: + return nil + } + }, + RunE: func(cmd *cobra.Command, args []string) error { + id, err := util.ParseAccessListID(args[0]) + if err != nil { + return err + } + + cleared := make(map[client.Modality][]string, len(args)-1) + names := make([]string, 0, len(args)-1) + for _, raw := range args[1:] { + modality, err := util.ParseModality(raw) + if err != nil { + return err + } + if _, seen := cleared[modality]; seen { + continue + } + cleared[modality] = nil + names = append(names, string(modality)) + } + + if err := env.apiv2Client.UpdateAccessListModels(cmd.Context(), id, cleared); err != nil { + return err + } + + return reportUpdate(cmd, id, strings.Join(names, ","), + fmt.Sprintf("Removed every %s model from access list %s.", strings.Join(names, " and "), id)) + }, + } +} + +func newAccessListsDeleteCommand(env environment) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete an access list", + Long: "Delete an access list.\n\n" + + "Deletion is permanent. It is refused while the list is still attached to a\n" + + "group or API key, so detach it everywhere first.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id, err := util.ParseAccessListID(args[0]) + if err != nil { + return err + } + + skipPrompt, err := cmd.Flags().GetBool(accessListYesFlag) + if err != nil { + return err + } + + if !skipPrompt { + confirmed, err := util.Confirm( + cmd.InOrStdin(), + cmd.OutOrStdout(), + fmt.Sprintf("Delete access list %s? This cannot be undone [y/N]: ", id), + ) + if err != nil { + return err + } + if !confirmed { + _, err := fmt.Fprintln(cmd.OutOrStdout(), "Left it alone.") + return err + } + } + + if err := env.apiv2Client.DeleteAccessList(cmd.Context(), id); err != nil { + return err + } + + out := cmd.OutOrStdout() + if jsonOutput(cmd) { + return printJSON(out, map[string]any{"id": id, "deleted": true}) + } + + _, err = fmt.Fprintf(out, "Deleted access list %s.\n", id) + return err + }, + } + + cmd.Flags().BoolP(accessListYesFlag, "y", false, "delete without asking first") + + return cmd +} + +// pluralModels picks the noun for a count of models. +func pluralModels(count int) string { + if count == 1 { + return "model" + } + + return "models" +} diff --git a/cmd/requesty.go b/cmd/requesty.go index d078473..032abdc 100644 --- a/cmd/requesty.go +++ b/cmd/requesty.go @@ -61,6 +61,7 @@ func newRootCommand(env environment) *cobra.Command { root.AddCommand( newAPIKeysCommand(env), newGroupsCommand(env), + newAccessListsCommand(env), ) return root diff --git a/internal/client/access_lists.go b/internal/client/access_lists.go new file mode 100644 index 0000000..1bb4305 --- /dev/null +++ b/internal/client/access_lists.go @@ -0,0 +1,200 @@ +package client + +import ( + "context" + "net/http" +) + +// Modality is one kind of model an access list can allow. +type Modality string + +const ( + ModalityChat Modality = "chat" + ModalityEmbedding Modality = "embedding" + ModalityImage Modality = "image" + ModalityTranscription Modality = "transcription" + ModalitySpeech Modality = "speech" +) + +// Modalities lists every modality in the order the API documents them. +var Modalities = []Modality{ + ModalityChat, + ModalityEmbedding, + ModalityImage, + ModalityTranscription, + ModalitySpeech, +} + +// AccessList is a named set of models that groups and API keys may be limited +// to. Only the models listed are allowed, whatever their modality, so a list +// with only chat models blocks embeddings, images and audio outright. +type AccessList struct { + ID string `json:"id"` + Name string `json:"name"` + OrganizationID string `json:"organization_id"` + AutoApprove bool `json:"auto_approve"` + Chat []string `json:"chat"` + Embedding []string `json:"embedding"` + Image []string `json:"image"` + Transcription []string `json:"transcription"` + Speech []string `json:"speech"` +} + +// Models returns the allowed models of one modality. +func (list AccessList) Models(modality Modality) []string { + switch modality { + case ModalityChat: + return list.Chat + case ModalityEmbedding: + return list.Embedding + case ModalityImage: + return list.Image + case ModalityTranscription: + return list.Transcription + case ModalitySpeech: + return list.Speech + default: + return nil + } +} + +// CreateAccessListInput describes an access list to create. A modality left +// nil allows no models of that kind. +type CreateAccessListInput struct { + Name string + AutoApprove bool + Chat []string + Embedding []string + Image []string + Transcription []string + Speech []string +} + +// CreatedAccessList identifies an access list that was just created. +type CreatedAccessList struct { + ID string `json:"id"` +} + +// AccessLists lists the organization's access lists. +func (c *Client) AccessLists(ctx context.Context) ([]AccessList, error) { + endpoint, err := c.manageURL("access-list") + if err != nil { + return nil, err + } + + var response struct { + AccessLists []AccessList `json:"access_lists"` + } + if err := c.do(ctx, http.MethodGet, endpoint, nil, &response); err != nil { + return nil, err + } + + return response.AccessLists, nil +} + +// AccessList returns one access list along with the models it allows. +func (c *Client) AccessList(ctx context.Context, id string) (AccessList, error) { + endpoint, err := c.manageURL("access-list", id) + if err != nil { + return AccessList{}, err + } + + var response struct { + AccessList AccessList `json:"access_list"` + } + if err := c.do(ctx, http.MethodGet, endpoint, nil, &response); err != nil { + return AccessList{}, err + } + + return response.AccessList, nil +} + +// CreateAccessList adds an access list to the organization. +func (c *Client) CreateAccessList(ctx context.Context, input CreateAccessListInput) (CreatedAccessList, error) { + endpoint, err := c.manageURL("access-list") + if err != nil { + return CreatedAccessList{}, err + } + + body := struct { + Name string `json:"name"` + AutoApprove bool `json:"auto_approve"` + Chat []string `json:"chat,omitempty"` + Embedding []string `json:"embedding,omitempty"` + Image []string `json:"image,omitempty"` + Transcription []string `json:"transcription,omitempty"` + Speech []string `json:"speech,omitempty"` + }{ + Name: input.Name, + AutoApprove: input.AutoApprove, + Chat: input.Chat, + Embedding: input.Embedding, + Image: input.Image, + Transcription: input.Transcription, + Speech: input.Speech, + } + + var created CreatedAccessList + if err := c.do(ctx, http.MethodPost, endpoint, body, &created); err != nil { + return CreatedAccessList{}, err + } + + return created, nil +} + +// UpdateAccessListName renames an access list. +func (c *Client) UpdateAccessListName(ctx context.Context, id, name string) error { + body := struct { + Name string `json:"name"` + }{Name: name} + + return c.patchAccessList(ctx, id, body) +} + +// UpdateAccessListAutoApprove decides whether new models matching the list are +// approved without anyone adding them by hand. +func (c *Client) UpdateAccessListAutoApprove(ctx context.Context, id string, autoApprove bool) error { + body := struct { + AutoApprove bool `json:"auto_approve"` + }{AutoApprove: autoApprove} + + return c.patchAccessList(ctx, id, body) +} + +// UpdateAccessListModels replaces the allowed models of each modality given. +// A nil or empty list removes every model of that modality; modalities not in +// the map are left as they are. +func (c *Client) UpdateAccessListModels(ctx context.Context, id string, models map[Modality][]string) error { + body := make(map[Modality][]string, len(models)) + for modality, allowed := range models { + if allowed == nil { + // Encode as [] rather than null, which is what the API takes to + // mean "no models". + allowed = []string{} + } + body[modality] = allowed + } + + return c.patchAccessList(ctx, id, body) +} + +// patchAccessList sends a partial update; only the fields in body change. +func (c *Client) patchAccessList(ctx context.Context, id string, body any) error { + endpoint, err := c.manageURL("access-list", id) + if err != nil { + return err + } + + return c.do(ctx, http.MethodPatch, endpoint, body, nil) +} + +// DeleteAccessList removes an access list for good. The API refuses while the +// list is still attached to a group or API key. +func (c *Client) DeleteAccessList(ctx context.Context, id string) error { + endpoint, err := c.manageURL("access-list", id) + if err != nil { + return err + } + + return c.do(ctx, http.MethodDelete, endpoint, nil, nil) +} diff --git a/internal/client/access_lists_test.go b/internal/client/access_lists_test.go new file mode 100644 index 0000000..e65cf54 --- /dev/null +++ b/internal/client/access_lists_test.go @@ -0,0 +1,182 @@ +package client + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClientAccessLists(t *testing.T) { + reply := `{"access_lists":[` + + `{"id":"list-1","name":"Production","organization_id":"org-123","auto_approve":false,` + + `"chat":["openai/gpt-4o","anthropic/claude-sonnet-4-20250514"],"embedding":["openai/text-embedding-3-small"],` + + `"image":[],"transcription":[],"speech":[]},` + + `{"id":"list-2","name":"Everything new","organization_id":"org-123","auto_approve":true}]}` + client, seen := newTestClient(t, http.StatusOK, reply) + + lists, err := client.AccessLists(context.Background()) + + require.NoError(t, err) + assert.Equal(t, http.MethodGet, seen.method) + assert.Equal(t, "/v1/manage/access-list", seen.path) + assert.Equal(t, "Bearer test-key", seen.auth) + assert.Equal(t, []AccessList{ + { + ID: "list-1", + Name: "Production", + OrganizationID: "org-123", + Chat: []string{"openai/gpt-4o", "anthropic/claude-sonnet-4-20250514"}, + Embedding: []string{"openai/text-embedding-3-small"}, + Image: []string{}, + Transcription: []string{}, + Speech: []string{}, + }, + { + ID: "list-2", + Name: "Everything new", + OrganizationID: "org-123", + AutoApprove: true, + }, + }, lists) +} + +func TestClientAccessList(t *testing.T) { + reply := `{"access_list":{"id":"list-1","name":"Production","organization_id":"org-123","auto_approve":true,` + + `"chat":["openai/gpt-4o"],"embedding":[],"image":["openai/dall-e-3"],"transcription":["openai/whisper-1"],` + + `"speech":["openai/tts-1"]}}` + client, seen := newTestClient(t, http.StatusOK, reply) + + list, err := client.AccessList(context.Background(), "list-1") + + require.NoError(t, err) + assert.Equal(t, http.MethodGet, seen.method) + assert.Equal(t, "/v1/manage/access-list/list-1", seen.path) + assert.Equal(t, AccessList{ + ID: "list-1", + Name: "Production", + OrganizationID: "org-123", + AutoApprove: true, + Chat: []string{"openai/gpt-4o"}, + Embedding: []string{}, + Image: []string{"openai/dall-e-3"}, + Transcription: []string{"openai/whisper-1"}, + Speech: []string{"openai/tts-1"}, + }, list) +} + +func TestClientAccessListEscapesID(t *testing.T) { + client, seen := newTestClient(t, http.StatusOK, `{"access_list":{"id":"list-1"}}`) + + _, err := client.AccessList(context.Background(), "../apikey") + + require.NoError(t, err) + assert.Equal(t, "/v1/manage/access-list/..%2Fapikey", seen.path) +} + +func TestAccessListModels(t *testing.T) { + list := AccessList{ + Chat: []string{"chat-model"}, + Embedding: []string{"embedding-model"}, + Image: []string{"image-model"}, + Transcription: []string{"transcription-model"}, + Speech: []string{"speech-model"}, + } + + for _, modality := range Modalities { + assert.Equal(t, []string{string(modality) + "-model"}, list.Models(modality), string(modality)) + } + assert.Nil(t, list.Models("video")) +} + +func TestClientCreateAccessList(t *testing.T) { + client, seen := newTestClient(t, http.StatusOK, `{"id":"list-1"}`) + + created, err := client.CreateAccessList(context.Background(), CreateAccessListInput{ + Name: "Production", + AutoApprove: true, + Chat: []string{"openai/gpt-4o", "anthropic/claude-sonnet-4-20250514"}, + Speech: []string{"openai/tts-1"}, + }) + + require.NoError(t, err) + assert.Equal(t, http.MethodPost, seen.method) + assert.Equal(t, "/v1/manage/access-list", seen.path) + // Modalities with no models stay out of the request rather than going as + // null, and auto_approve always goes so the default is explicit. + assert.JSONEq(t, `{"name":"Production","auto_approve":true,`+ + `"chat":["openai/gpt-4o","anthropic/claude-sonnet-4-20250514"],"speech":["openai/tts-1"]}`, seen.body) + assert.Equal(t, CreatedAccessList{ID: "list-1"}, created) +} + +func TestClientCreateAccessListWithNameOnly(t *testing.T) { + client, seen := newTestClient(t, http.StatusOK, `{"id":"list-1"}`) + + _, err := client.CreateAccessList(context.Background(), CreateAccessListInput{Name: "Empty"}) + + require.NoError(t, err) + assert.JSONEq(t, `{"name":"Empty","auto_approve":false}`, seen.body) +} + +func TestClientUpdateAccessListName(t *testing.T) { + client, seen := newTestClient(t, http.StatusOK, "") + + err := client.UpdateAccessListName(context.Background(), "list-1", "Staging") + + require.NoError(t, err) + assert.Equal(t, http.MethodPatch, seen.method) + assert.Equal(t, "/v1/manage/access-list/list-1", seen.path) + assert.JSONEq(t, `{"name":"Staging"}`, seen.body) +} + +func TestClientUpdateAccessListAutoApprove(t *testing.T) { + client, seen := newTestClient(t, http.StatusOK, "") + + err := client.UpdateAccessListAutoApprove(context.Background(), "list-1", false) + + require.NoError(t, err) + assert.Equal(t, http.MethodPatch, seen.method) + assert.Equal(t, "/v1/manage/access-list/list-1", seen.path) + // false must still go out; leaving it off would leave the setting alone. + assert.JSONEq(t, `{"auto_approve":false}`, seen.body) +} + +func TestClientUpdateAccessListModels(t *testing.T) { + client, seen := newTestClient(t, http.StatusOK, "") + + err := client.UpdateAccessListModels(context.Background(), "list-1", map[Modality][]string{ + ModalityChat: {"openai/gpt-4o"}, + ModalityImage: nil, + }) + + require.NoError(t, err) + assert.Equal(t, http.MethodPatch, seen.method) + assert.Equal(t, "/v1/manage/access-list/list-1", seen.path) + // A modality being cleared goes as an empty array, not null, and the + // modalities not mentioned stay out of the request. + assert.JSONEq(t, `{"chat":["openai/gpt-4o"],"image":[]}`, seen.body) +} + +func TestClientDeleteAccessList(t *testing.T) { + client, seen := newTestClient(t, http.StatusOK, "") + + err := client.DeleteAccessList(context.Background(), "list-1") + + require.NoError(t, err) + assert.Equal(t, http.MethodDelete, seen.method) + assert.Equal(t, "/v1/manage/access-list/list-1", seen.path) + assert.Empty(t, seen.body) +} + +func TestClientDeleteAccessListInUse(t *testing.T) { + client, _ := newTestClient(t, http.StatusConflict, + `{"error":{"origin":"router","message":"access list is assigned to 2 groups"}}`) + + err := client.DeleteAccessList(context.Background(), "list-1") + + require.Error(t, err) + assert.Contains(t, err.Error(), "access list is assigned to 2 groups") + assert.Contains(t, err.Error(), "409") +} diff --git a/internal/util/parse.go b/internal/util/parse.go index ede98a0..91a872a 100644 --- a/internal/util/parse.go +++ b/internal/util/parse.go @@ -2,6 +2,7 @@ package util import ( "fmt" + "slices" "strings" "time" @@ -33,6 +34,11 @@ func ParseUserID(value string) (string, error) { return parseID("user", value) } +// ParseAccessListID trims an access list ID and rejects an empty value. +func ParseAccessListID(value string) (string, error) { + return parseID("access list", value) +} + // parseID trims an identifier and names what was missing when it is empty. func parseID(subject, value string) (string, error) { id := strings.TrimSpace(value) @@ -53,6 +59,41 @@ func ParseRole(value string) (client.GroupRole, error) { } } +// ParseModality validates the kind of model an access list entry is for. +func ParseModality(value string) (client.Modality, error) { + modality := client.Modality(strings.TrimSpace(value)) + if slices.Contains(client.Modalities, modality) { + return modality, nil + } + + return "", fmt.Errorf("invalid modality %q: want %s", value, modalityChoices()) +} + +// modalityChoices lists the modalities the way an error message reads them out. +func modalityChoices() string { + names := make([]string, 0, len(client.Modalities)) + for _, modality := range client.Modalities { + names = append(names, string(modality)) + } + + return strings.Join(names[:len(names)-1], ", ") + " or " + names[len(names)-1] +} + +// ParseModels trims model identifiers and rejects any that are blank, which +// would otherwise slip into an access list as an entry nothing can match. +func ParseModels(values []string) ([]string, error) { + models := make([]string, 0, len(values)) + for _, value := range values { + model := strings.TrimSpace(value) + if model == "" { + return nil, fmt.Errorf("invalid model %q: want a model id such as openai/gpt-4o", value) + } + models = append(models, model) + } + + return models, nil +} + // ParsePermissions validates and combines the management and completions // permissions. Both values must be provided together, or both left empty. func ParsePermissions(manage, completions string) (*client.APIKeyPermissions, error) { diff --git a/internal/util/parse_test.go b/internal/util/parse_test.go index ecab681..14da64a 100644 --- a/internal/util/parse_test.go +++ b/internal/util/parse_test.go @@ -37,6 +37,43 @@ func TestParseUserID(t *testing.T) { assert.EqualError(t, err, "missing user id") } +func TestParseAccessListID(t *testing.T) { + id, err := ParseAccessListID(" list-123 ") + require.NoError(t, err) + assert.Equal(t, "list-123", id) + + _, err = ParseAccessListID(" \t ") + assert.EqualError(t, err, "missing access list id") +} + +func TestParseModality(t *testing.T) { + modality, err := ParseModality(" chat ") + require.NoError(t, err) + assert.Equal(t, client.ModalityChat, modality) + + for _, want := range client.Modalities { + modality, err := ParseModality(string(want)) + require.NoError(t, err) + assert.Equal(t, want, modality) + } + + _, err = ParseModality("video") + assert.EqualError(t, err, `invalid modality "video": want chat, embedding, image, transcription or speech`) +} + +func TestParseModels(t *testing.T) { + models, err := ParseModels([]string{" openai/gpt-4o ", "anthropic/claude-sonnet-4-20250514"}) + require.NoError(t, err) + assert.Equal(t, []string{"openai/gpt-4o", "anthropic/claude-sonnet-4-20250514"}, models) + + models, err = ParseModels(nil) + require.NoError(t, err) + assert.Empty(t, models) + + _, err = ParseModels([]string{"openai/gpt-4o", " "}) + assert.EqualError(t, err, `invalid model " ": want a model id such as openai/gpt-4o`) +} + func TestParseRole(t *testing.T) { role, err := ParseRole(" admin ") require.NoError(t, err)