Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions e2e/testscripts/agents/providers.txtar
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ stderr 'requires exactly 1 argument'
! exec algolia agents providers delete some-id
stderr '--confirm required'

# `create` requires --file
# `create` without -F or shortcut flags prints usage error
! exec algolia agents providers create
stderr 'required flag\(s\) "file" not set'
stderr 'specify a JSON body with -F, or pass --name, --provider, and an API key flag'

# --dry-run on create previews POST to /1/providers, no network
exec algolia agents providers create -F spec.json --dry-run
Expand Down
73 changes: 61 additions & 12 deletions pkg/cmd/agents/providers/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ type CreateOptions struct {
PrintFlags *cmdutil.PrintFlags

File string
Name string
Provider string
APIKey string
APIKeyStdin bool
APIKeyEnv string
BaseURL string
DryRun bool
Show bool
OutputChanged bool
Expand All @@ -35,20 +41,30 @@ func newCreateCmd(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
}

cmd := &cobra.Command{
Use: "create -F <file>",
Short: "Create an LLM provider authentication from a JSON file",
Use: "create (-F <file> | --name <name> --provider <type> " +
"(--api-key <key> | --api-key-stdin | --api-key-env <var>))",
Short: "Create an LLM provider authentication",
Long: heredoc.Doc(`
Create a provider authentication from a JSON file describing
the ProviderAuthenticationCreate body (name, providerName,
input). The "input" subobject's shape varies per providerName:
Create a provider authentication from a JSON file (-F) or from
flags for the common case (OpenAI, Anthropic, Google GenAI, or
DeepSeek with a single API key).

The -F body is the ProviderAuthenticationCreate JSON (name,
providerName, input). The "input" subobject's shape varies per
providerName:

- openai / anthropic: {apiKey, baseUrl?}
- azure_openai: {apiKey, azureEndpoint, azureDeployment, apiVersion?}
- openai_compatible: {apiKey, baseUrl, defaultModel}
- google_genai / deepseek: {apiKey}

The file is sent verbatim; field-level validation is the
backend's job (a 4xx surfaces with the structured detail).
With flags, only openai, anthropic, google_genai, and deepseek
are supported; use -F for Azure or openai_compatible.

Do not combine -F with --name/--provider/--api-key*.

Prefer --api-key-stdin or --api-key-env over --api-key (shell
history may record raw flags).

Use --dry-run to preview the request without sending.

Expand All @@ -57,6 +73,7 @@ func newCreateCmd(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
`),
Example: heredoc.Doc(`
$ algolia agents providers create -F openai-prod.json
$ algolia agents providers create --name openai-prod --provider openai --api-key-env OPENAI_API_KEY
$ cat spec.json | algolia agents providers create -F -
$ algolia agents providers create -F spec.json --dry-run
`),
Expand All @@ -73,7 +90,12 @@ func newCreateCmd(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co

cmd.Flags().
StringVarP(&opts.File, "file", "F", "", "JSON file with the provider body (use \"-\" for stdin)")
_ = cmd.MarkFlagRequired("file")
cmd.Flags().StringVar(&opts.Name, "name", "", "Provider label (shortcut; not with -F)")
cmd.Flags().StringVar(&opts.Provider, "provider", "", `Provider backend: openai, anthropic, google_genai, or deepseek (shortcut; not with -F)`)
cmd.Flags().StringVar(&opts.APIKey, "api-key", "", "API credential (shortcut; not with -F)")
cmd.Flags().BoolVar(&opts.APIKeyStdin, "api-key-stdin", false, "Read API key from stdin (shortcut; not with -F)")
cmd.Flags().StringVar(&opts.APIKeyEnv, "api-key-env", "", "Read API key from this environment variable (shortcut; not with -F)")
cmd.Flags().StringVar(&opts.BaseURL, "base-url", "", `Optional OpenAI / Anthropic-compatible base URL (shortcut; not with -F)`)
cmd.Flags().
BoolVar(&opts.DryRun, "dry-run", false, "Validate and print the resolved request body without calling the API")
cmd.Flags().
Expand All @@ -83,14 +105,41 @@ func newCreateCmd(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
}

func runCreateCmd(opts *CreateOptions) error {
body, err := shared.ReadJSONFile(opts.IO.In, opts.File)
if err != nil {
return err
source := opts.File

var body json.RawMessage
var err error

switch {
case inlineFlagsConflictWithFile(opts.File, opts.Name, opts.Provider, opts.APIKey, opts.APIKeyEnv, opts.BaseURL, opts.APIKeyStdin):
return cmdutil.FlagErrorf("cannot combine -F/--file with --name/--provider/--api-key/--api-key-* flags")
case opts.File != "":
body, err = shared.ReadJSONFile(opts.IO.In, opts.File)
if err != nil {
return err
}
default:
if !createShortcutAttempted(opts.Name, opts.Provider, opts.APIKey, opts.APIKeyEnv, opts.BaseURL, opts.APIKeyStdin) {
return cmdutil.FlagErrorf(`specify a JSON body with -F, or pass --name, --provider, and an API key flag`)
}
if opts.Name == "" || opts.Provider == "" {
return cmdutil.FlagErrorf("when creating without -F, both --name and --provider are required")
}
key, err := resolveProvidedAPIKey(opts.IO.In, opts.APIKey, opts.APIKeyEnv, opts.APIKeyStdin)
if err != nil {
return err
}
raw, err := marshalSimpleProviderCreate(opts.Name, opts.Provider, opts.BaseURL, key)
if err != nil {
return err
}
body = raw
source = "(flags)"
}

if opts.DryRun {
return shared.PrintDryRun(opts.IO, opts.PrintFlags, opts.OutputChanged,
"create_provider", "POST /1/providers", opts.File, body, nil)
"create_provider", "POST /1/providers", source, body, nil)
}

client, err := opts.AgentStudioClient()
Expand Down
59 changes: 59 additions & 0 deletions pkg/cmd/agents/providers/create_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package providers

import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"testing"
Expand Down Expand Up @@ -74,3 +76,60 @@ func Test_runCreateCmd_RejectsInvalidJSON(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "not valid JSON")
}

func Test_runCreateCmd_Flags_PostsMinimalOpenAIBody(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/1/providers", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
body, err := io.ReadAll(r.Body)
require.NoError(t, err)
assert.JSONEq(t, `{"input":{"apiKey":"sk-env"},"name":"prod","providerName":"openai"}`,
string(bytes.TrimSpace(body)))
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{
"id":"p1","name":"prod","providerName":"openai",
"input":{"apiKey":"sk-env"},
"createdAt":"2026-01-01T00:00:00Z","updatedAt":"2026-01-01T00:00:00Z"
}`))
})
ts := httptest.NewServer(mux)
t.Cleanup(ts.Close)

t.Setenv("OPENAI_CLI_TEST_KEY", "sk-env")

f, out := test.NewFactory(false, nil, nil, "")
f.AgentStudioClient = sharedtest.NewClient(t, ts)

cmd := NewProvidersCmd(f)
cli := `create --name prod --provider openai --api-key-env OPENAI_CLI_TEST_KEY`
result, err := test.Execute(cmd, cli, out)
require.NoError(t, err)
assert.Contains(t, result.String(), `"name":"prod"`)
}

func Test_runCreateCmd_FlagsMutuallyExclusiveWithFile(t *testing.T) {
specPath := sharedtest.WriteTempJSON(
t,
"spec.json",
`{"name":"x","providerName":"openai","input":{"apiKey":"sk"}}`,
)
f, out := test.NewFactory(false, nil, nil, "")
cmd := NewProvidersCmd(f)
cli := "create --name clash --provider openai --api-key sk -F " + specPath
_, err := test.Execute(cmd, cli, out)
require.Error(t, err)
assert.Contains(t, err.Error(), "combine")
}

func Test_runCreateCmd_FlagsUnsupportedProviderUsesF(t *testing.T) {
f, out := test.NewFactory(false, nil, nil, "")
cmd := NewProvidersCmd(f)
_, err := test.Execute(
cmd,
`create --name azure --provider azure_openai --api-key sk-azure --dry-run`,
out,
)
require.Error(t, err)
assert.Contains(t, err.Error(), "use -F")
}

170 changes: 170 additions & 0 deletions pkg/cmd/agents/providers/simple_flags.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package providers

import (
"encoding/json"
"fmt"
"io"
"os"
"strings"

"github.com/algolia/cli/pkg/cmdutil"
)

const maxAPIKeyBytes = 64 << 10

func flagSimpleProvider(providerName string) bool {
switch strings.TrimSpace(providerName) {
case "openai", "anthropic", "google_genai", "deepseek":
return true
default:
return false
}
}

func resolveProvidedAPIKey(stdin io.Reader, apiKey, apiKeyEnv string, apiKeyStdin bool) (string, error) {
n := 0
if apiKey != "" {
n++
}
if apiKeyStdin {
n++
}
if apiKeyEnv != "" {
n++
}
if n == 0 {
return "", cmdutil.FlagErrorf(
"one of --api-key, --api-key-stdin, or --api-key-env is required when creating a provider without -F",
)
}
if n > 1 {
return "", cmdutil.FlagErrorf("use only one of --api-key, --api-key-stdin, or --api-key-env")
}

switch {
case apiKey != "":
return apiKey, nil
case apiKeyStdin:
b, err := io.ReadAll(io.LimitReader(stdin, maxAPIKeyBytes))
if err != nil {
return "", fmt.Errorf("read API key from stdin: %w", err)
}
out := strings.TrimSpace(string(b))
if out == "" {
return "", cmdutil.FlagErrorf("stdin did not provide a non-empty API key")
}
return out, nil
default:
raw, ok := os.LookupEnv(apiKeyEnv)
if !ok {
return "", cmdutil.FlagErrorf("environment variable %q is not set", apiKeyEnv)
}
out := strings.TrimSpace(raw)
if out == "" {
return "", cmdutil.FlagErrorf("environment variable %q is empty", apiKeyEnv)
}
return out, nil
}
}

func resolveOptionalAPIKey(stdin io.Reader, apiKey, apiKeyEnv string, apiKeyStdin bool) (string, bool, error) {
if !apiKeyStdin && apiKeyEnv == "" && apiKey == "" {
return "", false, nil
}
k, err := resolveProvidedAPIKey(stdin, apiKey, apiKeyEnv, apiKeyStdin)
if err != nil {
return "", false, err
}
return k, true, nil
}

func marshalSimpleProviderCreate(name, providerName, baseURL, apiKey string) ([]byte, error) {
pn := strings.TrimSpace(providerName)
if pn == "" {
return nil, cmdutil.FlagErrorf("--provider must not be empty")
}
if !flagSimpleProvider(pn) {
return nil, cmdutil.FlagErrorf(
"providers create with flags supports openai, anthropic, google_genai, and deepseek; for %q use -F",
pn,
)
}

inp := map[string]string{"apiKey": apiKey}
if baseURL != "" {
if pn != "openai" && pn != "anthropic" {
return nil, cmdutil.FlagErrorf("--base-url is only valid with --provider openai or anthropic")
}
inp["baseUrl"] = baseURL
}

body := struct {
Name string `json:"name"`
ProviderName string `json:"providerName"`
Input map[string]string `json:"input"`
}{
Name: strings.TrimSpace(name),
ProviderName: pn,
Input: inp,
}
return json.Marshal(body)
}

func marshalSimpleProviderPatch(name, baseURL, apiKey string, setKey bool) ([]byte, error) {
patch := map[string]any{}
if strings.TrimSpace(name) != "" {
patch["name"] = strings.TrimSpace(name)
}
if setKey || strings.TrimSpace(baseURL) != "" {
in := map[string]string{}
if setKey {
in["apiKey"] = apiKey
}
if strings.TrimSpace(baseURL) != "" {
in["baseUrl"] = strings.TrimSpace(baseURL)
}
patch["input"] = in
}
if len(patch) == 0 {
return nil, cmdutil.FlagErrorf(
"when -F is omitted, specify at least one of --name, --api-key, --api-key-stdin, --api-key-env, or --base-url",
)
}
return json.Marshal(patch)
}

func createShortcutAttempted(name, provider, apiKey, apiKeyEnv, baseURL string, apiKeyStdin bool) bool {
return strings.TrimSpace(name) != "" ||
strings.TrimSpace(provider) != "" ||
apiKey != "" ||
apiKeyStdin ||
apiKeyEnv != "" ||
strings.TrimSpace(baseURL) != ""
}

func inlineFlagsConflictWithFile(file, name, provider, apiKey, apiKeyEnv, baseURL string, apiKeyStdin bool) bool {
if strings.TrimSpace(file) == "" {
return false
}
return strings.TrimSpace(name) != "" ||
strings.TrimSpace(provider) != "" ||
apiKey != "" ||
apiKeyStdin ||
apiKeyEnv != "" ||
strings.TrimSpace(baseURL) != ""
}

func updateUsesInlineFlags(name, apiKey, apiKeyEnv, baseURL string, apiKeyStdin bool) bool {
return strings.TrimSpace(name) != "" ||
apiKey != "" ||
apiKeyStdin ||
apiKeyEnv != "" ||
strings.TrimSpace(baseURL) != ""
}

func updateInlineFlagsConflictWithFile(file, name, apiKey, apiKeyEnv, baseURL string, apiKeyStdin bool) bool {
if strings.TrimSpace(file) == "" {
return false
}
return updateUsesInlineFlags(name, apiKey, apiKeyEnv, baseURL, apiKeyStdin)
}
Loading