Files
magpie/internal/provider/plugins.go
T
yetone a999770531 plugins: a plugin's subscription does what a built-in's does, in seven places it didn't (the user: 要保证插件订阅 provider 跟内置的订阅 provider所有逻辑都要一模一样) — an installed plugin's provider id is kept for it signed in or not, as a built-in's is: a provider of the user's own isn't saved on it, renamed onto it or imported as it (freeID gives id-2). A plugin's account whose plan lacks the model is tried after the ones that list it, as a Codex Free account behind a Plus, by the list the plugin told for it. Cursor's plugin has fast mode where the built-in Cursor has it: a model with a -fast one is saved :fast in a group and sent service_tier "priority", one without is refused. Removing a plugin's provider hides it as a built-in's is, its accounts kept and shown again from Hidden or by signing in, rather than signing every account out ("stays signed in" said so already). magpie accounts lists, adds, switches and forgets a plugin's accounts by its provider's id or name. Grok's plugin beside the built-in Grok (grok-plugin) searches by itself as Grok does. A plugin's plan is named as its built-in's ("Cursor Pro", "SuperGrok"), and a plugin's resets are shown but have no Use button or Auto-use, which spent Codex's reset of the same user. TestPluginIDReservedSignedOut, TestPluginAccountLackingTheModelGoesLast, TestCursorPluginGroupFast, TestAccountsOfAPlugin, TestGrokPluginSearchesItself, plugin-plan and codex-auto-reset fail without the change; TestPluginSignIn and TestPluginAccounts now expect a removed plugin hidden. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass (TestClaudeSignedOut failed once under load, passes alone); the gateway, provider and plugin suites and TestAccountsOfAPlugin pass on Windows, the plugin tests on Linux.
2026-10-01 20:26:55 +08:00

476 lines
14 KiB
Go

