picoclaw/cmd/picoclaw/internal/model/add_test.go
taonyx a36472b55f
Add CLI support for custom OpenAI-compatible endpoints and remove TUI (#2710)
* feat(model): add `picoclaw model add` for custom OpenAI-compatible endpoints

Onboards a model from a user-supplied API base + key by hitting
GET <base>/models, prompting the user to pick one, and writing the entry
into model_list[] (with api_keys) plus setting it as the default model.
This was previously only available in the TUI launcher (issue #2208) and
is now accessible from the CLI:

    picoclaw model add -b URL -k KEY [-m MODEL] [-n ALIAS]

* chore: remove deprecated picoclaw-launcher-tui

Per RFC #2208, the TUI launcher is deprecated in favor of the CLI; its
"online model picker" feature has been ported to `picoclaw model add` in
the previous commit. This drops the binary and all build/release/docs
references:

- delete cmd/picoclaw-launcher-tui/ and assets/launcher-tui.jpg
- Makefile: remove the `build-launcher-tui` target
- .goreleaser.yaml: drop the build entry plus the `picoclaw-launcher-tui`
  ids from the launcher docker image, macOS notarize list, and nfpms
  contents
- docker/Dockerfile.goreleaser.launcher: drop the COPY for the TUI binary
- READMEs (root + 8 locales): remove the "TUI Launcher" section and
  screenshot link
- docs/guides/docker.*: update the "launcher image includes …" sentence
  to reflect the two remaining binaries

`make build` still succeeds; `go build ./web/backend` (the launcher
target) still succeeds. `picoclaw-launcher` (web console) is unaffected.
2026-04-29 17:52:47 +08:00

257 lines
7.1 KiB
Go

package model
import (
"bytes"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestFetchOpenAIModels_DataEnvelope(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/models", r.URL.Path)
assert.Equal(t, "Bearer secret", r.Header.Get("Authorization"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"id":"gpt-foo","name":"Foo"},{"id":"gpt-bar"}]}`))
}))
defer srv.Close()
entries, err := fetchOpenAIModels(srv.URL, "secret")
require.NoError(t, err)
require.Len(t, entries, 2)
assert.Equal(t, "gpt-foo", entries[0].ID)
assert.Equal(t, "Foo", entries[0].Name)
assert.Equal(t, "gpt-bar", entries[1].ID)
}
func TestFetchOpenAIModels_BareArray(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[{"id":"a"},{"id":"b"}]`))
}))
defer srv.Close()
entries, err := fetchOpenAIModels(srv.URL, "secret")
require.NoError(t, err)
require.Len(t, entries, 2)
assert.Equal(t, "a", entries[0].ID)
assert.Equal(t, "b", entries[1].ID)
}
func TestFetchOpenAIModels_TrimsTrailingSlash(t *testing.T) {
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
_, _ = w.Write([]byte(`{"data":[{"id":"x"}]}`))
}))
defer srv.Close()
_, err := fetchOpenAIModels(srv.URL+"/", "k")
require.NoError(t, err)
assert.Equal(t, "/models", gotPath)
}
func TestFetchOpenAIModels_HTTPError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "nope", http.StatusUnauthorized)
}))
defer srv.Close()
_, err := fetchOpenAIModels(srv.URL, "bad")
require.Error(t, err)
assert.Contains(t, err.Error(), "HTTP 401")
}
func TestFetchOpenAIModels_EmptyDataEnvelope(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"data":[]}`))
}))
defer srv.Close()
entries, err := fetchOpenAIModels(srv.URL, "k")
require.NoError(t, err)
assert.Empty(t, entries)
}
func TestFetchOpenAIModels_EmptyBareArray(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`[]`))
}))
defer srv.Close()
entries, err := fetchOpenAIModels(srv.URL, "k")
require.NoError(t, err)
assert.Empty(t, entries)
}
func TestFetchOpenAIModels_UnrecognizedShape(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"models":"not-supported"}`))
}))
defer srv.Close()
_, err := fetchOpenAIModels(srv.URL, "k")
require.Error(t, err)
assert.Contains(t, err.Error(), "unrecognized shape")
}
func TestFetchOpenAIModels_RequiresInputs(t *testing.T) {
_, err := fetchOpenAIModels("", "k")
require.Error(t, err)
assert.Contains(t, err.Error(), "api base")
_, err = fetchOpenAIModels("https://example.com", "")
require.Error(t, err)
assert.Contains(t, err.Error(), "api key")
}
func TestPickModel_ByIndex(t *testing.T) {
entries := []modelEntry{{ID: "a"}, {ID: "b"}, {ID: "c"}}
out := &bytes.Buffer{}
got, err := pickModel(strings.NewReader("2\n"), out, entries)
require.NoError(t, err)
assert.Equal(t, "b", got)
assert.Contains(t, out.String(), "3 model(s) available")
}
func TestPickModel_ByID(t *testing.T) {
entries := []modelEntry{{ID: "alpha"}, {ID: "beta"}}
out := &bytes.Buffer{}
got, err := pickModel(strings.NewReader("beta\n"), out, entries)
require.NoError(t, err)
assert.Equal(t, "beta", got)
}
func TestPickModel_RetriesOnInvalid(t *testing.T) {
entries := []modelEntry{{ID: "x"}}
out := &bytes.Buffer{}
got, err := pickModel(strings.NewReader("\n9\nnot-a-model\nx\n"), out, entries)
require.NoError(t, err)
assert.Equal(t, "x", got)
rendered := out.String()
assert.Contains(t, rendered, "Out of range")
assert.Contains(t, rendered, "Not a valid number")
}
func TestRunAdd_WithExplicitModel_NoNetwork(t *testing.T) {
initTest(t)
out := &bytes.Buffer{}
err := runAdd(addOptions{
apiBase: "https://invalid.invalid/v1",
apiKey: "k",
modelID: "explicit-model",
alias: "myalias",
modelType: "openai-compatible",
stdout: out,
})
require.NoError(t, err)
assert.Contains(t, out.String(), "Saved model 'myalias' (explicit-model)")
cfg, err := config.LoadConfig(configPath)
require.NoError(t, err)
assert.Equal(t, "myalias", cfg.Agents.Defaults.GetModelName())
added := findModelByName(cfg, "myalias")
require.NotNil(t, added, "expected model 'myalias' in model_list")
assert.Equal(t, "explicit-model", added.Model)
assert.Equal(t, "https://invalid.invalid/v1", added.APIBase)
assert.True(t, added.Enabled)
require.Len(t, added.APIKeys, 1)
assert.Equal(t, "k", added.APIKeys[0].String())
}
func findModelByName(cfg *config.Config, name string) *config.ModelConfig {
for _, m := range cfg.ModelList {
if m != nil && m.ModelName == name {
return m
}
}
return nil
}
func TestRunAdd_FetchAndPick(t *testing.T) {
initTest(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "Bearer my-key", r.Header.Get("Authorization"))
_, _ = w.Write([]byte(`{"data":[{"id":"m1"},{"id":"m2"}]}`))
}))
defer srv.Close()
out := &bytes.Buffer{}
err := runAdd(addOptions{
apiBase: srv.URL,
apiKey: "my-key",
alias: defaultAliasName,
modelType: "openai-compatible",
stdin: strings.NewReader("2\n"),
stdout: out,
})
require.NoError(t, err)
cfg, err := config.LoadConfig(configPath)
require.NoError(t, err)
assert.Equal(t, defaultAliasName, cfg.Agents.Defaults.GetModelName())
added := findModelByName(cfg, defaultAliasName)
require.NotNil(t, added)
assert.Equal(t, "m2", added.Model)
}
func TestRunAdd_UpsertsExistingAlias(t *testing.T) {
initTest(t)
first := &bytes.Buffer{}
require.NoError(t, runAdd(addOptions{
apiBase: "https://a.example/v1",
apiKey: "k1",
modelID: "m1",
alias: "shared",
stdout: first,
}))
second := &bytes.Buffer{}
require.NoError(t, runAdd(addOptions{
apiBase: "https://b.example/v1",
apiKey: "k2",
modelID: "m2",
alias: "shared",
stdout: second,
}))
cfg, err := config.LoadConfig(configPath)
require.NoError(t, err)
matches := 0
for _, m := range cfg.ModelList {
if m != nil && m.ModelName == "shared" {
matches++
}
}
assert.Equal(t, 1, matches, "alias should be updated, not duplicated")
updated := findModelByName(cfg, "shared")
require.NotNil(t, updated)
assert.Equal(t, "m2", updated.Model)
assert.Equal(t, "https://b.example/v1", updated.APIBase)
assert.Equal(t, "k2", updated.APIKeys[0].String())
}
func TestRunAdd_RejectsUnsupportedType(t *testing.T) {
initTest(t)
err := runAdd(addOptions{
apiBase: "https://x/v1",
apiKey: "k",
modelID: "m",
alias: "a",
modelType: "anthropic",
stdout: &bytes.Buffer{},
})
require.Error(t, err)
assert.Contains(t, err.Error(), "unsupported --type")
}