picoclaw/cmd/picoclaw/internal/model/online.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

77 lines
2.1 KiB
Go

package model
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
type modelEntry struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
}
type modelsAPIResponse struct {
Data []modelEntry `json:"data"`
}
// fetchOpenAIModels GETs <baseURL>/models with Bearer auth and accepts both the
// {data:[…]} envelope and a bare array shape used by various OpenAI-compatible servers.
func fetchOpenAIModels(baseURL, apiKey string) ([]modelEntry, error) {
if strings.TrimSpace(baseURL) == "" {
return nil, fmt.Errorf("api base is required")
}
if strings.TrimSpace(apiKey) == "" {
return nil, fmt.Errorf("api key is required")
}
url := strings.TrimRight(baseURL, "/") + "/models"
client := &http.Client{Timeout: 15 * time.Second}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
// {"data": [...]} envelope. Distinguish "envelope shape with empty list"
// from "object without a data key" via Data being non-nil after unmarshal:
// json.Unmarshal sets Data to []modelEntry{} for `{"data":[]}` but leaves
// it as nil when "data" is absent or null.
var envelope modelsAPIResponse
if err := json.Unmarshal(body, &envelope); err == nil && envelope.Data != nil {
return envelope.Data, nil
}
// Bare-array shape, including `[]`.
var arr []modelEntry
if err := json.Unmarshal(body, &arr); err == nil {
return arr, nil
}
preview := body
if len(preview) > 256 {
preview = preview[:256]
}
return nil, fmt.Errorf("decode response: unrecognized shape: %s", strings.TrimSpace(string(preview)))
}