package provider
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"slices"
"strconv"
"strings"
"time"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/plugin"
)
// A plugin's provider (see internal/plugin) is a subscription like the
// built-in ones: signed in to through the plugin, its models the ones the
// plugin lists, its requests made by the plugin's fetch. It speaks what
// OpenCode would speak to each model, by the AI SDK package the model is
// spoken to with.
// pluginBase is where a plugin provider's requests seem to go, for the
// gateway: its transport takes them to where the plugin says instead.
const pluginBase = "plugin://"
// ConversationHeader carries the conversation a request belongs to, to a
// plugin's chat.headers hook as its session; it goes no further.
const ConversationHeader = "X-Magpie-Conversation"
// PluginID is the id magpie gives the provider OpenCode calls id: the same,
// unless a preset or a built-in subscription has it (google, openai,
// anthropic), when it is id-plugin — but for a built-in moved onto the
// plugin, whose id the plugin has now.
func PluginID(id string) string {
if Moved(id) {
return id
}
if slices.Contains(accountIDs, id) || Preset(id) != nil || id == "magpie" {
return id + "-plugin"
}
return id
}
// subscriptionID is whether id is a subscription's, a built-in's or an
// installed plugin's, signed in or not: a provider of the user's own never
// takes it, or it would hide that subscription once signed in.
func subscriptionID(id string) bool {
if slices.Contains(accountIDs, id) {
return true
}
_, ok := PluginOf(id)
return ok
}
// IsPlugin is whether the provider is a plugin's.
func (p Provider) IsPlugin() bool { return p.Account != nil && p.Account.plugin != nil }
// PluginProvider is the id OpenCode knows the plugin's provider p is by
// (grok, kiro), whatever magpie names it; "" for one no plugin gives.
func (p Provider) PluginProvider() string {
if !p.IsPlugin() {
return ""
}
return p.Account.plugin.ID
}
// PluginOf is the plugin provider magpie's provider id is, when it is one.
func PluginOf(id string) (plugin.Provider, bool) {
for _, pp := range plugin.Cached() {
if PluginID(pp.ID) == id {
return pp, true
}
}
return plugin.Provider{}, false
}
// pluginAccounts are the plugins' providers signed in to, each as its
// first account.
func pluginAccounts() []Provider {
var out []Provider
for _, pp := range plugin.Cached() {
if movingNow(pp.ID) {
continue // shown once the move is through (migrate.go)
}
if ls := pluginLogins(pp); len(ls) > 0 {
out = append(out, pluginProvider(pp, ls[0]))
}
}
return out
}
// pluginProtocol is the API model is spoken to on, as OpenCode's AI SDK
// package for it speaks: Anthropic's, OpenAI's Responses (and Copilot's
// for GPT-5 and after), Gemini's (as Code Assist's inner request), else
// chat completions.
func pluginProtocol(provider string, m plugin.Model) Protocol {
switch m.NPM {
case "@ai-sdk/anthropic", "@ai-sdk/google-vertex/anthropic":
return Anthropic
case "@ai-sdk/openai", "@ai-sdk/azure":
return Responses
case "@ai-sdk/google":
return CodeAssist
case "@ai-sdk/github-copilot":
return copilotAPI(modelAPIID(m))
}
if strings.HasPrefix(provider, "github-copilot") {
return copilotAPI(modelAPIID(m))
}
return Chat
}
var gptN = regexp.MustCompile(`^gpt-(\d+)`)
// copilotAPI is OpenCode's shouldUseCopilotResponsesApi: GPT-5 and after,
// but for gpt-5-mini, on Responses.
func copilotAPI(model string) Protocol {
if m := gptN.FindStringSubmatch(model); m != nil {
if n, _ := strconv.Atoi(m[1]); n >= 5 && !strings.HasPrefix(model, "gpt-5-mini") {
return Responses
}
}
return Chat
}
// npmBase is where the AI SDK package sends a model by default.
var npmBase = map[string]string{
"@ai-sdk/anthropic": "https://api.anthropic.com/v1",
"@ai-sdk/openai": "https://api.openai.com/v1",
"@ai-sdk/google": "https://generativelanguage.googleapis.com/v1beta",
"@ai-sdk/github-copilot": "https://api.githubcopilot.com",
"@ai-sdk/xai": "https://api.x.ai/v1",
"@ai-sdk/mistral": "https://api.mistral.ai/v1",
"@ai-sdk/groq": "https://api.groq.com/openai/v1",
"@ai-sdk/deepseek": "https://api.deepseek.com/v1",
}
func pluginModel(pp plugin.Provider, id string) (plugin.Model, bool) {
for _, m := range pp.Models {
if m.ID == id {
return m, true
}
}
return plugin.Model{}, false
}
func pluginCatalog(pp plugin.Provider) []catalog.Model {
out := make([]catalog.Model, 0, len(pp.Models))
for _, m := range pp.Models {
c := catalog.Model{
ID: m.ID, Name: m.Name, Provider: pp.ID, Released: m.Released,
APIs: []string{string(pluginProtocol(pp.ID, m))}, Images: m.Image,
Context: m.Input, Output: m.Output, Free: m.Free,
}
if c.Context == 0 {
c.Context = m.Context
}
// Cursor's own ids no catalog knows: one not named a 1M model
// holds what the catalog knows its base to, as the built-in's did
if pp.ID == "cursor" && c.Context <= cursorDefaultContext {
if n := cursorContext(m.ID, m.Name); c.Context == 0 || n < c.Context {
c.Context = n
}
}
if c.Name == "" {
c.Name = m.ID
}
if m.Reasoning {
c.Efforts, c.Reasoning = m.Variants, true
}
// a built-in moved onto its plugin keeps the levels it had for a
// model its vendor gives none: its maker's, as effortsOf borrows
if len(c.Efforts) == 0 && Moved(pp.ID) {
c.Efforts = borrowedEfforts(m.ID)
}
// OpenCode's price of a model it has none for is 0, as the
// plugins give a plan's models: a price is only one above it
if m.Cost != nil && (m.Cost.Input > 0 || m.Cost.Output > 0) {
c.Price = &catalog.Price{Input: m.Cost.Input, Output: m.Cost.Output}
}
if m.ImageSaid {
c.ImageInput = &m.Image
}
out = append(out, c)
}
return out
}
// pluginAccountCatalog is what the account at key serves: its own list
// when the plugin told one, as the built-ins read each account's.
func pluginAccountCatalog(pp plugin.Provider, key string) []catalog.Model {
all := pluginCatalog(pp)
for _, a := range pp.Accounts {
if a.Key == key && a.Models != nil {
return slices.DeleteFunc(all, func(m catalog.Model) bool { return !slices.Contains(a.Models, m.ID) })
}
}
return all
}
// pluginLists is whether a plugin's account serves model, as the plugin
// last told its list; one it told none of serves all of the provider's.
func (a *Account) pluginLists(model string) bool {
pp := *a.plugin
if cur, ok := PluginOf(PluginID(pp.ID)); ok {
pp = cur
}
for _, ac := range pp.Accounts {
if ac.Key == a.pluginKey && ac.Models != nil {
return slices.Contains(ac.Models, model)
}
}
return true
}
// pluginProvider is the provider as one of its accounts, l as the
// accounts list has it.
func pluginProvider(pp plugin.Provider, l pluginLogin) Provider {
acct, user := l.acct, l.User
id := PluginID(pp.ID)
name := pp.Name
if name == "" {
name = pp.ID
}
a := &Account{Agent: "plugin", User: user, Plan: l.Plan, Stream: true, plugin: &pp, pluginKey: acct.Key}
if pp.ID == "grok" {
// Codex's namespaced tools go to Grok flat, as the built-in sends
// them (#404): the plugin's own rewrite would leave them out
a.body = grokBody
}
a.models = func() []catalog.Model {
if cur, ok := PluginOf(id); ok {
return pluginAccountCatalog(cur, acct.Key)
}
return pluginAccountCatalog(pp, acct.Key)
}
a.fetch = func(ctx context.Context) ([]catalog.Model, error) {
ps, err := plugin.Providers(ctx)
if err != nil {
return nil, err
}
for _, cur := range ps {
if cur.ID == pp.ID {
return catalog.Chat(pluginAccountCatalog(cur, acct.Key)), nil
}
}
return nil, fmt.Errorf("%s's plugin no longer lists it", name)
}
a.sign = func(ctx context.Context, req *http.Request, body []byte) error { return nil }
a.transport = func(req *http.Request) (*http.Response, error) { return pluginFetch(pp, acct.Key, req) }
p := Provider{ID: id, Name: name, Icon: PluginIcon(pp), Account: a}
if c, ok := movedCards[pp.ID]; ok && Moved(pp.ID) {
p.Name, p.Icon, p.Website = c.name, c.icon, c.site // as the built-in was
m, _ := MigrationOf(pp.ID)
a.moved, a.wasHost = true, m.Host
if a.wasHost == "" { // moved before the move kept it
a.wasHost = builtinHost(pp.ID)
}
if n, ok := movedNames[pp.ID]; ok {
p.Name = n
}
}
for _, m := range pp.Models {
switch pluginProtocol(pp.ID, m) {
case Chat:
p.Chat = pluginBase + pp.ID + "/v1"
case Responses:
p.Responses = pluginBase + pp.ID + "/v1"
case Anthropic:
p.Anthropic = pluginBase + pp.ID
case CodeAssist:
a.codeAssist = pluginBase + pp.ID
}
}
return p
}
// pluginAPIs is the one API a plugin provider's model is spoken to on.
func (p Provider) pluginAPIs(model string) []Protocol {
pp := p.Account.plugin
if cur, ok := PluginOf(p.ID); ok {
pp = &cur
}
if m, ok := pluginModel(*pp, model); ok {
return []Protocol{pluginProtocol(pp.ID, m)}
}
return nil
}
// pluginFetch sends a request the gateway made for a plugin's provider
// through the plugin: to the base URL its loader gave (else the model's,
// the provider's, the AI SDK package's), with what the loader adds.
func pluginFetch(pp plugin.Provider, account string, req *http.Request) (*http.Response, error) {
ctx := req.Context()
var body []byte
if req.Body != nil {
b, err := io.ReadAll(req.Body)
req.Body.Close()
if err != nil {
return nil, err
}
body = b
}
rest := strings.TrimPrefix(req.URL.String(), pluginBase+pp.ID)
model := bodyModel(body)
codeAssist := strings.HasPrefix(rest, "/v1internal:")
if codeAssist {
// Code Assist's envelope off: Gemini's own request, the model in
// the path, as @ai-sdk/google sends it
var env struct {
Model string `json:"model"`
Request json.RawMessage `json:"request"`
}
if err := json.Unmarshal(body, &env); err != nil {
return nil, err
}
model, body = env.Model, env.Request
}
m, ok := pluginModel(pp, model)
if !ok {
if cur, found := PluginOf(PluginID(pp.ID)); found {
pp = cur
m, ok = pluginModel(pp, model)
}
}
if !ok {
m = plugin.Model{ID: model, NPM: pp.NPM}
}
api := modelAPIID(m)
if api != model && !codeAssist {
body = withModel(body, api)
}
o, err := plugin.LoaderOptions(ctx, pp.ID, account)
if err != nil {
return nil, err
}
base := firstOf(o.BaseURL, m.URL, pp.API, npmBase[m.NPM])
if base == "" {
return nil, errors.New(pp.Name + "'s plugin says nowhere to send its requests")
}
base = strings.TrimRight(base, "/")
var url string
switch {
case codeAssist:
url = base + "/models/" + api + ":streamGenerateContent?alt=sse"
case strings.HasPrefix(rest, "/v1/"):
// chat, responses and Anthropic's messages: the AI SDK's base
// ends where magpie's /v1 does
url = base + strings.TrimPrefix(rest, "/v1")
default:
url = base + rest
}
h := map[string]string{}
for k, vs := range req.Header {
if len(vs) > 0 && !strings.EqualFold(k, ConversationHeader) {
h[strings.ToLower(k)] = vs[0]
}
}
resp, err := plugin.Fetch(ctx, plugin.FetchRequest{
Provider: pp.ID, Account: account, Model: api, NPM: m.NPM, URL: url, Method: req.Method,
Headers: h, Body: body, Session: req.Header.Get(ConversationHeader),
})
if err == nil {
notePluginSignIn(pp, account, resp)
}
return resp, err
}
// SignInHeader is how a plugin says what its answer means for the
// account's sign-in, whatever its status, so it can answer with the
// status its built-in did: "expired" marks the account lapsed, as a
// built-in whose vendor refused the sign-in marked it, though the status
// be a 502; "kept" leaves the account as it is, as a built-in answering a
// 401 of the vendor's without its sign-in refused did; "renewed" clears
// the mark, as a built-in whose sign-in renewed took it off whatever the
// request then met. Without it a 401 marks the account and a success
// clears the mark.
const SignInHeader = "X-Magpie-Sign-In"
// notePluginSignIn marks or clears an account's lapse as the plugin's
// answer says, and takes SignInHeader off it.
func notePluginSignIn(pp plugin.Provider, account string, resp *http.Response) {
said := strings.ToLower(strings.TrimSpace(resp.Header.Get(SignInHeader)))
resp.Header.Del(SignInHeader)
notePluginSaid(pp, account, said, resp.StatusCode)
}
// notePluginSaid marks or clears an account's lapse as the plugin said
// ("expired", "kept", "renewed"), or, when it said nothing, as status
// reads: a 401 marks it and a success clears it.
func notePluginSaid(pp plugin.Provider, account, said string, status int) {
switch said {
case "expired":
notePluginLapse(pp, account, http.StatusUnauthorized)
case "renewed":
notePluginLapse(pp, account, http.StatusOK)
case "kept":
default:
notePluginLapse(pp, account, status)
}
}
func init() {
// a plugin's model list is asked through the proxy of the account it
// is the list of, as a built-in's is fetched through the account's
plugin.ProxyFor = func(id, key string) string {
mid := PluginID(id)
if key == "" {
return ProxyOf(mid)
}
for _, l := range readLogins() {
if l.Agent == "plugin:"+id && l.Home == key {
return ProxyOfLogin(mid, l.User)
}
}
return ProxyOf(mid)
}
// a models hook saying its account's sign-in expired marks it, as a
// built-in whose model list the vendor refused marked the account
plugin.OnSignIn(func(id, account, said string) {
pp, ok := pluginOfAgent("plugin:" + id)
if !ok {
// told while the providers were first read: wait for them
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
_, _ = plugin.Providers(ctx)
if pp, ok = pluginOfAgent("plugin:" + id); !ok {
pp = plugin.Provider{ID: id}
}
}
notePluginSaid(pp, account, said, 0)
})
}
func modelAPIID(m plugin.Model) string {
if m.APIID != "" {
return m.APIID
}
return m.ID
}
func withModel(body []byte, model string) []byte {
var v map[string]json.RawMessage
if json.Unmarshal(body, &v) != nil {
return body
}
v["model"], _ = json.Marshal(model)
if b, err := json.Marshal(v); err == nil {
return b
}
return body
}
func firstOf(ss ...string) string {
for _, s := range ss {
if s != "" {
return s
}
}
return ""
}
// Do sends req, through the account's own transport when it has one (a
// plugin's) and client otherwise.
func (p Provider) Do(client *http.Client, req *http.Request) (*http.Response, error) {
if p.Account != nil && p.Account.transport != nil {
return p.Account.transport(req)
}
req.Header.Del(ConversationHeader)
return client.Do(req)
}