package qoder // PLUGIN-SERVED (see AGENTS.md): Qoder ("qoder") and Qoder CN ("qoder-cn") // are deprecated built-in subscriptions served by their plugin, // @magpie-community/opencode-qoder-auth, each once moved onto it // (provider.Moved; the default for a new sign-in). A moved one's // sign-ins, models, requests and usage are all the plugin's, never this // code's (only the move, in migrate*.go, still reads its accounts). A fix here alone doesn't reach those users; fix the plugin // (github.com/magpie-community/plugins, packages/qoder) and raise the // movers' min in internal/provider/migrate_qoder.go. import ( "context" "encoding/json" "fmt" "io" "net/http" "sort" "strings" "github.com/yetone/magpie/internal/catalog" ) // ModelInfo is one entry of the model-list response's "chat" array. Only the // fields magpie uses are kept. type ModelInfo struct { Key string `json:"key"` Source string `json:"source,omitempty"` Enable bool `json:"enable"` DisplayName string `json:"display_name,omitempty"` IsVL bool `json:"is_vl,omitempty"` IsReasoning bool `json:"is_reasoning,omitempty"` MaxInputTokens int `json:"max_input_tokens,omitempty"` Efforts []string `json:"reasoning_efforts,omitempty"` Config json.RawMessage `json:"-"` // From thinking_config: whether the model can think, whether it can't // stop, and the effort it thinks at unless asked for another. Thinks bool `json:"-"` AlwaysThinks bool `json:"-"` DefaultEffort string `json:"-"` // Free is set on a model that costs the plan no credits, as Qoder's // client shows it: a price_factor of 0 (see free). Free bool `json:"-"` // Rate is the credits a request costs, as a multiple (its // price_factor), and RateWas the price struck through beside it while // a discount runs (see free); 0 when the listing says none. Rate float64 `json:"-"` RateWas float64 `json:"-"` } // promotion is a listing entry's running discount: a price_factor of 0 // while it is active, with a price before it, is a discount, not a free // model. type promotion struct { Active bool `json:"active"` Before *float64 `json:"before_promotion_price_factor"` BeforeC *float64 `json:"beforePromotionPriceFactor"` Discount float64 `json:"discount_factor"` DiscountC float64 `json:"discountFactor"` } // free reads whether a model costs the plan no credits: a price_factor (the // credits a request costs, as a multiple; priceFactor in camel case) of 0, // the price Qoder's own client shows ("0×"). is_free is no word on that: // Qoder's listing has it true on Qwen3.8-Max at 0.5×, an off-peak discount // (错峰 4 折) on it, and Qoder's client shows the price, not it. It is taken // only from a listing with no price at all. A price of 0 for the while an // active promotion lasts, with a price before it, is a discount, not a free // model; a limited-time free model (Qwen3.8-Flash: 0×, its // original_price_factor 0.1 struck through) is free while it is. The same // rule as the plugin's freeOf (packages/qoder/index.mjs). // // The price is kept too (Rate), as Qoder's client shows it beside the // model: 0.5×, and the price before a discount struck through (RateWas): // an active promotion's before_promotion_price_factor, else an // original_price_factor above the price. A promotion's 0 that isn't free // is its price before times its discount_factor. func (m *ModelInfo) free(raw json.RawMessage) { var v struct { IsFree *bool `json:"is_free"` IsFreeC *bool `json:"isFree"` PriceFactor *float64 `json:"price_factor"` PriceC *float64 `json:"priceFactor"` Original float64 `json:"original_price_factor"` OriginalC float64 `json:"originalPriceFactor"` Promotion *promotion `json:"promotion"` PromotionT *promotion `json:"prommotion"` } if json.Unmarshal(raw, &v) != nil { return } if v.IsFree == nil { v.IsFree = v.IsFreeC } if v.PriceFactor == nil { v.PriceFactor = v.PriceC } if v.Promotion == nil { v.Promotion = v.PromotionT } if v.PriceFactor != nil { p := v.Promotion if p != nil && p.Before == nil { p.Before = p.BeforeC } if p != nil && p.Discount == 0 { p.Discount = p.DiscountC } discounted := p != nil && p.Active && p.Before != nil && *p.Before > 0 m.Rate, m.RateWas = max(*v.PriceFactor, 0), max(v.Original, v.OriginalC) if discounted { m.RateWas = *p.Before if m.Rate == 0 { m.Rate = *p.Before * p.Discount } } if m.RateWas <= m.Rate { m.RateWas = 0 } m.Free = *v.PriceFactor == 0 && !discounted return } m.Free = v.IsFree != nil && *v.IsFree } // effortOrder ranks Qoder's effort names, lowest first. var effortOrder = []string{"minimal", "low", "medium", "high", "xhigh", "max"} // thinking reads the listing's thinking_config, where Qoder keeps the // efforts a model offers and its default; is_reasoning alone stands for a // model listed without one. func (m *ModelInfo) thinking(raw json.RawMessage) { var v struct { Config *struct { Disabled json.RawMessage `json:"disabled"` Enabled *struct { Efforts map[string]struct { IsDefault bool `json:"is_default"` } `json:"efforts"` } `json:"enabled"` } `json:"thinking_config"` } m.Thinks = m.IsReasoning if json.Unmarshal(raw, &v) != nil || v.Config == nil { return } m.Thinks = v.Config.Enabled != nil m.AlwaysThinks = m.Thinks && len(v.Config.Disabled) == 0 m.Efforts = nil if !m.Thinks { return } for name, e := range v.Config.Enabled.Efforts { m.Efforts = append(m.Efforts, name) if e.IsDefault { m.DefaultEffort = name } } rank := func(s string) int { for i, e := range effortOrder { if e == s { return i } } return len(effortOrder) } sort.Slice(m.Efforts, func(i, j int) bool { if rank(m.Efforts[i]) != rank(m.Efforts[j]) { return rank(m.Efforts[i]) < rank(m.Efforts[j]) } return m.Efforts[i] < m.Efforts[j] }) } // FetchModels retrieves the raw model listing for a signed-in user. The // request carries the same COSY envelope the client uses for every /algo // call; an empty body is signed for a GET. func FetchModels(ctx context.Context, client *http.Client, baseURL string, user *User) ([]byte, error) { if client == nil { client = &http.Client{} } if user == nil || strings.TrimSpace(user.Token) == "" || strings.TrimSpace(user.UID) == "" { return nil, fmt.Errorf("qoder: uid and token are required to fetch models") } url := strings.TrimRight(baseURL, "/") + ListModelsPath req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("qoder: build models request: %w", err) } cosy, err := BuildCosyHeaders(url, user, "", 0) if err != nil { return nil, fmt.Errorf("qoder: build cosy headers: %w", err) } for k, v := range cosy { req.Header.Set(k, v) } resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("qoder models request failed: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) if err != nil { return nil, fmt.Errorf("qoder models: read response: %w", err) } if resp.StatusCode/100 != 2 { return nil, fmt.Errorf("qoder models: HTTP %d: %s", resp.StatusCode, sanitize(body)) } return body, nil } func sanitize(body []byte) string { s := strings.TrimSpace(string(body)) if len(s) > 512 { s = s[:512] + "..." } return s } // ParseModels decodes the listing and returns the enabled, routable chat // models as magpie's catalog entries. Placeholder ids ("auto", "default") // are dropped: they route between models inside Qoder and aren't a single // model an agent can pick. provider is the magpie provider id the entries are // listed under (ProviderKey or CNProviderKey). func ParseModels(body []byte, provider string) ([]catalog.Model, error) { models, err := ModelConfigs(body) if err != nil { return nil, err } var out []catalog.Model for _, m := range models { name := m.DisplayName if name == "" { name = m.Key } out = append(out, catalog.Model{ID: m.Key, Name: name, Provider: provider, Context: m.MaxInputTokens, Images: m.IsVL, Efforts: m.Efforts, Free: m.Free, Rate: m.Rate, RateWas: m.RateWas}) } return out, nil } // ModelConfigs preserves each enabled model's complete upstream configuration. func ModelConfigs(body []byte) ([]ModelInfo, error) { var resp struct { Chat []json.RawMessage `json:"chat"` } if err := json.Unmarshal(body, &resp); err != nil { return nil, fmt.Errorf("qoder: invalid models json: %w", err) } var out []ModelInfo for _, raw := range resp.Chat { var m ModelInfo if err := json.Unmarshal(raw, &m); err != nil { return nil, err } if !m.Enable || !IsRoutableModel(m.Key) { continue } m.Config = raw m.thinking(raw) m.free(raw) out = append(out, m) } return out, nil } // IsRoutableModel reports whether a model key names one model an agent can // pick. Aggregate entries ("auto", "default") route inside Qoder and aren't. func IsRoutableModel(key string) bool { switch strings.TrimSpace(key) { case "", "auto", "default": return false } return true }