Files
magpie/internal/qoder/models.go
T

209 lines
6.2 KiB
Go

package qoder
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 reads it: is_free, or a price_factor of 0.
Free bool `json:"-"`
}
// free reads whether the listing marks a model free: is_free true, or a
// price_factor (the credits a request costs, as a multiple) of 0. Either
// may come in snake or camel case.
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"`
}
if json.Unmarshal(raw, &v) != nil {
return
}
if v.IsFree == nil {
v.IsFree = v.IsFreeC
}
if v.PriceFactor == nil {
v.PriceFactor = v.PriceC
}
m.Free = v.IsFree != nil && *v.IsFree || v.PriceFactor != nil && *v.PriceFactor == 0
}
// 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})
}
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
}