* 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.
77 lines
2.1 KiB
Go
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)))
|
|
}
|