diff --git a/cmd/orchestra/agents.go b/cmd/orchestra/agents.go index 7fce73b..e7ae7b3 100644 --- a/cmd/orchestra/agents.go +++ b/cmd/orchestra/agents.go @@ -1,6 +1,7 @@ package main import ( + "errors" "fmt" "strings" "time" @@ -30,6 +31,7 @@ func newAgentsCmd() *cobra.Command { return err } reg := cfg.BuildRegistry() + var keyHints []string if probe { return probeAgents(cmd, reg, cfg, probeTimeout) @@ -37,9 +39,11 @@ func newAgentsCmd() *cobra.Command { fmt.Printf("%-12s %-16s %s\n", "AGENT", "STATUS", "CAPABILITIES") for _, a := range reg.All() { - status := "available" - if err := a.Health(); err != nil { - status = "not installed" + herr := a.Health() + status := agent.HealthLabel(herr) + var ee *agent.EnvError + if errors.As(herr, &ee) { + keyHints = append(keyHints, a.Name()+": "+herr.Error()) } def := "" if a.Name() == cfg.DefaultAgent { @@ -47,6 +51,12 @@ func newAgentsCmd() *cobra.Command { } fmt.Printf("%-12s %-16s %s%s%s\n", a.Name(), status, capList(a), apiTag(a), def) } + for _, h := range keyHints { + fmt.Println(ui.Warn("! " + h)) + } + for _, w := range cfg.LiteralSecretWarnings() { + fmt.Println(ui.Warn("! " + w)) + } fmt.Println(ui.Dim("\ntip: `orchestra agents --probe` checks agents can actually run, not just that they're installed")) return nil }, @@ -72,7 +82,11 @@ func probeAgents(cmd *cobra.Command, reg *agent.Registry, cfg *config.Config, ti a := all[i] rows[i].name = a.Name() if err := a.Health(); err != nil { - rows[i].result = ui.Dim("not installed") + rows[i].result = ui.Dim(agent.HealthLabel(err)) + var ee *agent.EnvError + if errors.As(err, &ee) { + rows[i].detail = err.Error() + } return nil } p, ok := a.(agent.Prober) diff --git a/cmd/orchestra/do.go b/cmd/orchestra/do.go index 4742015..4cdbf12 100644 --- a/cmd/orchestra/do.go +++ b/cmd/orchestra/do.go @@ -155,7 +155,11 @@ func runSequential(ctx context.Context, in *bufio.Reader, cfg *config.Config, re return nil } } - fmt.Printf("\n%s\n", ui.Success(fmt.Sprintf("✓ workflow complete — all %d steps accepted and committed", len(pl.Steps)))) + kept := "accepted and committed" + if !gitutil.IsRepo(flagDir) { + kept = "accepted — changes left in the folder" // git-free: nothing is committed + } + fmt.Printf("\n%s\n", ui.Success(fmt.Sprintf("✓ workflow complete — all %d steps %s", len(pl.Steps), kept))) return nil } diff --git a/cmd/orchestra/init.go b/cmd/orchestra/init.go index e07c0cb..352284e 100644 --- a/cmd/orchestra/init.go +++ b/cmd/orchestra/init.go @@ -69,6 +69,12 @@ agents: # model: gpt-4o # api_key_env: OPENAI_API_KEY # capabilities: [plan, implement, review] + # + # Any agent can take an env: block to choose its key without writing it here + # (bring your own key; $VAR / ${VAR} are expanded at run time): + # + # env: + # GROQ_API_KEY: "${MY_GROQ_KEY}" ` func newInitCmd() *cobra.Command { diff --git a/docs/EXTENDING.md b/docs/EXTENDING.md index 680990b..17fcb43 100644 --- a/docs/EXTENDING.md +++ b/docs/EXTENDING.md @@ -117,6 +117,37 @@ and cost shows as `—`. Usage appears in the end-of-run line and the dashboard's History, Benchmarks and Agents (per-agent totals) tabs. CLI agents don't report usage yet, so their runs show `—`. +### Bring your own key (`env:`) + +Orchestra never holds or proxies keys. CLI agents keep their own login; API agents read a key from +the environment. The optional `env:` block on any agent lets you pick *which* key, without putting it +in `orchestra.yaml`: + +```yaml +agents: + - name: opencode-groq # a second opencode profile on another provider + bin: opencode + args: ["run"] + dir_flag: "--dir" + env: + GROQ_API_KEY: "${MY_GROQ_KEY}" # passed into the opencode process + + - name: sonnet-work + type: api + provider: anthropic + model: claude-sonnet-4-5 + env: + ANTHROPIC_API_KEY: "${WORK_ANTHROPIC_KEY}" # api agents use the api_key_env entry as their key +``` + +- Values reference variables as `$VAR` or `${VAR}` and are expanded each time the agent runs. +- If a referenced variable is unset, the agent shows as **missing key** (not "not installed") in + `orchestra agents`, the dashboard's Agents tab and `--probe`, with the variable's name. Values are + never printed. +- Literal values are allowed but `orchestra agents` warns when a `*KEY*`/`*TOKEN*`/`*SECRET*` entry + holds one: `orchestra.yaml` is often committed. +- If the provider rejects a key, the probe names the variable it came from. + --- ## 3. Implement the `Agent` interface (for a built-in / non-CLI agent) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index d20ad12..22d0999 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -109,6 +109,7 @@ type CLIAgent struct { args []string dirFlag string // if set, inject " " so CLIs that ignore cwd still isolate caps []Capability + env map[string]string // extra environment ($VAR-expanded at spawn); the CLI keeps its own auth } // New builds a CLIAgent. dirFlag (may be empty) is the flag an agent needs to be @@ -117,13 +118,22 @@ func New(name, bin string, args []string, dirFlag string, caps []Capability) *CL return &CLIAgent{name: name, bin: bin, args: args, dirFlag: dirFlag, caps: caps} } +// SetEnv sets extra environment variables for the CLI process, e.g. a +// provider key: {"GROQ_API_KEY": "${MY_GROQ_KEY}"}. Values may reference +// variables as $VAR or ${VAR}; they are expanded at every spawn. +func (a *CLIAgent) SetEnv(env map[string]string) { a.env = copyEnv(env) } + func (a *CLIAgent) Name() string { return a.name } func (a *CLIAgent) Capabilities() []Capability { return a.caps } -// Health reports whether the agent's binary is on PATH. +// Health reports whether the agent's binary is on PATH and every variable its +// env: block references is set (an *EnvError otherwise). func (a *CLIAgent) Health() error { - _, err := exec.LookPath(a.bin) - return err + if _, err := exec.LookPath(a.bin); err != nil { + return err + } + _, missing := expandEnv(a.env) + return firstMissing(a.name, missing) } // Run dispatches the task to the CLI via the runner. @@ -223,10 +233,12 @@ func (a *CLIAgent) spec(task Task) runner.Spec { args = append(args, a.dirFlag, dir) // tell cwd-ignoring CLIs where to work } args = append(args, task.Prompt) + env, _ := expandEnv(a.env) return runner.Spec{ Bin: a.bin, Args: args, Dir: dir, + Env: env, Timeout: task.Timeout, Output: task.Output, } diff --git a/internal/agent/apiagent.go b/internal/agent/apiagent.go index df5348f..4059e7f 100644 --- a/internal/agent/apiagent.go +++ b/internal/agent/apiagent.go @@ -25,7 +25,8 @@ import ( type APIAgent struct { name string model string - keyEnv string // env var holding the API key + keyEnv string // env var holding the API key + env map[string]string // optional env: block; may supply keyEnv (e.g. "${WORK_KEY}") prov llm.Provider caps []Capability budget int @@ -91,13 +92,37 @@ func (a *APIAgent) Model() string { return a.model } // ProviderName returns the llm provider backing this agent. func (a *APIAgent) ProviderName() string { return a.prov.Name() } -// Health reports whether the agent is usable without network I/O: the API key -// env var must be set (mirrors CLIAgent's cheap binary-on-PATH check). -func (a *APIAgent) Health() error { - if v := os.Getenv(a.keyEnv); strings.TrimSpace(v) == "" { - return fmt.Errorf("environment variable %s is not set (required by api agent %q)", a.keyEnv, a.name) +// SetEnv sets the agent's env: block. Only the entry named by api_key_env is +// used: it lets one agent read its key from another variable, e.g. +// {"ANTHROPIC_API_KEY": "${WORK_ANTHROPIC_KEY}"}. +func (a *APIAgent) SetEnv(env map[string]string) { a.env = copyEnv(env) } + +// apiKey resolves the key: the env: block's entry for keyEnv when present, +// otherwise the process environment. A missing key is an *EnvError. +func (a *APIAgent) apiKey() (string, error) { + if raw, ok := a.env[a.keyEnv]; ok { + pairs, missing := expandEnv(map[string]string{a.keyEnv: raw}) + if err := firstMissing(a.name, missing); err != nil { + return "", err + } + key := strings.TrimPrefix(pairs[0], a.keyEnv+"=") + if strings.TrimSpace(key) == "" { + return "", &EnvError{Agent: a.name, Var: a.keyEnv} + } + return key, nil } - return nil + key := os.Getenv(a.keyEnv) + if strings.TrimSpace(key) == "" { + return "", &EnvError{Agent: a.name, Var: a.keyEnv} + } + return key, nil +} + +// Health reports whether the agent is usable without network I/O: its API key +// must resolve (mirrors CLIAgent's cheap binary-on-PATH check). +func (a *APIAgent) Health() error { + _, err := a.apiKey() + return err } // outputContract is the strict reply format demanded of the model. @@ -193,7 +218,10 @@ func (a *APIAgent) Probe(ctx context.Context, timeout time.Duration) ProbeResult case err != nil: detail := firstMeaningfulLine(err.Error()) var le *llm.Error - if errors.As(err, &le) && le.UserDetail() != "" { + switch { + case errors.As(err, &le) && le.Kind == llm.ErrAuth: + detail = fmt.Sprintf("%s rejected the key in %s — check it and that it can use this model", le.Provider, a.keyEnv) + case errors.As(err, &le) && le.UserDetail() != "": detail = le.UserDetail() } return ProbeResult{OK: false, Detail: detail} @@ -211,7 +239,12 @@ func (a *APIAgent) complete(ctx context.Context, system, user string, timeout ti ctx, cancel = context.WithTimeout(ctx, timeout) defer cancel() } + key, err := a.apiKey() + if err != nil { + return llm.Response{}, err + } return a.prov.Complete(ctx, llm.Request{ + APIKey: key, System: system, Messages: []llm.Message{{Role: "user", Content: user}}, Temperature: 0, diff --git a/internal/agent/env.go b/internal/agent/env.go new file mode 100644 index 0000000..b879cd3 --- /dev/null +++ b/internal/agent/env.go @@ -0,0 +1,85 @@ +package agent + +import ( + "errors" + "fmt" + "os" + "sort" + "strings" +) + +// EnvError reports a variable an agent needs from the environment that is +// unset or empty — typically a missing API key. Callers use errors.As to tell +// "missing key" apart from "not installed". +type EnvError struct { + Agent string // agent name + Var string // the environment variable that is missing + For string // the env: entry that references it; "" when Var is the API key itself +} + +func (e *EnvError) Error() string { + if e.For != "" { + return fmt.Sprintf("%s is not set (referenced by env.%s of agent %q)", e.Var, e.For, e.Agent) + } + return fmt.Sprintf("%s is not set: export it, or point api_key_env / env at the variable holding your key (agent %q)", e.Var, e.Agent) +} + +// expandEnv resolves an agent's env: block against the process environment. +// Values may reference variables as $VAR or ${VAR}. It returns sorted +// KEY=value pairs for the child process, plus every referenced variable that +// is unset or empty (in key order), so Health can report them without the +// values ever being printed. +func expandEnv(env map[string]string) (pairs []string, missing []EnvError) { + keys := make([]string, 0, len(env)) + for k := range env { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + v := os.Expand(env[k], func(name string) string { + val := os.Getenv(name) + if strings.TrimSpace(val) == "" { + missing = append(missing, EnvError{Var: name, For: k}) + } + return val + }) + pairs = append(pairs, k+"="+v) + } + return pairs, missing +} + +// firstMissing turns the first unresolved reference into an error for agent. +func firstMissing(agent string, missing []EnvError) error { + if len(missing) == 0 { + return nil + } + e := missing[0] + e.Agent = agent + return &e +} + +// copyEnv defensively copies an env: block. +func copyEnv(env map[string]string) map[string]string { + if len(env) == 0 { + return nil + } + out := make(map[string]string, len(env)) + for k, v := range env { + out[k] = v + } + return out +} + +// HealthLabel is a short status for a Health result: "available", +// "missing key" (an *EnvError), or "not installed". +func HealthLabel(err error) string { + var ee *EnvError + switch { + case err == nil: + return "available" + case errors.As(err, &ee): + return "missing key" + default: + return "not installed" + } +} diff --git a/internal/agent/env_test.go b/internal/agent/env_test.go new file mode 100644 index 0000000..225c441 --- /dev/null +++ b/internal/agent/env_test.go @@ -0,0 +1,146 @@ +package agent + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "os/exec" + "slices" + "strings" + "testing" + "time" + + "github.com/crossben/orchestra-code/internal/llm" +) + +func TestExpandEnvResolvesAndReportsMissing(t *testing.T) { + t.Setenv("BYOK_SET", "sk-set") + t.Setenv("BYOK_EMPTY", "") + pairs, missing := expandEnv(map[string]string{ + "B_KEY": "${BYOK_SET}", + "A_PLAIN": "literal", + "C_GONE": "$BYOK_UNSET_XYZ", + "D_EMPTY": "${BYOK_EMPTY}", + }) + want := []string{"A_PLAIN=literal", "B_KEY=sk-set", "C_GONE=", "D_EMPTY="} + if !slices.Equal(pairs, want) { + t.Fatalf("pairs = %v, want %v (sorted, expanded)", pairs, want) + } + if len(missing) != 2 || missing[0].Var != "BYOK_UNSET_XYZ" || missing[0].For != "C_GONE" || + missing[1].Var != "BYOK_EMPTY" || missing[1].For != "D_EMPTY" { + t.Fatalf("missing = %+v", missing) + } +} + +func TestCLIAgentEnvReachesProcess(t *testing.T) { + t.Setenv("BYOK_SRC", "sk-from-parent") + a := New("echoer", "sh", []string{"-c", `printf '%s' "$GROQ_API_KEY"`, "sh"}, "", nil) + a.SetEnv(map[string]string{"GROQ_API_KEY": "${BYOK_SRC}"}) + out, err := a.Query(context.Background(), Task{Prompt: "ignored", Dir: t.TempDir(), Timeout: 5 * time.Second}) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(out) != "sk-from-parent" { + t.Fatalf("child saw %q", out) + } +} + +func TestCLIAgentHealthReportsMissingEnv(t *testing.T) { + if _, err := exec.LookPath("true"); err != nil { + t.Skip("no true on PATH") + } + a := New("opencode-groq", "true", nil, "", nil) + a.SetEnv(map[string]string{"GROQ_API_KEY": "${BYOK_NOT_SET_ABC}"}) + err := a.Health() + var ee *EnvError + if !errors.As(err, &ee) || ee.Var != "BYOK_NOT_SET_ABC" { + t.Fatalf("want EnvError for BYOK_NOT_SET_ABC, got %v", err) + } + if !strings.Contains(err.Error(), "GROQ_API_KEY") { + t.Fatalf("message should name the env entry: %v", err) + } + t.Setenv("BYOK_NOT_SET_ABC", "x") + if err := a.Health(); err != nil { + t.Fatalf("healthy once set: %v", err) + } +} + +func TestCLIAgentMissingBinaryIsNotEnvError(t *testing.T) { + a := New("ghost", "definitely-not-a-binary-xyz", nil, "", nil) + var ee *EnvError + if err := a.Health(); err == nil || errors.As(err, &ee) { + t.Fatalf("want a plain not-installed error, got %v", err) + } +} + +func TestAPIAgentHealthMissingKeyIsEnvError(t *testing.T) { + t.Setenv("BYOK_API_KEY", "") + a, err := NewAPI("x", "anthropic", "m", "", "BYOK_API_KEY", nil, 0) + if err != nil { + t.Fatal(err) + } + herr := a.Health() + var ee *EnvError + if !errors.As(herr, &ee) || ee.Var != "BYOK_API_KEY" { + t.Fatalf("want EnvError, got %v", herr) + } +} + +func TestAPIAgentKeyFromEnvBlock(t *testing.T) { + t.Setenv("ANTHROPIC_API_KEY", "") + t.Setenv("WORK_ANTHROPIC_KEY", "sk-work") + a, err := NewAPI("x", "anthropic", "m", "", "", nil, 0) + if err != nil { + t.Fatal(err) + } + a.SetEnv(map[string]string{"ANTHROPIC_API_KEY": "${WORK_ANTHROPIC_KEY}"}) + if err := a.Health(); err != nil { + t.Fatalf("env block should supply the key: %v", err) + } + fp := &fakeProvider{text: "OK"} + a.prov = fp + if _, err := a.Query(context.Background(), Task{Prompt: "hi"}); err != nil { + t.Fatal(err) + } + if fp.last.APIKey != "sk-work" { + t.Fatalf("request key = %q", fp.last.APIKey) + } +} + +// Regression: API agents built by NewAPI must actually send the key. +func TestAPIAgentSendsKeyOverHTTP(t *testing.T) { + var got string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"OK"}}]}`)) + })) + defer srv.Close() + t.Setenv("BYOK_OPENAI", "sk-real") + a, err := NewAPI("x", "openai", "m", srv.URL, "BYOK_OPENAI", nil, 0) + if err != nil { + t.Fatal(err) + } + if pr := a.Probe(context.Background(), 5*time.Second); !pr.OK { + t.Fatalf("probe: %+v", pr) + } + if got != "Bearer sk-real" { + t.Fatalf("Authorization header = %q", got) + } +} + +func TestAPIAgentProbeAuthNamesKeyVar(t *testing.T) { + a := newTestAPIAgent(t, &fakeProvider{err: &llm.Error{Provider: "anthropic", Kind: llm.ErrAuth, Status: 401}}) + pr := a.Probe(context.Background(), time.Second) + if pr.OK || !strings.Contains(pr.Detail, "FAKE_KEY_ENV") { + t.Fatalf("auth failure should name the key variable, got %+v", pr) + } +} + +func TestHealthLabel(t *testing.T) { + if HealthLabel(nil) != "available" || HealthLabel(&EnvError{Var: "K"}) != "missing key" || + HealthLabel(errors.New("exec: not found")) != "not installed" { + t.Fatal("unexpected labels") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 7dc9055..147186f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "time" @@ -31,6 +32,12 @@ type AgentConfig struct { DirFlag string `yaml:"dir_flag"` // cli: flag to pass the working dir (e.g. opencode "--dir") Capabilities []string `yaml:"capabilities"` // plan|implement|review + // Env adds environment variables for the agent (bring your own key). + // Values may reference variables as $VAR / ${VAR}, expanded at run time: + // cli agents receive them in their process environment; api agents use + // the entry named by api_key_env as their key. + Env map[string]string `yaml:"env"` + // API-agent fields (used when Type == "api"). Provider string `yaml:"provider"` // openai (default) | anthropic Model string `yaml:"model"` // required @@ -420,6 +427,7 @@ func (c *Config) BuildRegistry() *agent.Registry { continue } a.SetPricing(ac.PriceInputPerMTok, ac.PriceOutputPerMTok) + a.SetEnv(ac.Env) reg.Add(a) continue } @@ -427,7 +435,41 @@ func (c *Config) BuildRegistry() *agent.Registry { if bin == "" { bin = ac.Name } - reg.Add(agent.New(ac.Name, bin, ac.Args, ac.DirFlag, caps)) + a := agent.New(ac.Name, bin, ac.Args, ac.DirFlag, caps) + a.SetEnv(ac.Env) + reg.Add(a) } return reg } + +// LiteralSecretWarnings flags env: entries that look like credentials but hold +// a literal value instead of a $VAR reference — orchestra.yaml is often +// committed, so keys belong in the environment. Values are never echoed. +func (c *Config) LiteralSecretWarnings() []string { + var out []string + for _, ac := range c.Agents { + keys := make([]string, 0, len(ac.Env)) + for k := range ac.Env { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + v := ac.Env[k] + if v == "" || strings.Contains(v, "$") || !looksSecret(k) { + continue + } + out = append(out, fmt.Sprintf("agent %q: env.%s holds a literal value; use \"${SOME_VAR}\" so the key stays out of orchestra.yaml", ac.Name, k)) + } + } + return out +} + +func looksSecret(name string) bool { + n := strings.ToUpper(name) + for _, s := range []string{"KEY", "TOKEN", "SECRET", "PASSWORD"} { + if strings.Contains(n, s) { + return true + } + } + return false +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 62e442a..e7d1933 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,8 +1,10 @@ package config import ( + "errors" "os" "path/filepath" + "strings" "testing" "github.com/crossben/orchestra-code/internal/agent" @@ -156,3 +158,53 @@ func loadFromSrc(dir, src string) (*Config, error) { func writeFile(dir, name, content string) error { return os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644) } + +func TestBuildRegistryPassesEnvBlock(t *testing.T) { + t.Setenv("BYOK_CFG_GROQ", "") + t.Setenv("BYOK_CFG_WORK", "sk-work") + cfg, err := parseForTest(t, ` +agents: + - name: opencode-groq + bin: true + env: + GROQ_API_KEY: "${BYOK_CFG_GROQ}" + - name: sonnet + type: api + provider: anthropic + model: m + api_key_env: BYOK_CFG_UNUSED + env: + BYOK_CFG_UNUSED: "${BYOK_CFG_WORK}" +`) + if err != nil { + t.Fatal(err) + } + reg := cfg.BuildRegistry() + cli, _ := reg.Get("opencode-groq") + var ee *agent.EnvError + if err := cli.Health(); !errors.As(err, &ee) || ee.Var != "BYOK_CFG_GROQ" { + t.Fatalf("cli agent should report the unset reference, got %v", err) + } + api, _ := reg.Get("sonnet") + if err := api.Health(); err != nil { + t.Fatalf("api agent key should come from env block: %v", err) + } +} + +func TestLiteralSecretWarnings(t *testing.T) { + cfg, err := parseForTest(t, ` +agents: + - name: a + env: + GROQ_API_KEY: "gsk_live_abc" + MY_TOKEN: "${FROM_ENV}" + LOG_LEVEL: "debug" +`) + if err != nil { + t.Fatal(err) + } + w := cfg.LiteralSecretWarnings() + if len(w) != 1 || !strings.Contains(w[0], "GROQ_API_KEY") || strings.Contains(w[0], "gsk_live_abc") { + t.Fatalf("want one warning naming (not echoing) the literal key, got %v", w) + } +} diff --git a/internal/llm/anthropic.go b/internal/llm/anthropic.go index 2266556..2dcec23 100644 --- a/internal/llm/anthropic.go +++ b/internal/llm/anthropic.go @@ -70,8 +70,12 @@ func (a *Anthropic) Complete(ctx context.Context, req Request) (Response, error) Temperature: req.Temperature, } + key := a.key + if req.APIKey != "" { + key = req.APIKey + } headers := map[string]string{ - "x-api-key": a.key, + "x-api-key": key, "anthropic-version": anthropicVersion, } diff --git a/internal/llm/llm.go b/internal/llm/llm.go index f237672..ae47d57 100644 --- a/internal/llm/llm.go +++ b/internal/llm/llm.go @@ -24,6 +24,7 @@ type Message struct { // Request is a single completion request. type Request struct { + APIKey string // overrides the provider's configured key when non-empty System string // system preamble (output contract, principles) Messages []Message // conversation; typically one user message MaxTokens int // hard cap on the reply; provider default when 0 diff --git a/internal/llm/llm_test.go b/internal/llm/llm_test.go index cffe1c1..ca7ff90 100644 --- a/internal/llm/llm_test.go +++ b/internal/llm/llm_test.go @@ -279,3 +279,32 @@ func TestProvidersParseUsage(t *testing.T) { }) } } + +func TestRequestAPIKeyOverridesConfiguredKey(t *testing.T) { + var gotAuth, gotXKey string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth, gotXKey = r.Header.Get("Authorization"), r.Header.Get("x-api-key") + w.Header().Set("Content-Type", "application/json") + if strings.HasSuffix(r.URL.Path, "/v1/messages") { + _, _ = io.WriteString(w, `{"content":[{"type":"text","text":"ok"}]}`) + return + } + _, _ = io.WriteString(w, `{"choices":[{"message":{"content":"ok"}}]}`) + })) + defer srv.Close() + + o, _ := New("openai", srv.URL, "configured", "m", nil) + if _, err := o.Complete(context.Background(), Request{APIKey: "per-request"}); err != nil { + t.Fatal(err) + } + if gotAuth != "Bearer per-request" { + t.Fatalf("openai Authorization = %q", gotAuth) + } + a, _ := New("anthropic", srv.URL, "configured", "m", nil) + if _, err := a.Complete(context.Background(), Request{APIKey: "per-request"}); err != nil { + t.Fatal(err) + } + if gotXKey != "per-request" { + t.Fatalf("anthropic x-api-key = %q", gotXKey) + } +} diff --git a/internal/llm/openai.go b/internal/llm/openai.go index b692b61..afe89c1 100644 --- a/internal/llm/openai.go +++ b/internal/llm/openai.go @@ -55,9 +55,13 @@ func (o *OpenAI) Complete(ctx context.Context, req Request) (Response, error) { } payload := openaiRequest{Model: o.model, Messages: msgs, MaxTokens: req.MaxTokens, Temperature: req.Temperature} + key := o.key + if req.APIKey != "" { + key = req.APIKey + } headers := map[string]string{} - if o.key != "" { - headers["Authorization"] = "Bearer " + o.key + if key != "" { + headers["Authorization"] = "Bearer " + key } var out openaiResponse diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 1d295ba..461d110 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -249,7 +249,7 @@ func (m Model) probeCmd() tea.Cmd { var res agent.ProbeResult switch { case a.Health() != nil: - res = agent.ProbeResult{OK: false, Detail: "not installed"} + res = agent.ProbeResult{OK: false, Detail: agent.HealthLabel(a.Health())} default: if p, ok := a.(agent.Prober); ok { res = p.Probe(context.Background(), 45*time.Second) diff --git a/internal/tui/views.go b/internal/tui/views.go index ea41fda..466db34 100644 --- a/internal/tui/views.go +++ b/internal/tui/views.go @@ -118,8 +118,11 @@ func (m Model) agentsView() string { b.WriteString(" " + t.header() + "\n") for _, a := range m.d.Reg.All() { installed := okSty.Render("✓ yes") - if a.Health() != nil { + if herr := a.Health(); herr != nil { installed = dimSty.Render("✗ no") + if agent.HealthLabel(herr) == "missing key" { + installed = warnSty.Render("✗ key") + } } probe := dimSty.Render("press p") if m.probing {