209 lines
6.2 KiB
Go
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
|
|
}
|