264 lines
7.4 KiB
Go
264 lines
7.4 KiB
Go
package provider
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/yetone/magpie/internal/catalog"
|
|
)
|
|
|
|
// Result is what a probe of one endpoint came back with.
|
|
type Result struct {
|
|
Protocol Protocol `json:"protocol"`
|
|
OK bool `json:"ok"`
|
|
Status int `json:"status,omitempty"`
|
|
Millis int64 `json:"ms"`
|
|
Model string `json:"model,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// Test sends the smallest possible request to each endpoint the vendor
|
|
// serves, signed with a key made for it and asking for a model that key
|
|
// sees, and reports what came back.
|
|
func (p Provider) Test(ctx context.Context) []Result {
|
|
if p.Decides() {
|
|
return p.testDecide(ctx)
|
|
}
|
|
p.Fetch(ctx)
|
|
var out []Result
|
|
for _, proto := range p.Speaks() {
|
|
q, ok := p.keyFor(proto)
|
|
model := p.testModel(q, proto)
|
|
if !ok {
|
|
out = append(out, Result{Protocol: proto, Model: model, Error: "no key is on for this endpoint"})
|
|
continue
|
|
}
|
|
url, body := tiny(q, proto, model)
|
|
out = append(out, probe(ctx, q, proto, url, q.Prepare([]byte(body)), model))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// tiny is the smallest request for model on proto's endpoint.
|
|
func tiny(q Provider, proto Protocol, model string) (url, body string) {
|
|
switch proto {
|
|
case Chat:
|
|
return q.Chat + "/chat/completions", fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":"hi"}],"max_tokens":16}`, model)
|
|
case Responses:
|
|
return q.Responses + "/responses", fmt.Sprintf(`{"model":%q,"input":"hi","max_output_tokens":16}`, model)
|
|
case Anthropic:
|
|
return q.Anthropic + "/v1/messages", fmt.Sprintf(`{"model":%q,"max_tokens":16,"messages":[{"role":"user","content":"hi"}]}`, model)
|
|
}
|
|
return "", ""
|
|
}
|
|
|
|
// TestModels sends each of models the smallest request, a few at a time,
|
|
// on the endpoint it's served on and with a key that sees it: whether each
|
|
// answers, not only whether the vendor does. Results are in models' order.
|
|
func (p Provider) TestModels(ctx context.Context, models []string) []Result {
|
|
out := make([]Result, len(models))
|
|
sem := make(chan struct{}, 4)
|
|
var wg sync.WaitGroup
|
|
for i, model := range models {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
sem <- struct{}{}
|
|
defer func() { <-sem }()
|
|
out[i] = p.testOne(ctx, model)
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
return out
|
|
}
|
|
|
|
func (p Provider) testOne(ctx context.Context, model string) Result {
|
|
var protos []Protocol
|
|
for _, pr := range p.Speaks() {
|
|
if pr == Chat || pr == Responses || pr == Anthropic {
|
|
protos = append(protos, pr)
|
|
}
|
|
}
|
|
if len(protos) == 0 {
|
|
return Result{Model: model, Error: "this provider can't be sent a test request"}
|
|
}
|
|
// the endpoint the vendor's list says serves it, else Anthropic's for a
|
|
// Claude model, else the one it prefers
|
|
proto := protos[0]
|
|
if apis := p.APIs(model); apis != nil {
|
|
if i := slices.IndexFunc(protos, func(pr Protocol) bool { return slices.Contains(apis, pr) }); i >= 0 {
|
|
proto = protos[i]
|
|
}
|
|
} else if isClaude(model) && slices.Contains(protos, Anthropic) {
|
|
proto = Anthropic
|
|
}
|
|
// a key made for that endpoint, else one made for any, that sees it
|
|
q, ok := p, true
|
|
if keys := p.KeysOn(); len(keys) > 0 {
|
|
ok = false
|
|
pick:
|
|
for _, want := range []Protocol{proto, ""} {
|
|
for _, k := range keys {
|
|
if k.Protocol == want && p.Serves(k, model) {
|
|
q, ok = p.WithKey(k), true
|
|
break pick
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if !ok {
|
|
return Result{Protocol: proto, Model: model, Error: "no key that's on sees this model"}
|
|
}
|
|
url, body := tiny(q, proto, model)
|
|
return probe(ctx, q, proto, url, q.Prepare([]byte(body)), model)
|
|
}
|
|
|
|
// keyFor is p using the first key on that works with proto: one made for
|
|
// it, else one made for any. A provider without keys is left as it is.
|
|
func (p Provider) keyFor(proto Protocol) (Provider, bool) {
|
|
keys := p.KeysOn()
|
|
if len(keys) == 0 {
|
|
return p, true
|
|
}
|
|
for _, want := range []Protocol{proto, ""} {
|
|
for _, k := range keys {
|
|
if k.Protocol == want {
|
|
return p.WithKey(k), true
|
|
}
|
|
}
|
|
}
|
|
return p, false
|
|
}
|
|
|
|
// testModel is the model a probe of proto's endpoint asks for: the first
|
|
// exposed one q's key sees, else the first it sees at all — preferring a
|
|
// Claude model on the Anthropic endpoint.
|
|
func (p Provider) testModel(q Provider, proto Protocol) string {
|
|
k := q.first()
|
|
var pools [][]catalog.Model
|
|
if ms := p.Exposed(); len(ms) > 0 {
|
|
pools = append(pools, ms)
|
|
}
|
|
pools = append(pools, p.Available())
|
|
for _, want := range []func(string) bool{
|
|
func(id string) bool {
|
|
if apis := p.APIs(id); apis != nil {
|
|
return slices.Contains(apis, proto)
|
|
}
|
|
return proto != Anthropic || isClaude(id)
|
|
},
|
|
func(string) bool { return true },
|
|
} {
|
|
for _, pool := range pools {
|
|
for _, m := range pool {
|
|
if want(m.ID) && (k.Key == "" || p.Serves(k, m.ID)) {
|
|
return m.ID
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func isClaude(id string) bool {
|
|
id = strings.ToLower(id)
|
|
if i := strings.LastIndex(id, "/"); i >= 0 {
|
|
id = id[i+1:]
|
|
}
|
|
return strings.HasPrefix(id, "claude")
|
|
}
|
|
|
|
// AuthHeaders is how a request to the vendor proves who it is. Anthropic's
|
|
// own API wants x-api-key alone; compatible vendors take either, so both.
|
|
func AuthHeaders(p Provider, proto Protocol) map[string]string {
|
|
if p.Key == "" {
|
|
return map[string]string{}
|
|
}
|
|
if proto == Anthropic {
|
|
if strings.HasSuffix(p.Host(), "anthropic.com") {
|
|
return map[string]string{"x-api-key": p.Key}
|
|
}
|
|
return map[string]string{"x-api-key": p.Key, "Authorization": "Bearer " + p.Key}
|
|
}
|
|
return map[string]string{"Authorization": "Bearer " + p.Key}
|
|
}
|
|
|
|
func probe(ctx context.Context, p Provider, proto Protocol, url string, body []byte, model string) Result {
|
|
r := Result{Protocol: proto, Model: model}
|
|
if model == "" {
|
|
r.Error = "no model to try: expose one, or refresh the model list"
|
|
return r
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
|
defer cancel()
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
|
if err != nil {
|
|
r.Error = err.Error()
|
|
return r
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("anthropic-version", "2023-06-01")
|
|
if p.IsOpenCode() {
|
|
req.Header.Set("x-opencode-session", "magpie-test-"+randomUUID())
|
|
}
|
|
if err := p.Sign(ctx, req, proto, body); err != nil {
|
|
r.Error = err.Error()
|
|
return r
|
|
}
|
|
start := time.Now()
|
|
res, err := http.DefaultClient.Do(req)
|
|
r.Millis = time.Since(start).Milliseconds()
|
|
if err != nil {
|
|
r.Error = strings.TrimPrefix(err.Error(), "Post \""+url+"\": ")
|
|
return r
|
|
}
|
|
defer res.Body.Close()
|
|
r.Status = res.StatusCode
|
|
if res.StatusCode >= 200 && res.StatusCode < 300 {
|
|
r.OK = true
|
|
return r
|
|
}
|
|
b, _ := io.ReadAll(io.LimitReader(res.Body, 4096))
|
|
r.Error = APIError(b, res.Status)
|
|
return r
|
|
}
|
|
|
|
// APIError pulls the human message out of an error body when there is one.
|
|
func APIError(b []byte, fallback string) string {
|
|
var v struct {
|
|
Error json.RawMessage `json:"error"`
|
|
Message string `json:"message"`
|
|
Detail json.RawMessage `json:"detail"` // FastAPI's (TypeSafe)
|
|
}
|
|
if json.Unmarshal(b, &v) == nil {
|
|
if len(v.Detail) > 0 {
|
|
v.Error = v.Detail
|
|
}
|
|
var e struct {
|
|
Message string `json:"message"`
|
|
}
|
|
if json.Unmarshal(v.Error, &e) == nil && e.Message != "" {
|
|
return e.Message
|
|
}
|
|
var s string
|
|
if json.Unmarshal(v.Error, &s) == nil && s != "" {
|
|
return s
|
|
}
|
|
if v.Message != "" {
|
|
return v.Message
|
|
}
|
|
}
|
|
if s := strings.TrimSpace(string(b)); s != "" && len(s) < 200 && !strings.HasPrefix(s, "<") {
|
|
return fallback + ": " + s
|
|
}
|
|
return fallback
|
|
}
|