mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
只需填 URL 和 key 即可自动扫描上游模型并配置,不用再手抄模型 id。 端点 POST /api/sources/discover 只探测,不写任何东西 POST /api/sources/discover/apply 探测后落库 核心难点是 URL 形态而不是 /models 调用本身。网关按 base_url + adapter endpoint 拼路径(openai 提供 "/chat/completions"),而各家厂商对版本段 放在 base 还是 path 上没有共识,也没有哪个响应头能说明。所以探测不信任 调用方给的 URL,而是按偏好顺序试若干候选,返回答的那个,并给出归一化 后的 base_url + endpoint 组合。这正是第一次配 vela 时 /v1/images/ generations 返回 404 的成因:base_url 里已有 /v1,默认图像路径再拼一次。 归一化的不变量是「版本段在 base_url + endpoint 里恰好出现一次」。留在两边 会重复;两边都没有则 chat 请求在要求版本段的厂商上会打错路径。 kind 只信上游自己声明的 modality,绝不从模型名猜。"gpt-image-2" 这类 id 也出现在同时提供对话服务的目录上,猜错会让 AUTO 链选中一个只支持图像 生成的 id,其后每一层都被它拖垮。 不做的事: - 不逐模型发单 token 探活。34 个模型的目录跑一轮要花钱且耗时数分钟, /models 是足够便宜的信号;模型是否真能应答留给正常请求路径。 - 不猜能力。「vision」出现在 id 里不等于该模型接受图片。 - 不自动写盘。探测只读,保存仍需操作员按按钮——错误的 base_url 会产出 一个看起来合理却无法使用的源。 已存在的源必须显式 replace_models=true 才会覆盖模型列表:重新探测顺手 抹掉为 AUTO 链手工调过的 priority 是最难排查的一类数据丢失。 测试用真实抓取的 amr-link /models 响应做夹具,它把 modality 嵌在 architecture 下、metadata 下还有 cost/context_limit,34 个模型里混着 5 个 图像生成 id——朴素解析器正好在这三处出错。 变异测试 9/9 全杀(TestRealMutants,baseline 43 个测试)。过程中发现并 修掉四个真缺陷:嵌套 metadata 丢失、null 被当空目录、候选盲目拼接出 /v1/v1、以及 discoveryResult 零值序列化成 null 会让前端渲染出 "undefined"。早先的 shell 版变异脚本因 -run 转义损坏报出 4 个"存活", 实际是零个测试被执行——判据分不清"通过"和"没跑"就不是判据。
383 lines
12 KiB
Go
383 lines
12 KiB
Go
package gateway
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
"llmsproxy/internal/config"
|
|
)
|
|
|
|
// HTTP surface for model discovery.
|
|
//
|
|
// Two endpoints, deliberately separate:
|
|
//
|
|
// POST /api/sources/discover — probe a URL+key, return the catalog.
|
|
// Nothing is written.
|
|
// POST /api/sources/discover/apply — probe, then write the source.
|
|
//
|
|
// The split exists because discovery mutates nothing and is therefore safe to
|
|
// call repeatedly while the operator is still typing a key, whereas apply
|
|
// rewrites credentials and the AUTO chain's inputs. Making them one endpoint
|
|
// with a flag would mean a "just check it" click can silently create a source.
|
|
//
|
|
// Both are admin-only, matching /api/sources: the request body carries an API
|
|
// key, and a model catalog reveals which upstreams an operator uses.
|
|
|
|
// discoverRequest is the body of both endpoints.
|
|
type discoverRequest struct {
|
|
// BaseURL is what the operator pasted. It may or may not include a
|
|
// version segment; discovery works that out rather than making the
|
|
// caller know this gateway's path-composition rules.
|
|
BaseURL string `json:"base_url"`
|
|
APIKey string `json:"api_key"`
|
|
|
|
// Name is required for apply, ignored for discover. It defaults from the
|
|
// base URL's host when omitted, because "https://api.vendor.com/v1" is a
|
|
// better starting name than "new-source".
|
|
Name string `json:"name"`
|
|
|
|
// Adapter is optional. Discovery assumes "openai" because that is what a
|
|
// /v1/models catalog implies; a non-OpenAI upstream (Anthropic, Gemini)
|
|
// has no such endpoint, so discovery will legitimately fail for those and
|
|
// the operator configures them by hand.
|
|
Adapter string `json:"adapter"`
|
|
|
|
// Temperature and MaxConcurrent are carried into the written source.
|
|
Temperature float64 `json:"temperature"`
|
|
MaxConcurrent int `json:"max_concurrent"`
|
|
|
|
// ReplaceModels drops the source's existing model list before applying the
|
|
// discovered one. It defaults to true for a NEW source and is required to
|
|
// be spelled out for an existing one, so a re-discovery cannot silently
|
|
// delete hand-tuned priorities.
|
|
ReplaceModels *bool `json:"replace_models,omitempty"`
|
|
|
|
// Select narrows what gets written. Empty means everything discovered.
|
|
// Ids that are not in the catalog are an error rather than a silent skip:
|
|
// a typo here would produce a source that 404s on every request.
|
|
Include []string `json:"include,omitempty"`
|
|
Exclude []string `json:"exclude,omitempty"`
|
|
}
|
|
|
|
// discoverResponse is the JSON the WebUI binds to.
|
|
type discoverResponse struct {
|
|
OK bool `json:"ok"`
|
|
|
|
// Normalized source fields, ready to be echoed back to /api/sources.
|
|
BaseURL string `json:"base_url"`
|
|
Adapter string `json:"adapter"`
|
|
Models []string `json:"models"`
|
|
ImageModels []string `json:"image_models"`
|
|
|
|
// Endpoint is set only when discovery had to move a version segment.
|
|
Endpoint string `json:"endpoint"`
|
|
|
|
// Diagnostics for the operator.
|
|
ModelsPath string `json:"models_path"`
|
|
Tried []string `json:"tried"`
|
|
LatencyMS int64 `json:"latency_ms"`
|
|
Note string `json:"note,omitempty"`
|
|
Warnings []string `json:"warnings,omitempty"`
|
|
|
|
// Set by apply only.
|
|
SourceName string `json:"source_name,omitempty"`
|
|
Applied bool `json:"applied,omitempty"`
|
|
}
|
|
|
|
func (g *Gateway) handleSourceDiscoverAPI(w http.ResponseWriter, r *http.Request) {
|
|
if reqRole(r.Context()) != "admin" {
|
|
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
|
return
|
|
}
|
|
apply := strings.HasSuffix(r.URL.Path, "/apply")
|
|
if r.Method != http.MethodPost {
|
|
w.Header().Set("Allow", "POST")
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed",
|
|
"use POST with {\"base_url\":…,\"api_key\":…}")
|
|
return
|
|
}
|
|
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "cannot read body")
|
|
return
|
|
}
|
|
var req discoverRequest
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
|
return
|
|
}
|
|
req.BaseURL = strings.TrimSpace(req.BaseURL)
|
|
if req.BaseURL == "" {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "base_url required")
|
|
return
|
|
}
|
|
if req.Adapter == "" {
|
|
req.Adapter = "openai"
|
|
}
|
|
|
|
// Bounded so a hung upstream cannot pin the handler; discovery has its own
|
|
// per-request timeout on top.
|
|
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
|
defer cancel()
|
|
|
|
res, err := DiscoverModels(ctx, req.BaseURL, req.APIKey)
|
|
if err != nil {
|
|
// 502: the upstream is what failed. The message lists every URL tried.
|
|
writeError(w, http.StatusBadGateway, "discover_failed", err.Error())
|
|
return
|
|
}
|
|
|
|
chat, image, warns, err := selectModels(res.Models, res.ImageModels, req.Include, req.Exclude)
|
|
if err != nil {
|
|
// 400: the operator asked for a model the catalog does not have.
|
|
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
|
return
|
|
}
|
|
|
|
out := &discoverResponse{
|
|
OK: true,
|
|
BaseURL: res.BaseURL,
|
|
Adapter: req.Adapter,
|
|
Models: chat,
|
|
ImageModels: image,
|
|
Endpoint: res.Endpoint,
|
|
ModelsPath: res.ModelsPath,
|
|
Tried: res.Tried,
|
|
LatencyMS: res.LatencyMS,
|
|
Note: res.Note,
|
|
Warnings: warns,
|
|
}
|
|
if len(chat) == 0 && len(image) == 0 {
|
|
out.Warnings = append(out.Warnings,
|
|
"every discovered model was excluded; applying this would create a source with no models")
|
|
}
|
|
|
|
if !apply {
|
|
writeJSON(w, http.StatusOK, out)
|
|
return
|
|
}
|
|
|
|
name := strings.TrimSpace(req.Name)
|
|
if name == "" {
|
|
name = defaultSourceName(req.BaseURL)
|
|
}
|
|
if name == "" {
|
|
writeError(w, http.StatusBadRequest, "invalid_request",
|
|
"name required: cannot derive one from base_url")
|
|
return
|
|
}
|
|
if strings.ContainsAny(name, "/\\ \t") || name == "." || name == ".." {
|
|
writeError(w, http.StatusBadRequest, "invalid_request",
|
|
"source name must not contain slashes or whitespace")
|
|
return
|
|
}
|
|
|
|
exists := g.sourceExists(name)
|
|
if exists && req.ReplaceModels == nil {
|
|
// Refusing here is deliberate. Re-running discovery on an existing
|
|
// source and having it silently replace a hand-tuned model list —
|
|
// including priorities chosen for the AUTO chain — is the kind of
|
|
// "helpful" behaviour that loses an afternoon.
|
|
writeError(w, http.StatusConflict, "conflict",
|
|
"source "+name+" already exists; set replace_models=true to overwrite its model list, "+
|
|
"or choose a different name")
|
|
return
|
|
}
|
|
|
|
// Build the source directly rather than round-tripping through the
|
|
// /api/sources payload: resolveAPIKey carries the "an empty api_key means
|
|
// keep the stored one" rule, and going through a second decoder here would
|
|
// duplicate (and eventually diverge from) that logic.
|
|
src := config.Source{
|
|
Name: name,
|
|
BaseURL: res.BaseURL,
|
|
APIKey: g.resolveAPIKey(name, req.APIKey, exists),
|
|
Adapter: req.Adapter,
|
|
Endpoint: res.Endpoint,
|
|
Models: modelsToConfig(chat, image),
|
|
Temperature: req.Temperature,
|
|
}
|
|
if req.MaxConcurrent > 0 {
|
|
src.MaxConcurrent = req.MaxConcurrent
|
|
}
|
|
|
|
// An existing source may carry fields discovery does not know about
|
|
// (proxy_url, headers, api_key_env, timeout). Rebuilding from scratch would
|
|
// drop them, so they are inherited unless the request overrides them.
|
|
if exists {
|
|
old := g.sourceByName(name)
|
|
if old != nil {
|
|
src.ProxyURL = old.ProxyURL
|
|
src.APIKeyEnv = old.APIKeyEnv
|
|
src.Timeout = old.Timeout
|
|
src.QueueTimeout = old.QueueTimeout
|
|
src.Headers = old.Headers
|
|
src.Meta = old.Meta
|
|
src.MaxTokens = old.MaxTokens
|
|
src.RPM = old.RPM
|
|
src.ImageEndpoint = old.ImageEndpoint
|
|
if src.Adapter == "" {
|
|
src.Adapter = old.Adapter
|
|
}
|
|
if req.MaxConcurrent <= 0 {
|
|
src.MaxConcurrent = old.MaxConcurrent
|
|
}
|
|
// replace_models=false keeps the curated list. Discovery still
|
|
// refreshed base_url/endpoint/credential, which is the point of
|
|
// re-running it.
|
|
if req.ReplaceModels != nil && !*req.ReplaceModels {
|
|
src.Models = old.Models
|
|
out.Warnings = append(out.Warnings,
|
|
"model list left unchanged (replace_models=false)")
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := g.core.AddSource(src); err != nil {
|
|
writeError(w, http.StatusBadRequest, "source_error", err.Error())
|
|
return
|
|
}
|
|
|
|
out.SourceName = name
|
|
out.Applied = true
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
// selectModels applies the include/exclude filters.
|
|
//
|
|
// An include entry the catalog does not have is an error: silently dropping it
|
|
// produces a source that looks configured and 404s at request time, which is
|
|
// far harder to diagnose than a rejected apply. An exclude entry that is not
|
|
// present is fine — the operator is allowed to be defensive.
|
|
func selectModels(chat, image, include, exclude []string) ([]string, []string, []string, error) {
|
|
have := map[string]bool{}
|
|
for _, m := range chat {
|
|
have[m] = true
|
|
}
|
|
for _, m := range image {
|
|
have[m] = true
|
|
}
|
|
|
|
if len(include) > 0 {
|
|
var missing []string
|
|
for _, want := range include {
|
|
if !have[want] {
|
|
missing = append(missing, want)
|
|
}
|
|
}
|
|
if len(missing) > 0 {
|
|
return nil, nil, nil, &discoverError{
|
|
msg: "these models are not in the upstream catalog: " + strings.Join(missing, ", "),
|
|
}
|
|
}
|
|
keep := map[string]bool{}
|
|
for _, want := range include {
|
|
keep[want] = true
|
|
}
|
|
var c, i []string
|
|
for _, m := range chat {
|
|
if keep[m] {
|
|
c = append(c, m)
|
|
}
|
|
}
|
|
for _, m := range image {
|
|
if keep[m] {
|
|
i = append(i, m)
|
|
}
|
|
}
|
|
chat, image = c, i
|
|
}
|
|
|
|
if len(exclude) > 0 {
|
|
drop := map[string]bool{}
|
|
for _, m := range exclude {
|
|
drop[m] = true
|
|
}
|
|
var c, i []string
|
|
for _, m := range chat {
|
|
if !drop[m] {
|
|
c = append(c, m)
|
|
}
|
|
}
|
|
for _, m := range image {
|
|
if !drop[m] {
|
|
i = append(i, m)
|
|
}
|
|
}
|
|
chat, image = c, i
|
|
}
|
|
|
|
// chat and image must stay non-nil: the JSON contract forbids null here.
|
|
if chat == nil {
|
|
chat = []string{}
|
|
}
|
|
if image == nil {
|
|
image = []string{}
|
|
}
|
|
|
|
var warns []string
|
|
if len(chat) == 0 && len(image) == 0 {
|
|
// Caller can only get here by excluding everything, or from a catalog
|
|
// that was already empty. Either way "silence" would read as success.
|
|
warns = append(warns,
|
|
"no models selected; applying this would create a source that cannot serve any request")
|
|
return chat, image, warns, nil
|
|
}
|
|
if len(image) > 0 && len(chat) == 0 {
|
|
warns = append(warns,
|
|
"every model is an image model; AUTO chat requests have nothing to select")
|
|
}
|
|
if len(chat) > 0 && len(image) > 0 {
|
|
warns = append(warns,
|
|
"image models are only reachable through /v1/images/generations, not AUTO chat")
|
|
}
|
|
return chat, image, warns, nil
|
|
}
|
|
|
|
// discoverError is a client mistake rather than an upstream failure, which is
|
|
// why it gets its own type instead of fmt.Errorf.
|
|
type discoverError struct{ msg string }
|
|
|
|
func (e *discoverError) Error() string { return e.msg }
|
|
|
|
// defaultSourceName derives a source name from a base URL host, stripping the
|
|
// version segment and any "api." prefix. It returns "" for input that is not a
|
|
// URL at all — the caller must not fall back to echoing garbage into a source
|
|
// name, since the name becomes a config key and an API path segment.
|
|
func defaultSourceName(baseURL string) string {
|
|
s := strings.TrimSpace(baseURL)
|
|
if !strings.Contains(s, "://") {
|
|
return ""
|
|
}
|
|
s = s[strings.Index(s, "://")+3:]
|
|
if i := strings.IndexAny(s, "/?#"); i >= 0 {
|
|
s = s[:i]
|
|
}
|
|
if i := strings.LastIndex(s, ":"); i > 0 && !strings.Contains(s[i+1:], "]") {
|
|
s = s[:i] // strip port
|
|
}
|
|
// A host must still look like one after trimming the cosmetic prefixes.
|
|
s = strings.TrimPrefix(s, "api.")
|
|
s = strings.TrimPrefix(s, "www.")
|
|
if s == "" || strings.ContainsAny(s, " \t\"'<>") {
|
|
return ""
|
|
}
|
|
for _, r := range s {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
|
|
case r == '.' || r == '-' || r == '_':
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
if !strings.Contains(s, ".") {
|
|
// A bare label is far more likely to be a mistake than a hostname.
|
|
return ""
|
|
}
|
|
return s
|
|
}
|