Files
magpie/internal/qoder/models.go
T
yetone 33acb6bca0 providers: each model's credit rate shows by it in the provider's model list and an agent's model picker, Qoder's 0.5× and WorkBuddy's 0.03×, a discount's price before it struck through (01huadalang on Discord: qcoder 或者 workbuddy 能不能实时抓取或者按一段时间抓取能展示列表模型的积分消耗倍率,这样不用打开软件查看,能选择到便宜的模型) — magpie read each model's price only to tell free from not: Qoder's price_factor (06584e15) and WorkBuddy's "credits" ("x0.03", wbFreeCredits), so the lists said FREE or nothing, and picking a cheap model meant opening Qoder or WorkBuddy. catalog.Model now keeps the price, Rate (the credits a request costs, as a multiple; 0 is not listed, or Free) and RateWas (the price before a discount running now), and carries both through provider.Entry, agent.Option and the providers page's models. Qoder (and Qoder CN): Rate is the price_factor; RateWas an active promotion's before_promotion_price_factor, else an original_price_factor above the price (Qwen3.8-Flash: free, 0.1× struck through), and a promotion's 0 that isn't free is its price before times its discount_factor. WorkBuddy and WorkBuddy AI (the CodeBuddy plan; both builds read the same /v3/config): Rate is the credits (wbCredits; x0.00 stays FREE, an unreadable or infinite rate is none). A plugin's model gives it as it gives free: rate and rateWas on the model its provider.models hook lists, a number (0.5) or as the vendor writes it ("x0.03", "0.5×"), which host.js reads (rateOf) into plugin.Model.Rate/RateWas and pluginCatalog keeps; the moved Qoder and WorkBuddy users get it once their plugins send it. The badge is small and grey (the context badge's colours, no uppercase), beside FREE where that shows, its title saying what a request costs (每次请求消耗 0.5× 积分;折扣前 / 限时免费,原价). Rates are as of the provider's last list: a built-in's is fetched at start-up when it has none, on Refresh and when its key changes, a plugin's when its plugins are asked again (start-up, a sign-in, a plugin change); nothing asks on a timer. Kiro, Cursor, ZCode and Devin list no per-model rate magpie reads, so they show none. TestParseModelsRate (qoder) and TestWBCreditsRate (provider) are new and don't build without the change; TestFakePlugin now checks a plugin's rate given as a number and as "x0.03" (0 for both with host.js not reading them). model-rate.test.cjs (WebKit, en and zh: the chips' badges and titles, struck-through prices, grey not green, no stripe, a click picks and scrolls nothing, the picker's badges) fails without the change and passes with it; click-scroll, models-free-only, model-pick and favorite-star pass in WebKit. No request was made to Qoder, WorkBuddy or any vendor. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME, go test ./internal/gui pass.
2026-10-02 22:48:45 +08:00

277 lines
9.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}