Files
yetone 5d126a0db7 agents: Pi's model picker lists the models of its openai-codex sign-in from Pi's own registry, and its Default says it lets Pi pick (#709) — Pi's native options came from models.dev by provider id, and models.dev has no openai-codex (Pi's "OpenAI Codex (legacy)" ChatGPT sign-in), so a Pi signed in to both openai and openai-codex was offered openai/… only. Pi's (and OmO's) own options now come first from the model registry in the package Pi was installed from: the pi command on PATH leads to @earendil-works/pi-coding-agent, and pi-ai's dist/providers/data/<provider>.json (Pi 1.0) is read as is, in its order, chat models only (no gpt-image-2), memoized by the file's stamp — the list Pi's /model offers, which moves with every Pi update. A provider the registry doesn't have keeps models.dev's list; openai-codex with no registry in reach falls back to Codex CLI's models_cache.json (the same ChatGPT backend); models.dev's openai list isn't mapped onto it. The rows are openai-codex/<model> under "OpenAI Codex" with OpenAI's logo, a row each beside openai/<model> of the same name; picking one writes defaultProvider=openai-codex, defaultModel=<model> as before. omp shared the gap for its current model's provider (ownOptions): it now reads omp's own catalog (@oh-my-pi/pi-catalog src/models.json, non-chat kinds left out); omp's signed-in providers that aren't the current one are still #341's. Default in Pi's and OmO's model picker now says "clears the default model; Pi picks one on its own" (zh 清除显式默认模型,由 Pi 自动选择; no ja table exists), other agents' still say what they ship with. TestPiOffersItsOwnOpenAICodexModels (a fake npm-linked Pi 1.0 install, temp HOME and PI_CODING_AGENT_DIR, auth.json with openai and openai-codex oauth test metadata), TestPiOpenAICodexWithoutItsRegistry and TestOmpOffersItsOwnOpenAICodexModels fail on the old code (no openai-codex rows) and pass; pi-default-note.test.cjs (webkit, en/zh) fails on the old app.js and passes; agent-disconnect, agent-models and agent-model-groups GUI tests pass. Read-only against this machine's real installs: Pi 1.0.0 gives openai-codex 9 models and openai 44, omp's catalog openai-codex 8. go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass but TestProvidersAnswerWhileListsComeIn, TestClaudeSignedOut, TestClaudeAccountNamedAsItsLogin and TestClaudeIdentityServedWhileAsked, timing tests that pass alone. Not tried in the GUI with real Pi sign-ins, nor with a Pi older than 1.0 (no data folder: models.dev and Codex CLI's list as before) or a Windows npm shim.
2026-10-04 01:53:58 +08:00

193 lines
6.1 KiB
Go

package agent
import (
"bytes"
"encoding/json"
"errors"
"os/exec"
"path/filepath"
"strings"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/filememo"
)
// A Pi-family agent's built-in providers are the ones its own model
// registry ships, not models.dev's: models.dev has no openai-codex (Pi's
// "OpenAI Codex (legacy)" sign-in, the ChatGPT backend), so a Pi signed in
// to it was offered none of its models (#709). The registry is read from
// the package the agent was installed from, so it is the list that agent
// itself offers in its /model, and moves with it on every update.
// nodeModulesOf is where a CLI installed from one of pkgs resolves its
// packages: its own package's node_modules, then the node_modules it sits
// in (npm, bun and Homebrew's npm link the command into it). nil when the
// command isn't on PATH or wasn't installed from npm. A var for tests.
var nodeModulesOf = findNodeModules
func findNodeModules(bin string, pkgs []string) []string {
path, err := exec.LookPath(bin)
if err != nil {
return nil
}
real, err := filepath.EvalSymlinks(path)
if err != nil {
real = path
}
slash := filepath.ToSlash(real)
for _, pkg := range pkgs {
modules := ""
if i := strings.Index(slash, "/node_modules/"+pkg+"/"); i >= 0 {
modules = filepath.FromSlash(slash[:i] + "/node_modules")
} else if d := filepath.Join(filepath.Dir(path), "node_modules"); isDir(filepath.Join(d, filepath.FromSlash(pkg))) {
modules = d // npm's shim on Windows sits beside its node_modules
}
if modules != "" {
return []string{filepath.Join(modules, filepath.FromSlash(pkg), "node_modules"), modules}
}
}
return nil
}
// safeProvider says p can be a file name: auth.json's keys come from the
// user's file.
func safeProvider(p string) bool {
return p != "" && p != "." && p != ".." && !strings.ContainsAny(p, `/\:`)
}
// registryModel is a model as Pi's and omp's registries list it.
type registryModel struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"` // Pi: chat, image, classifier
Kind string `json:"kind"` // omp: "" for chat, image, embedding…
Input []string `json:"input"`
Context int `json:"contextWindow"`
Output int `json:"maxTokens"`
}
func (m registryModel) chat() bool {
return m.ID != "" && (m.Type == "" || m.Type == "chat") && (m.Kind == "" || m.Kind == "tiny")
}
func (m registryModel) model(provider string) catalog.Model {
name := m.Name
if name == "" {
name = m.ID
}
images := false
for _, in := range m.Input {
images = images || in == "image"
}
return catalog.Model{ID: m.ID, Name: name, Provider: provider, Images: images, Context: m.Context, Output: m.Output}
}
// orderedObject calls fn with each key and value of a JSON object, in the
// order the file has them: the registry's order is the agent's own.
func orderedObject(b []byte, fn func(key string, v json.RawMessage) error) error {
d := json.NewDecoder(bytes.NewReader(b))
if t, err := d.Token(); err != nil || t != json.Delim('{') {
return errors.New("not an object")
}
for d.More() {
t, err := d.Token()
if err != nil {
return err
}
k, _ := t.(string)
var v json.RawMessage
if err := d.Decode(&v); err != nil {
return err
}
if err := fn(k, v); err != nil {
return err
}
}
return nil
}
// modelsIn is the chat models of an object of models by id, in its order.
func modelsIn(b []byte, provider string) []catalog.Model {
var out []catalog.Model
seen := map[string]bool{}
orderedObject(b, func(_ string, v json.RawMessage) error {
var m registryModel
if json.Unmarshal(v, &m) == nil && m.chat() && !seen[m.ID] {
seen[m.ID] = true
out = append(out, m.model(provider))
}
return nil
})
return out
}
// piPackages are the npm packages Pi is installed from, newest name first.
var piPackages = []string{"@earendil-works/pi-coding-agent", "@mariozechner/pi-coding-agent"}
// piRegistry is the models Pi's installed registry has for provider p:
// pi-ai's dist/providers/data/<p>.json (Pi 1.0), groups by API of models
// by "type:id". nil when Pi or the provider isn't found there.
func piRegistry(bin string) func(p string) []catalog.Model {
return func(p string) []catalog.Model {
if !safeProvider(p) {
return nil
}
for _, modules := range nodeModulesOf(bin, piPackages) {
for _, ai := range []string{"@earendil-works/pi-ai", "@mariozechner/pi-ai"} {
file := filepath.Join(modules, filepath.FromSlash(ai), "dist", "providers", "data", p+".json")
ms, err := filememo.Read("pi models", file, func(b []byte) ([]catalog.Model, error) {
var out []catalog.Model
err := orderedObject(b, func(_ string, group json.RawMessage) error {
out = append(out, modelsIn(group, p)...)
return nil
})
return out, err
})
if err == nil && len(ms) > 0 {
return ms
}
}
}
return nil
}
}
// ompRegistry is the models omp's installed catalog has for provider p:
// @oh-my-pi/pi-catalog's src/models.json, models by id under each provider.
func ompRegistry(p string) []catalog.Model {
for _, modules := range nodeModulesOf("omp", []string{"@oh-my-pi/pi-coding-agent"}) {
file := filepath.Join(modules, "@oh-my-pi", "pi-catalog", "src", "models.json")
all, err := filememo.Read("omp models", file, func(b []byte) (map[string][]catalog.Model, error) {
out := map[string][]catalog.Model{}
err := orderedObject(b, func(provider string, v json.RawMessage) error {
out[provider] = modelsIn(v, provider)
return nil
})
return out, err
})
if err == nil {
if ms := all[p]; len(ms) > 0 {
return ms
}
}
}
return nil
}
// codexServed is, for openai-codex when the agent's registry isn't found,
// the models Codex CLI last listed for a ChatGPT account: the same backend.
func codexServed(p string) []catalog.Model {
if p != "openai-codex" {
return nil
}
ms := catalog.Codex()
for i := range ms {
ms[i].Provider = p
}
return ms
}
// nativeProviderNames are the names of built-in providers models.dev
// doesn't list, as their agents call them.
var nativeProviderNames = map[string]string{"openai-codex": "OpenAI Codex"}