From 3860c7381f726b12b5fbfbe5a76a71d60ff00b22 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 07:57:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(gateway):=20=E6=BA=90=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E6=8E=A2=E6=B5=8B=E6=A8=A1=E5=9E=8B=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 只需填 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 个"存活", 实际是零个测试被执行——判据分不清"通过"和"没跑"就不是判据。 --- internal/gateway/apiv1.go | 14 + internal/gateway/discover.go | 598 +++++++++++++++++++++ internal/gateway/discover_api.go | 382 +++++++++++++ internal/gateway/discover_api_test.go | 219 ++++++++ internal/gateway/discover_mutation_test.go | 134 +++++ internal/gateway/discover_test.go | 567 +++++++++++++++++++ internal/gateway/server.go | 5 + internal/gateway/ui/index.html | 100 ++++ 8 files changed, 2019 insertions(+) create mode 100644 internal/gateway/discover.go create mode 100644 internal/gateway/discover_api.go create mode 100644 internal/gateway/discover_api_test.go create mode 100644 internal/gateway/discover_mutation_test.go create mode 100644 internal/gateway/discover_test.go diff --git a/internal/gateway/apiv1.go b/internal/gateway/apiv1.go index b49956d..0c0bc90 100644 --- a/internal/gateway/apiv1.go +++ b/internal/gateway/apiv1.go @@ -189,6 +189,20 @@ func (g *Gateway) apiV1Index(w http.ResponseWriter, r *http.Request) { {Method: "GET", Path: "/api/stats", Auth: "any", Summary: "usage aggregates"}, {Method: "GET", Path: "/api/stats/records", Auth: "any", Summary: "paged request records"}, {Method: "GET", Path: "/api/adapters", Auth: "admin", Summary: "installed Lua adapters"}, + {Method: "POST", Path: "/api/sources/discover", Auth: "admin", + Summary: "probe a base_url+api_key for its model catalog", + // Probing is read-only with respect to configuration: it writes + // nothing, which is why it is safe to call repeatedly while the + // operator is still typing a key. + WriteEffect: "none", + }, + {Method: "POST", Path: "/api/sources/discover/apply", Auth: "admin", + Summary: "probe and write the source in one step", + // Requires replace_models=true when the source already exists, + // so re-running discovery cannot silently discard a hand-tuned + // model list. An empty api_key keeps the stored credential. + WriteEffect: "creates or replaces a source and its model list", + }, {Method: "GET", Path: "/api/source_templates", Auth: "admin", Summary: "source templates"}, {Method: "POST", Path: "/v1/chat/completions", Auth: "any", Summary: "OpenAI-compatible inference"}, }, diff --git a/internal/gateway/discover.go b/internal/gateway/discover.go new file mode 100644 index 0000000..a959b99 --- /dev/null +++ b/internal/gateway/discover.go @@ -0,0 +1,598 @@ +package gateway + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strings" + "time" + + "llmsproxy/internal/config" +) + +// Model discovery: turn "here is a URL and a key" into a configured source. +// +// The hard part is not the GET /models call — it is guessing which URL the +// upstream actually serves. This gateway builds request paths as +// base_url + adapter endpoint (openai contributes "/chat/completions"), so a +// base_url that already ends in "/v1" yields "/v1/chat/completions" while a +// base_url of "/v1" plus our default image path yields the broken +// "/v1/v1/images/generations". Vendors disagree on whether the version segment +// belongs to the base or to the path, and there is no header that says which. +// +// So discovery does not trust the URL it is given. It tries a small, ordered +// set of candidates, records which one answered, and reports that back as the +// normalized base_url + adapter endpoint pair. A caller who typed the wrong +// form gets a working source anyway, plus the correction needed to make it +// permanent. +// +// What is deliberately NOT here: +// +// - No guessing of model capabilities from the name. "vision" in an id does +// not make a model accept images, and marking an image-only endpoint as +// kind:"chat" makes every AUTO request fail on it. Kind stays "chat" unless +// the upstream's own metadata says otherwise (see discoveredModelKind). +// - No live probe per model. A single-token completion per model would cost +// real money across a 34-model catalog and takes minutes. /models is the +// cheap, authoritative-enough signal for "this id is served here"; whether +// it actually answers stays for the normal request path to discover. +// +// The one thing that IS verified live is reachability, because a source that +// 404s is not worth writing to disk. + +const ( + // discoveryTimeout bounds the whole probe. Vendors are routinely slow on + // the first request to an uncached endpoint; 20s is enough for a healthy + // one and short enough that the UI is not left hanging. + discoveryTimeout = 20 * time.Second + + // discoveryModelsPath is the OpenAI-compatible listing endpoint. Nearly + // every vendor proxies it verbatim, including the ones whose chat path + // differs. + discoveryModelsPath = "/v1/models" + + // discoveryMaxBody caps the listing response. A catalog with thousands of + // models is still far under this; anything larger is a captive portal or + // an error page, not a model list. + discoveryMaxBody = 8 << 20 +) + +// discoveredModel is one entry of a parsed /models response, kept in the +// upstream's own terms. The upstream is the only authority on what it serves. +type discoveredModel struct { + ID string + Kind string // "chat" | "image" + Meta map[string]interface{} +} + +// discoveryResult reports what a probe found, including the case where nothing +// worked — the failure text is what the operator needs, so it must name every +// URL that was tried rather than just the last one. +type discoveryResult struct { + BaseURL string `json:"base_url"` + Endpoint string `json:"endpoint"` + Adapter string `json:"adapter"` + ModelsPath string `json:"models_path"` + Models []string `json:"models"` + ImageModels []string `json:"image_models"` + Tried []string `json:"tried"` + LatencyMS int64 `json:"latency_ms"` + Note string `json:"note,omitempty"` +} + +// MarshalJSON guarantees the two list fields are always JSON arrays, never +// null. The WebUI concatenates them directly, and "null" renders as the string +// "undefined" in every row — a failure that only shows up in a browser and not +// in a JSON schema check. +// +// Relying on the constructor to initialize the slices is not enough: the zero +// value of this struct is a legitimate thing for a caller to build, and a +// future code path that forgets the initializer would ship a UI bug silently. +func (r discoveryResult) MarshalJSON() ([]byte, error) { + type alias discoveryResult // avoid recursion + out := alias(r) + if out.Models == nil { + out.Models = []string{} + } + if out.ImageModels == nil { + out.ImageModels = []string{} + } + if out.Tried == nil { + out.Tried = []string{} + } + return json.Marshal(out) +} + +// discoveryCandidate is one (base_url, models-path) guess. Order matters: the +// first candidate is the one reported as normalized when several work, because +// it is the one that keeps base_url free of a version segment and therefore +// composes correctly with the adapter's chat path. +type discoveryCandidate struct { + baseURL string + modelsPath string + // prefer ranks candidates that are more likely to be the vendor's real + // path. A 200 from a lower-preference candidate is still accepted, but + // only after every higher-preference one failed — see DiscoverModels. + prefer int +} + +// discoveryCandidates enumerates the URL forms worth trying for a raw input. +// +// The ranking encodes a real observation rather than a guess: the amr-link +// endpoint serves its catalog at "/v1/models" (the form an SDK example shows) +// from a base the operator pasted WITH the "/v1", while several other vendors +// mount it at the host root. Both are probed; the one that answers is reported +// back as a normalized base/endpoint pair. +// +// What we are really deciding is the base_url to persist. The safest one keeps +// the version segment on the ENDPOINT, because openai's adapter endpoint is +// "/chat/completions" and the two are concatenated: base "https://h" + +// endpoint "/v1/chat/completions" is correct, while leaving "/v1" on the base +// doubles the default image path into "/v1/v1/images/generations" — which is +// exactly the 404 observed on the first vela configuration attempt. +func discoveryCandidates(raw string) []discoveryCandidate { + raw = strings.TrimRight(strings.TrimSpace(raw), "/") + if raw == "" { + return nil + } + seen := map[string]bool{} + var out []discoveryCandidate + add := func(base, path string, prefer int) { + if seen[base+"|"+path] { + return + } + seen[base+"|"+path] = true + out = append(out, discoveryCandidate{baseURL: base, modelsPath: path, prefer: prefer}) + } + + stripped := trimVersionSuffix(raw) + hasVersion := stripped != raw + + // Preference 0: the URL exactly as typed, catalog at the canonical path. + // When the typed base already ends in "/v1" this is "…/v1/v1/models", which + // is precisely the doubled form that 404s — so the canonical path is only + // offered against a base that does not already carry a version. + if !hasVersion { + add(raw, discoveryModelsPath, 0) + } else { + // Base already has "/v1"; ask for the bare "/models" relative to it. + add(raw, "/models", 0) + } + // Preference 1: host root + canonical versioned path. + add(stripped, discoveryModelsPath, 1) + // Preference 2: host root + bare path (vendors that imply the version). + if hasVersion { + add(stripped, "/models", 2) + } + + // Stable insertion sort by preference; add() above is not ordered. + for i := 1; i < len(out); i++ { + for j := i; j > 0 && out[j].prefer < out[j-1].prefer; j-- { + out[j], out[j-1] = out[j-1], out[j] + } + } + return out +} + +// trimVersionSuffix removes a trailing "/v1" (or /v2, /v1beta, ...) so the +// host root can be probed on its own. +func trimVersionSuffix(base string) string { + i := strings.LastIndex(base, "/") + if i < 0 { + return base + } + if !isVersionSegment(base[i+1:]) { + return base + } + return base[:i] +} + +// versionSegment returns the leading path segment of a models path when that +// segment is a version marker ("/v1/models" → "/v1", "/models" → ""). It is the +// piece that must move between base_url and endpoint so the two compose. +func versionSegment(modelsPath string) string { + parts := strings.Split(strings.Trim(modelsPath, "/"), "/") + if len(parts) != 2 || !isVersionSegment(parts[0]) { + return "" + } + return "/" + parts[0] +} + +// isVersionSegment reports whether a path segment looks like "v1", "v2", +// "v1beta" — and not like a real path name such as "vision" or a wildcard +// path such as "v1x". +// +// The rule is: a leading "v", then digits, then an OPTIONAL suffix drawn from +// the prerelease markers vendors actually use. A free-form alphabetic tail was +// tried and rejected — it classifies "v1x" as a version, and that is how a +// templated path segment would get stripped off a real base URL. +func isVersionSegment(seg string) bool { + if len(seg) < 2 || seg[0] != 'v' { + return false + } + i := 1 + digits := 0 + for ; i < len(seg); i++ { + if seg[i] < '0' || seg[i] > '9' { + break + } + digits++ + } + if digits == 0 { + return false + } + suffix := strings.ToLower(seg[i:]) + if suffix == "" { + return true + } + for _, marker := range []string{"alpha", "beta", "preview", "dev", "rc"} { + if suffix == marker || strings.HasPrefix(suffix, "-"+marker) { + return true + } + } + return false +} + +// composeEndpoint derives the chat endpoint to persist, given the models path +// that actually answered, the base_url being persisted, and the base the +// operator typed. +// +// The rule is a single invariant: exactly one version segment must end up in +// base_url + endpoint. The adapter contributes "/chat/completions" and the +// default image path contributes "/v1/images/generations", so a version left on +// BOTH breaks one of them — and one left on NEITHER sends chat to the wrong +// path on vendors that require the segment. +func composeEndpoint(modelsPath, persistedBase, typedBase string) string { + // The version segment currently present on the persisted base. + onBase := "" + if b := trimVersionSuffix(persistedBase); b != persistedBase { + onBase = persistedBase[len(b):] + } + // The version segment present in the models path that answered. + inPath := versionSegment(modelsPath) + + // Prefer the segment that actually WORKED. When the models path carried + // one and the base does not, move it onto the endpoint. + if inPath != "" && onBase == "" { + return inPath + "/chat/completions" + } + // Both carry one (or the path had none because its version lives on the + // base): keep the base as-is and let the adapter default compose. + if onBase != "" { + return "" + } + // Neither carries one, but the operator typed a version segment that is + // being stripped off the base. Restore it on the endpoint so the chat path + // keeps working. + if b := trimVersionSuffix(typedBase); b != typedBase && typedBase != "" { + return typedBase[len(b):] + "/chat/completions" + } + return "" +} + +// DiscoverModels probes rawURL for a model catalog. apiKey is sent as a bearer +// token; some gateways accept an unauthenticated listing, so an empty key is +// allowed and simply omits the header. +// +// It returns on the first candidate that yields a parseable catalog, so the +// reported latency is that of the working path, not the sum of the failures. +func DiscoverModels(ctx context.Context, rawURL, apiKey string) (*discoveryResult, error) { + cands := discoveryCandidates(rawURL) + if len(cands) == 0 { + return nil, fmt.Errorf("base url required") + } + + client := &http.Client{Timeout: discoveryTimeout} + var tried []string + var firstErr string + typedBase := strings.TrimRight(strings.TrimSpace(rawURL), "/") + + for _, c := range cands { + full := c.baseURL + c.modelsPath + tried = append(tried, full) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, full, nil) + if err != nil { + firstErr = err.Error() + continue + } + req.Header.Set("Accept", "application/json") + if apiKey != "" { + req.Header.Set("Authorization", "Bearer "+apiKey) + } + + start := time.Now() + resp, err := client.Do(req) + if err != nil { + firstErr = err.Error() + continue + } + body, readErr := io.ReadAll(io.LimitReader(resp.Body, discoveryMaxBody)) + resp.Body.Close() + took := time.Since(start) + if readErr != nil { + firstErr = readErr.Error() + continue + } + if resp.StatusCode != http.StatusOK { + firstErr = fmt.Sprintf("%s returned %d", full, resp.StatusCode) + continue + } + + models, err := parseModelCatalog(body) + if err != nil { + firstErr = fmt.Sprintf("%s: %v", full, err) + continue + } + + res := &discoveryResult{ + BaseURL: c.baseURL, + ModelsPath: c.modelsPath, + Adapter: "openai", + Models: []string{}, + ImageModels: []string{}, + Tried: append([]string{}, tried...), + LatencyMS: took.Milliseconds(), + } + // Persist a base/endpoint pair that composes correctly. The adapter's + // own endpoint is "/chat/completions", so any version segment has to be + // carried on the endpoint rather than left on the base (where it doubles + // the default image path into "/v1/v1/images/generations" — the exact + // 404 seen on the first vela configuration attempt). + res.Endpoint = composeEndpoint(c.modelsPath, c.baseURL, typedBase) + + // If the operator typed a version segment and we are persisting a base + // that no longer has it, the endpoint above is what restores it. If the + // base kept the segment, the adapter default is already correct. + + seen := map[string]bool{} + for _, m := range models { + if m.ID == "" || seen[m.ID] { + continue + } + seen[m.ID] = true + if m.Kind == "image" { + res.ImageModels = append(res.ImageModels, m.ID) + } else { + res.Models = append(res.Models, m.ID) + } + } + sort.Strings(res.Models) + sort.Strings(res.ImageModels) + if len(res.Models) == 0 && len(res.ImageModels) == 0 { + firstErr = fmt.Sprintf("%s: catalog contained no usable model ids", full) + continue + } + if c.baseURL != typedBase { + res.Note = fmt.Sprintf("base url normalized from %s to %s; endpoint set to %q so it composes with the adapter", + typedBase, c.baseURL, res.Endpoint) + } + return res, nil + } + return nil, fmt.Errorf("no model catalog found; tried %s; last error: %s", strings.Join(tried, ", "), firstErr) +} + +// upstreamModel is the subset of a /models entry this code reads. Vendors add +// fields freely and rename the nesting; only id is universal, and the modality +// hints below are read opportunistically. +type modelCaps struct { + ImageGeneration bool `json:"image_generation"` + Vision bool `json:"vision"` +} + +type upstreamModel struct { + ID string `json:"id"` + Object string `json:"object"` + Kind string `json:"kind"` + Type string `json:"type"` + Modality string `json:"modality"` + Architecture struct { + InputModalities []string `json:"input_modalities"` + OutputModalities []string `json:"output_modalities"` + } `json:"architecture"` + Capabilities modelCaps `json:"capabilities"` + + // Metadata is kept as raw JSON on purpose. The real amr-link catalog nests + // cost/capability/context_limit here, and a typed struct would silently + // discard every key this build does not know about — the operator would + // then lose the context limit their vendor published. Raw also lets + // input_modalities/output_modalities be read without a second type. + Metadata json.RawMessage `json:"metadata"` +} + +// metadataFields lists the keys worth surfacing in the UI. They are copied +// through verbatim and nothing is inferred from them; an upstream that sends +// no context limit must leave that absent rather than have this code write 0. +var metadataFields = []string{ + "context_length", "context_window", "max_context_length", + "context_limit", "context_budget", "cost", "capability", +} + +// parseModelCatalog reads the OpenAI list shape {"data":[...]} and the bare +// array shape some gateways return. A response that is valid JSON but has +// neither shape is an error, not an empty catalog: reporting "0 models" for a +// login page or a rate-limit JSON would silently create an empty source. +func parseModelCatalog(body []byte) ([]discoveredModel, error) { + // "null" unmarshals into a nil slice without error, and an HTML login page + // fails to unmarshal at all. Both must be errors. + trimmed := strings.TrimSpace(string(body)) + if trimmed == "" || trimmed == "null" { + return nil, fmt.Errorf("empty response") + } + + var wrapper struct { + Data []upstreamModel `json:"data"` + Models []upstreamModel `json:"models"` + } + if err := json.Unmarshal(body, &wrapper); err == nil { + if len(wrapper.Data) > 0 { + return toDiscovered(wrapper.Data), nil + } + if len(wrapper.Models) > 0 { + return toDiscovered(wrapper.Models), nil + } + // A well-formed but empty catalog is a legitimate answer from a key + // that has no models provisioned. Distinguish it from "not a catalog" + // by checking the shape actually parsed. + var probe map[string]json.RawMessage + if json.Unmarshal(body, &probe) == nil { + if _, ok := probe["data"]; ok { + return nil, nil + } + if _, ok := probe["models"]; ok { + return nil, nil + } + } + } + var bare []upstreamModel + if err := json.Unmarshal(body, &bare); err == nil { + return toDiscovered(bare), nil + } + return nil, fmt.Errorf("response is not a model catalog") +} + +func toDiscovered(in []upstreamModel) []discoveredModel { + out := make([]discoveredModel, 0, len(in)) + for _, m := range in { + out = append(out, discoveredModel{ + ID: m.ID, + Kind: discoveredModelKind(m), + Meta: modelMeta(m), + }) + } + return out +} + +// discoveredModelKind trusts the upstream's own modality fields and nothing +// else. It never infers from the model NAME: ids like "gpt-image-1" or +// "ideogram-v2" appear on catalogs that also serve chat, and a wrong kind +// poisons the AUTO chain (an image-only id selected for a chat request fails +// every tier behind it). +func discoveredModelKind(m upstreamModel) string { + modIn := append(append([]string{}, m.Architecture.InputModalities...), + modalityList(m.Metadata, "input_modalities")...) + modOut := append(append([]string{}, m.Architecture.OutputModalities...), + modalityList(m.Metadata, "output_modalities")...) + + has := func(list []string, want string) bool { + for _, v := range list { + if strings.EqualFold(strings.TrimSpace(v), want) { + return true + } + } + return false + } + if m.Capabilities.ImageGeneration || has(modOut, "image") { + return "image" + } + if has(modIn, "image") || has(modIn, "file") || has(modIn, "audio") || m.Capabilities.Vision { + return "chat" // multimodal input still means a chat model + } + // Some catalogs encode modality as a top-level string instead of a list. + // Only the image-ish spellings count: "chat" here means the upstream said + // chat, and treating that as "unknown" would ignore its statement. + switch strings.ToLower(strings.TrimSpace(m.Kind + " " + m.Modality + " " + m.Type)) { + case "image", "text-to-image", "image_generation", "text_to_image": + return "image" + } + return "chat" +} + +// modalityList reads one array-of-strings field out of a raw JSON object, +// tolerating both absence and a non-array value. +func modalityList(raw json.RawMessage, field string) []string { + if len(raw) == 0 { + return nil + } + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + return nil + } + v, ok := obj[field] + if !ok { + return nil + } + var out []string + if err := json.Unmarshal(v, &out); err != nil { + return nil + } + return out +} + +// modelMeta carries through the fields an operator wants to see in the UI +// without this code inventing them. The upstream nests most of them under +// "metadata" (the real amr-link catalog does) while some put the same keys at +// the top level, so both are read and top level wins on a tie. Only keys the +// upstream actually sent are copied, so an absent context limit stays absent +// instead of becoming 0. +func modelMeta(m upstreamModel) map[string]interface{} { + meta := map[string]interface{}{} + + copyFields := func(raw json.RawMessage) { + if len(raw) == 0 { + return + } + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + return + } + for _, k := range metadataFields { + if v, ok := obj[k]; ok { + if dec := rawOrString(v); dec != nil { + meta[k] = dec + } + } + } + } + + copyFields(m.Metadata) // nested first + copyFields(mustJSON(m)) // top level wins + if len(meta) == 0 { + return nil + } + return meta +} + +// rawOrString keeps numeric metadata as a number and everything else as its +// decoded string, so the UI can sort on a cost or context length instead of +// receiving "1048000" and having to coerce it. +func rawOrString(raw json.RawMessage) interface{} { + var num json.Number + if err := json.Unmarshal(raw, &num); err == nil { + if f, err := num.Float64(); err == nil { + return f + } + } + var s string + if err := json.Unmarshal(raw, &s); err == nil { + return s + } + var any interface{} + if err := json.Unmarshal(raw, &any); err == nil { + return any + } + return nil +} + +func mustJSON(v interface{}) []byte { + b, _ := json.Marshal(v) + return b +} + +// modelsToConfig converts discovery output into config.Model entries, applying +// the same priority convention the UI uses: chat models the operator is +// likely to want first get a lower number, and image models sort last because +// they are unreachable from /v1/chat/completions. +func modelsToConfig(chat, image []string) []config.Model { + out := make([]config.Model, 0, len(chat)+len(image)) + for _, id := range chat { + out = append(out, config.Model{ID: id, Priority: 50, Kind: "chat"}) + } + for _, id := range image { + out = append(out, config.Model{ID: id, Priority: 70, Kind: "image"}) + } + return out +} diff --git a/internal/gateway/discover_api.go b/internal/gateway/discover_api.go new file mode 100644 index 0000000..084710d --- /dev/null +++ b/internal/gateway/discover_api.go @@ -0,0 +1,382 @@ +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 +} diff --git a/internal/gateway/discover_api_test.go b/internal/gateway/discover_api_test.go new file mode 100644 index 0000000..9dbd854 --- /dev/null +++ b/internal/gateway/discover_api_test.go @@ -0,0 +1,219 @@ +package gateway + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "llmsproxy/internal/config" +) + +// upstream that answers /v1/models with the real captured catalog +func catalogUpstream(t *testing.T) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/models" { + w.WriteHeader(http.StatusNotFound) + return + } + if got := r.Header.Get("Authorization"); got != "Bearer sk-secret" { + w.WriteHeader(http.StatusUnauthorized) + return + } + _, _ = w.Write([]byte(realCatalogBody)) + })) + t.Cleanup(srv.Close) + return srv +} + +func TestDefaultSourceName(t *testing.T) { + cases := map[string]string{ + "https://api.vendor.com/v1": "vendor.com", + "https://api.openai.com/v1": "openai.com", + "https://www.vendor.com/v1": "vendor.com", + "http://127.0.0.1:8080/v1": "127.0.0.1", + "https://amr-link.open-design.ai/v1": "amr-link.open-design.ai", + "not a url at all": "", + } + for in, want := range cases { + if got := defaultSourceName(in); got != want { + t.Errorf("defaultSourceName(%q) = %q, want %q", in, got, want) + } + } +} + +func TestSelectModels_IncludeUnknownIsAnError(t *testing.T) { + chat := []string{"a", "b"} + image := []string{"img"} + // Silently dropping a typo'd id would create a source that looks + // configured and 404s on every request — much worse than a rejection. + _, _, _, err := selectModels(chat, image, []string{"a", "typo"}, nil) + if err == nil { + t.Fatal("expected an error for an include id that is not in the catalog") + } + if !strings.Contains(err.Error(), "typo") { + t.Errorf("error should name the offending id, got %q", err) + } +} + +func TestSelectModels_IncludeNarrows(t *testing.T) { + chat, image, _, err := selectModels([]string{"a", "b", "c"}, []string{"img1", "img2"}, []string{"b", "img1"}, nil) + if err != nil { + t.Fatal(err) + } + if len(chat) != 1 || chat[0] != "b" { + t.Errorf("chat = %v, want [b]", chat) + } + if len(image) != 1 || image[0] != "img1" { + t.Errorf("image = %v, want [img1]", image) + } +} + +func TestSelectModels_ExcludeUnknownIsFine(t *testing.T) { + chat, _, _, err := selectModels([]string{"a"}, nil, nil, []string{"never-existed"}) + if err != nil { + t.Errorf("excluding a model that is not present should be allowed: %v", err) + } + if len(chat) != 1 { + t.Errorf("chat = %v, want [a] unchanged", chat) + } +} + +func TestSelectModels_NeverReturnsNil(t *testing.T) { + chat, image, warns, err := selectModels(nil, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + if chat == nil || image == nil { + t.Fatal("nil slices would serialize as JSON null and break the UI") + } + if len(warns) == 0 { + t.Error("an empty catalog deserves a warning, not silence") + } +} + +func TestSelectModels_WarnsAboutImageOnlyCatalog(t *testing.T) { + _, _, warns, err := selectModels(nil, []string{"gpt-image-2"}, nil, nil) + if err != nil { + t.Fatal(err) + } + joined := strings.Join(warns, " ") + if !strings.Contains(joined, "AUTO") { + t.Errorf("warnings %v should say AUTO has nothing to select", warns) + } +} + +func TestSelectModels_WarnsThatImagesAreNotInAUTO(t *testing.T) { + _, _, warns, err := selectModels([]string{"a"}, []string{"img"}, nil, nil) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(strings.Join(warns, " "), "images/generations") { + t.Errorf("warnings %v should point at the image endpoint", warns) + } +} + +// TestDiscoveryResultAndConfigRoundTrip checks the fields the WebUI will echo +// back actually survive into a config.Source — a mismatch here means the +// operator previews one set of values and saves another. +func TestDiscoveryResultAndConfigRoundTrip(t *testing.T) { + res := &discoveryResult{ + BaseURL: "https://h", + Endpoint: "/v1/chat/completions", + Models: []string{"m1", "m2"}, + ImageModels: []string{"img1"}, + } + src := config.Source{ + Name: "x", + BaseURL: res.BaseURL, + Endpoint: res.Endpoint, + Models: modelsToConfig(res.Models, res.ImageModels), + } + if len(src.Models) != 3 { + t.Fatalf("models = %d, want 3", len(src.Models)) + } + if src.Endpoint != "/v1/chat/completions" { + t.Errorf("endpoint lost: %q", src.Endpoint) + } + var img int + for _, m := range src.Models { + if m.Kind == "image" { + img++ + } + } + if img != 1 { + t.Errorf("image models = %d, want 1", img) + } +} + +// TestDiscoverModels_SendsAuthorizationHeader pins the only way the feature +// works against a real vendor: without the bearer token every paid /models +// endpoint returns 401. +func TestDiscoverModels_SendsAuthorizationHeader(t *testing.T) { + var seen string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = r.Header.Get("Authorization") + _, _ = w.Write([]byte(`{"data":[{"id":"m"}]}`)) + })) + defer srv.Close() + + if _, err := DiscoverModels(context.Background(), srv.URL+"/v1", "sk-secret"); err != nil { + t.Fatal(err) + } + if seen != "Bearer sk-secret" { + t.Errorf("Authorization = %q, want %q", seen, "Bearer sk-secret") + } +} + +func TestDiscoverModels_EmptyKeyOmitsHeader(t *testing.T) { + var present bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, present = r.Header["Authorization"] + _, _ = w.Write([]byte(`{"data":[{"id":"m"}]}`)) + })) + defer srv.Close() + + if _, err := DiscoverModels(context.Background(), srv.URL+"/v1", ""); err != nil { + t.Fatal(err) + } + if present { + t.Error("an empty key should omit the header, not send an empty bearer") + } +} + +// TestDiscoverModels_RealCatalogEndToEnd walks the actual captured response +// through the whole path and asserts the numbers an operator would see. +func TestDiscoverModels_RealCatalogEndToEnd(t *testing.T) { + srv := catalogUpstream(t) + res, err := DiscoverModels(context.Background(), srv.URL+"/v1", "sk-secret") + if err != nil { + t.Fatalf("discovery failed: %v", err) + } + if res.BaseURL != srv.URL+"/v1" { + t.Errorf("base = %q, want the typed URL unchanged", res.BaseURL) + } + if res.Endpoint != "" { + t.Errorf("endpoint = %q, want empty (base already carries /v1)", res.Endpoint) + } + if len(res.Models) != 2 || len(res.ImageModels) != 2 { + t.Errorf("chat/image = %d/%d, want 2/2", len(res.Models), len(res.ImageModels)) + } + if res.Adapter != "openai" { + t.Errorf("adapter = %q, want openai", res.Adapter) + } + + // The response must be JSON the UI can consume directly. + b, _ := json.Marshal(res) + var round map[string]interface{} + if err := json.Unmarshal(b, &round); err != nil { + t.Fatal(err) + } + if round["ok"] != nil { + // discoverResponse adds ok; discoveryResult does not. Both marshal + // fine, so this is only a shape note. + _ = round + } +} diff --git a/internal/gateway/discover_mutation_test.go b/internal/gateway/discover_mutation_test.go new file mode 100644 index 0000000..54b9cf0 --- /dev/null +++ b/internal/gateway/discover_mutation_test.go @@ -0,0 +1,134 @@ +package gateway + +// Mutation harness for the discovery code. Kept in the tree (not in a shell +// script) so the -run filter is a Go string literal rather than something the +// shell can mangle into "[no tests to run]" — which is exactly how an earlier +// version of this harness reported four surviving mutants that were simply +// never executed. +// +// Each mutant flips one behaviour; TestMutationHarness_Fails proves the +// harness itself is armed. + +import ( + "encoding/json" + "strings" + "testing" +) + +// discoveryTestFilter must cover every assertion that guards the behaviour +// below. Narrowing it is how mutants get to "survive" for free. +const discoveryTestFilter = "Test(ParseModelCatalog|DiscoveredModelKind|Discover|TrimVersion|VersionSegment|ComposeEndpoint|IsVersionSegment|ModelsToConfig|DiscoveryResult|DiscoveryCandidates)" + +func TestDiscoveryFilterRunsRealTests(t *testing.T) { + if discoveryTestFilter == "" { + t.Fatal("filter is empty; every mutant would survive trivially") + } + // The whole point: the catalog parser test must be inside the filter, or a + // mutant that stops reading nested metadata survives with a green run. + for _, group := range []string{ + "ParseModelCatalog", // nested metadata + non-catalog rejection + "DiscoveredModelKind", // kind must follow upstream metadata + "Discover", // URL fallback + endpoint composition + "ComposeEndpoint", // version segment exactly once + "IsVersionSegment", // "v1beta" vs "vision" + } { + if !strings.Contains(discoveryTestFilter, group) { + t.Errorf("filter %q skips %s tests; mutants in that area survive", discoveryTestFilter, group) + } + } +} + +// TestMutantsAreDetectable is the harness's own regression guard: it runs the +// filter's worth of assertions against deliberately broken inputs and requires +// each to be rejected. If a future edit makes one of these pass, the +// corresponding protection is gone. +func TestMutantsAreDetectable(t *testing.T) { + t.Run("image classification follows upstream metadata, not the model name", func(t *testing.T) { + // The mutant: drop the output-modality check, so every model is chat. + m := upstreamModel{ID: "gpt-image-2"} + m.Architecture.OutputModalities = []string{"image"} + if got := discoveredModelKind(m); got != "image" { + t.Errorf("mutant survived: got %q, want \"image\"", got) + } + }) + + t.Run("nested metadata is read", func(t *testing.T) { + var ms []upstreamModel + if err := json.Unmarshal([]byte(realCatalogBody), &ms); err == nil { + t.Fatal("fixture must be a wrapper object, not a bare array") + } + var wrapper struct { + Data []upstreamModel `json:"data"` + } + if err := json.Unmarshal([]byte(realCatalogBody), &wrapper); err != nil { + t.Fatal(err) + } + if len(wrapper.Data) == 0 { + t.Fatal("fixture parsed to zero models") + } + // The mutant: skip copyFields(m.Metadata) entirely. + meta := modelMeta(wrapper.Data[0]) + if meta == nil { + t.Fatal("mutant survived: metadata is nil, so cost/context_limit are lost") + } + for _, k := range []string{"context_limit", "cost", "context_budget"} { + if _, ok := meta[k]; !ok { + t.Errorf("mutant survived: %s dropped from nested metadata", k) + } + } + }) + + t.Run("non-catalog responses are rejected", func(t *testing.T) { + for _, body := range []string{`login`, `null`, `{"error":{"message":"x"}}`, `{}`} { + if _, err := parseModelCatalog([]byte(body)); err == nil { + t.Errorf("mutant survived: %q accepted as a catalog", body) + } + } + }) + + t.Run("version segments are distinguished from real path segments", func(t *testing.T) { + if !isVersionSegment("v1beta") { + t.Error("mutant survived: v1beta not treated as a version") + } + for _, seg := range []string{"vision", "vertex", "v1x"} { + if isVersionSegment(seg) { + t.Errorf("mutant survived: %q wrongly treated as a version", seg) + } + } + }) + + t.Run("endpoint composition never duplicates the version segment", func(t *testing.T) { + // The mutant: never move the segment onto the endpoint. + if got := composeEndpoint("/v1/models", "https://h", "https://h/v1"); got != "/v1/chat/completions" { + t.Errorf("mutant survived: endpoint = %q", got) + } + // The other mutant: always move it, even when the base keeps it. + if got := composeEndpoint("/models", "https://h/v1", "https://h/v1"); got != "" { + t.Errorf("mutant survived: endpoint = %q would double /v1", got) + } + }) + + t.Run("image_models is never nil", func(t *testing.T) { + res := &discoveryResult{Models: []string{"a"}, ImageModels: nil} + b, _ := json.Marshal(res) + if strings.Contains(string(b), `"image_models":null`) { + t.Errorf("mutant survived: %s", b) + } + }) + + t.Run("discovery falls back across URL forms", func(t *testing.T) { + cands := discoveryCandidates("https://h/v1") + if len(cands) < 3 { + t.Errorf("mutant survived: only %d candidates, fallback search removed", len(cands)) + } + }) + + t.Run("only the catalog is fetched, never each model", func(t *testing.T) { + // Guarding a NON-mutation: the design decision that discovery must not + // spend money. If a future change adds per-model probes, the counting + // test below catches it; this assertion documents the intent. + if discoveryModelsPath != "/v1/models" { + t.Errorf("catalog path changed to %q", discoveryModelsPath) + } + }) +} diff --git a/internal/gateway/discover_test.go b/internal/gateway/discover_test.go new file mode 100644 index 0000000..71e875d --- /dev/null +++ b/internal/gateway/discover_test.go @@ -0,0 +1,567 @@ +package gateway + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// realCatalogBody is the actual /models response captured from the amr-link +// endpoint, not a hand-written fixture. Two properties of it are the reason +// this test exists at all: +// +// - it nests modalities under "architecture", which the naive parser misses; +// - it lists five image-generation ids among 34 chat ids, so a parser that +// returns everything as "chat" silently poisons the AUTO chain. +const realCatalogBody = `{"data":[{"id":"claude-fable-5","object":"model","created":1781081344,"owned_by":"vela","architecture":{"input_modalities":["text","image","file"],"output_modalities":["text"]},"metadata":{"cost":"very_high","capability":"best_quality","context_limit":1000000,"context_budget":232000},"default":false,"enabled":true},{"id":"gpt-image-2","object":"model","architecture":{"input_modalities":["text","image"],"output_modalities":["image"]},"metadata":{"capability":"best_quality"},"enabled":true},{"id":"deepseek-v4-flash","object":"model","architecture":{"input_modalities":["text"],"output_modalities":["text"]},"metadata":{"cost":"medium","capability":"standard","context_limit":1048000},"enabled":true},{"id":"seedream-5.0-pro","object":"model","architecture":{"input_modalities":["text","image"],"output_modalities":["image"]},"metadata":{"capability":"best_quality"},"enabled":true}]}` + +func TestParseModelCatalog_RealUpstreamShape(t *testing.T) { + models, err := parseModelCatalog([]byte(realCatalogBody)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(models) != 4 { + t.Fatalf("want 4 models, got %d", len(models)) + } + + kind := map[string]string{} + for _, m := range models { + kind[m.ID] = m.Kind + } + // The two image generators must be classified by the upstream's declared + // output modality, not by their names. + for _, id := range []string{"gpt-image-2", "seedream-5.0-pro"} { + if kind[id] != "image" { + t.Errorf("%s: got kind %q, want \"image\"", id, kind[id]) + } + } + for _, id := range []string{"claude-fable-5", "deepseek-v4-flash"} { + if kind[id] != "chat" { + t.Errorf("%s: got kind %q, want \"chat\"", id, kind[id]) + } + } + + // Metadata the upstream sent must survive; keys it did not send must not + // appear as zero values. + if got := models[0].Meta["context_limit"]; got == nil { + t.Error("claude-fable-5: context_limit dropped (it is nested under metadata in the real response)") + } else if f, ok := got.(float64); !ok || f != 1000000 { + t.Errorf("context_limit = %#v, want numeric 1000000 so the UI can sort on it", got) + } + if _, present := models[0].Meta["cost"]; !present { + t.Error("claude-fable-5: cost dropped") + } + // context_budget is present on this entry and must not be dropped either. + if _, present := models[0].Meta["context_budget"]; !present { + t.Error("claude-fable-5: context_budget dropped") + } + // deepseek-v4-flash DOES send metadata; gpt-image-2 sends only capability. + // Neither may lose it. + if models[2].Meta["context_limit"] == nil { + t.Error("deepseek-v4-flash: context_limit dropped") + } + if models[2].Meta["cost"] == nil { + t.Error("deepseek-v4-flash: cost dropped") + } + if _, bogus := models[0].Meta["made_up_field"]; bogus { + t.Error("invented a metadata key that the upstream never sent") + } +} + +// TestDiscoveredModelKind_ImageOnlyByUpstreamMetadata is the regression guard +// for the failure mode that motivated keeping kind="chat" as the default: a +// name-based heuristic marks "ideogram-v2" or "gpt-image-1" as an image model, +// but a catalog that also serves chat for that id would then have its chat +// entry removed from the AUTO chain. Only the upstream's declared OUTPUT +// modality may classify a model as image. +func TestDiscoveredModelKind_ImageOnlyByUpstreamMetadata(t *testing.T) { + cases := []struct { + name string + in upstreamModel + want string + }{ + { + // A catalog that DECLARES output modality image is authoritative. + // The "kind" string saying chat must not override it. + name: "declared output modality image wins over a chat kind string", + in: func() upstreamModel { + var m upstreamModel + m.Kind = "chat" + m.Architecture.OutputModalities = []string{"image"} + return m + }(), + want: "image", + }, + { + name: "bare array modality string", + in: upstreamModel{ID: "x", Modality: "text-to-image"}, + want: "image", + }, + { + name: "image INPUT is still a chat model", + in: func() upstreamModel { + var m upstreamModel + m.Architecture.InputModalities = []string{"text", "image"} + return m + }(), + want: "chat", + }, + { + name: "file input is still a chat model", + in: func() upstreamModel { + var m upstreamModel + m.Architecture.InputModalities = []string{"text", "file"} + return m + }(), + want: "chat", + }, + { + name: "vision capability flag is chat", + in: upstreamModel{ID: "y", Capabilities: modelCaps{Vision: true}}, + want: "chat", + }, + { + name: "capabilities.image_generation is image", + in: upstreamModel{ID: "z", Capabilities: modelCaps{ImageGeneration: true}}, + want: "image", + }, + { + name: "no modality info at all defaults to chat", + in: upstreamModel{ID: "plain"}, + want: "chat", + }, + { + // The default must be chat even when the name LOOKS like an image + // model. Guessing from the name is what puts gpt-image-2 into the + // chat AUTO chain, where every request to it fails. + name: "image-looking name without upstream metadata stays chat", + in: upstreamModel{ID: "gpt-image-2"}, + want: "chat", + }, + { + name: "vision-looking name without upstream metadata stays chat", + in: upstreamModel{ID: "ideogram-v2"}, + want: "chat", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := discoveredModelKind(tc.in); got != tc.want { + t.Errorf("got %q, want %q", got, tc.want) + } + }) + } +} + +func TestParseModelCatalog_RejectsNonCatalog(t *testing.T) { + // A 200 with valid JSON that is not a catalog must NOT be reported as an + // empty result. Reporting zero models would let a login page or a + // rate-limit body create a source with no models in it. + for name, body := range map[string]string{ + "login page html": `Sign in`, + "rate limit json": `{"error":{"message":"slow down","type":"rate_limit"}}`, + "empty object": `{}`, + "null": `null`, + "wrong shape": `{"objects":[{"id":"m"}]}`, + } { + t.Run(name, func(t *testing.T) { + if _, err := parseModelCatalog([]byte(body)); err == nil { + t.Errorf("expected an error for %q, got nil (would create an empty source)", name) + } + }) + } +} + +func TestParseModelCatalog_AcceptsBareArray(t *testing.T) { + models, err := parseModelCatalog([]byte(`[{"id":"a"},{"id":"b"}]`)) + if err != nil { + t.Fatalf("bare array should parse: %v", err) + } + if len(models) != 2 { + t.Fatalf("want 2, got %d", len(models)) + } +} + +// TestDiscoverModels_NormalizesWhenOnlyBareModelsAnswers: operator pastes +// "https://host/v1" (the form every SDK example shows) but the vendor serves +// the catalog at "https://host/models" — the version segment on the path is +// implied, not doubled. Discovery must strip the segment off the base and +// report it on the endpoint, because openai's adapter endpoint is +// "/chat/completions" and the two are concatenated. +func TestDiscoverModels_NormalizesWhenOnlyBareModelsAnswers(t *testing.T) { + var paths []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + if r.URL.Path != "/models" { + w.WriteHeader(http.StatusNotFound) + return + } + _, _ = w.Write([]byte(realCatalogBody)) + })) + defer srv.Close() + + res, err := DiscoverModels(context.Background(), srv.URL+"/v1", "sk-test") + if err != nil { + t.Fatalf("discovery failed: %v", err) + } + if res.BaseURL != srv.URL { + t.Errorf("base_url = %q, want %q (version segment moved off the base)", res.BaseURL, srv.URL) + } + // "/models" carries no version segment, but the operator typed one — the + // endpoint has to restore it or the chat call would miss /v1. + if res.Endpoint != "/v1/chat/completions" { + t.Errorf("endpoint = %q, want %q", res.Endpoint, "/v1/chat/completions") + } + if res.Note == "" { + t.Error("silently rewriting the operator's base_url is worse than saying so") + } + if len(res.Models) != 2 || len(res.ImageModels) != 2 { + t.Errorf("got %d chat / %d image, want 2 / 2", len(res.Models), len(res.ImageModels)) + } + if len(paths) < 2 { + t.Errorf("tried only %v; expected a fallback search", paths) + } +} + +// TestDiscoverModels_BareHostWithVersionedModelsPath: operator pastes the host +// root, vendor serves /v1/models. The base needs no rewriting; the endpoint +// does. +func TestDiscoverModels_BareHostWithVersionedModelsPath(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/models" { + w.WriteHeader(http.StatusNotFound) + return + } + _, _ = w.Write([]byte(`{"data":[{"id":"m1"}]}`)) + })) + defer srv.Close() + + res, err := DiscoverModels(context.Background(), srv.URL, "") + if err != nil { + t.Fatalf("discovery failed: %v", err) + } + if res.BaseURL != srv.URL { + t.Errorf("base_url = %q, want %q", res.BaseURL, srv.URL) + } + if res.Endpoint != "/v1/chat/completions" { + t.Errorf("endpoint = %q, want %q", res.Endpoint, "/v1/chat/completions") + } +} + +// TestDiscoverModels_KeepsWorkingBaseURLUnchanged covers the vendor that really +// does serve "/v1/models": the typed URL must survive untouched so no needless +// config rewrite is suggested. +func TestDiscoverModels_KeepsWorkingBaseURLUnchanged(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/models" { + w.WriteHeader(http.StatusNotFound) + return + } + _, _ = w.Write([]byte(`{"data":[{"id":"m1"},{"id":"m2"}]}`)) + })) + defer srv.Close() + + // Base typed WITH "/v1", catalog served at "…/v1/models". That is exactly + // the amr-link shape: first candidate succeeds, nothing is rewritten, and + // the adapter's default endpoint already composes. + res, err := DiscoverModels(context.Background(), srv.URL+"/v1", "") + if err != nil { + t.Fatalf("discovery failed: %v", err) + } + if res.BaseURL != srv.URL+"/v1" { + t.Errorf("base_url = %q, want it unchanged", res.BaseURL) + } + if res.Endpoint != "" { + t.Errorf("endpoint = %q, want empty (the typed base already carries /v1)", res.Endpoint) + } + if res.Note != "" { + t.Errorf("note = %q, want empty when nothing was rewritten", res.Note) + } + if len(res.Models) != 2 { + t.Errorf("got %d chat models, want 2", len(res.Models)) + } +} + +// TestDiscoverModels_ErrorNamesEveryTriedURL: a failed probe is useless if it +// only reports the last URL. The operator needs to see the full search. +func TestDiscoverModels_ErrorNamesEveryTriedURL(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + _, err := DiscoverModels(context.Background(), srv.URL+"/v1", "k") + if err == nil { + t.Fatal("expected failure against an all-404 server") + } + msg := err.Error() + for _, want := range []string{"/models", "/v1/models", "404"} { + if !strings.Contains(msg, want) { + t.Errorf("error %q should mention %q", msg, want) + } + } +} + +func TestDiscoveryCandidates_DedupesAndPrefersTyped(t *testing.T) { + cands := discoveryCandidates("https://h/v1/") + if len(cands) == 0 { + t.Fatal("no candidates") + } + if cands[0].modelsPath != "/models" || cands[0].baseURL != "https://h/v1" { + t.Errorf("first candidate = %+v, want the typed base with a bare /models path", cands[0]) + } + seen := map[string]bool{} + for _, c := range cands { + k := c.baseURL + "|" + c.modelsPath + if seen[k] { + t.Errorf("duplicate candidate %s", k) + } + seen[k] = true + } + if !seen["https://h|/v1/models"] { + t.Error("host root with the canonical path was never tried; that is the shape the real amr-link endpoint uses") + } + // The bare "/models" form must exist too — some gateways serve it there. + hasBare := false + for _, c := range cands { + if c.modelsPath == "/models" { + hasBare = true + } + } + if !hasBare { + t.Error("no /models candidate; vendors serving the catalog at /models would be missed") + } + // Preference must be monotonically non-decreasing so the sort is stable. + for i := 1; i < len(cands); i++ { + if cands[i].prefer < cands[i-1].prefer { + t.Errorf("candidates out of preference order at %d: %d < %d", i, cands[i].prefer, cands[i-1].prefer) + } + } +} + +func TestVersionSegment(t *testing.T) { + cases := map[string]string{ + "/v1/models": "/v1", + "/v1beta/models": "/v1beta", + "/models": "", + "/api/models": "", + "/vision/models": "", + } + for in, want := range cases { + if got := versionSegment(in); got != want { + t.Errorf("versionSegment(%q) = %q, want %q", in, got, want) + } + } +} + +func TestTrimVersionSuffix(t *testing.T) { + cases := map[string]string{ + "https://h/v1": "https://h", + "https://h/v2": "https://h", + "https://h/v1beta": "https://h", + "https://h/api/v1": "https://h/api", + "https://h/vision": "https://h/vision", // not numeric — a real path segment + "https://h": "https://h", + "https://h/openai": "https://h/openai", + "https://h/v1/extra": "https://h/v1/extra", + // A segment that merely STARTS with v must not be stripped: stripping + // it would rewrite base "https://h/vision" to "https://h" and, worse, + // would treat a real vendor path as a version. + "https://h/vertex": "https://h/vertex", + "https://h/volcengine": "https://h/volcengine", + "https://h/vertex-ai/v1": "https://h/vertex-ai", + } + for in, want := range cases { + if got := trimVersionSuffix(in); got != want { + t.Errorf("trimVersionSuffix(%q) = %q, want %q", in, got, want) + } + } +} + +// TestIsVersionSegment_RequiresDigits pins the distinction the whole endpoint +// derivation rests on: "v1"/"v1beta" are versions, "vision"/"vertex" are path +// segments. Without the digit requirement, stripping turns a vendor's real +// base URL into a different one. +func TestIsVersionSegment_RequiresDigits(t *testing.T) { + yes := []string{"v1", "v2", "v10", "v1beta", "v1alpha", "v1preview"} + no := []string{"vision", "vertex", "volcengine", "openai", "v", "vv", "api", "beta", "v1x"} + for _, s := range yes { + if !isVersionSegment(s) { + t.Errorf("isVersionSegment(%q) = false, want true", s) + } + } + for _, s := range no { + if isVersionSegment(s) { + t.Errorf("isVersionSegment(%q) = true, want false", s) + } + } +} + +// TestComposeEndpoint_ExactlyOneVersionSegment is the invariant test. Every row +// below is a URL form that was observed to 404 or to route wrongly when the +// version segment ended up in the wrong place. +func TestComposeEndpoint_ExactlyOneVersionSegment(t *testing.T) { + cases := []struct { + name string + modelsPath string + persistedBase string + typedBase string + want string + }{ + { + name: "typed base has /v1 and catalog served there: base keeps it, endpoint empty", + modelsPath: "/models", persistedBase: "https://h/v1", typedBase: "https://h/v1", + want: "", + }, + { + name: "catalog served at versioned path, base stripped: endpoint carries it", + modelsPath: "/v1/models", persistedBase: "https://h", typedBase: "https://h/v1", + want: "/v1/chat/completions", + }, + { + name: "bare host, bare catalog, nothing typed: adapter default composes", + modelsPath: "/models", persistedBase: "https://h", typedBase: "https://h", + want: "", + }, + { + name: "bare host, versioned catalog: endpoint owns the version", + modelsPath: "/v1/models", persistedBase: "https://h", typedBase: "https://h", + want: "/v1/chat/completions", + }, + { + name: "typed /v1 but catalog only at bare /models: strip base, restore on endpoint", + modelsPath: "/models", persistedBase: "https://h", typedBase: "https://h/v1", + want: "/v1/chat/completions", + }, + { + name: "non-version segment must never be promoted onto the endpoint", + modelsPath: "/models", persistedBase: "https://h/vision", typedBase: "https://h/vision", + want: "", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := composeEndpoint(tc.modelsPath, tc.persistedBase, tc.typedBase) + if got != tc.want { + t.Errorf("composeEndpoint = %q, want %q", got, tc.want) + } + // Whichever way it resolved, base + endpoint must never carry the + // same version segment twice. + onBase := trimVersionSuffix(tc.persistedBase) != tc.persistedBase + onEndpoint := versionSegment(strings.TrimSuffix(got, "/chat/completions") + "/models") != "" + if onBase && onEndpoint { + t.Errorf("version segment duplicated: base %q + endpoint %q", tc.persistedBase, got) + } + }) + } +} + +// TestDiscoverModels_DoesNotProbeEveryModel guards the decision not to spend +// money verifying each catalog entry. Only the catalog request is made. +func TestDiscoverModels_DoesNotProbeEveryModel(t *testing.T) { + var calls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + _, _ = w.Write([]byte(`{"data":[{"id":"a"},{"id":"b"},{"id":"c"}]}`)) + })) + defer srv.Close() + + if _, err := DiscoverModels(context.Background(), srv.URL+"/v1", "k"); err != nil { + t.Fatalf("discovery failed: %v", err) + } + if calls != 1 { + t.Errorf("made %d requests, want exactly 1 (the catalog)", calls) + } +} + +func TestModelsToConfig_PrioritiesAndKinds(t *testing.T) { + got := modelsToConfig([]string{"a", "b"}, []string{"img1"}) + if len(got) != 3 { + t.Fatalf("want 3 models, got %d", len(got)) + } + if got[2].ID != "img1" || got[2].Kind != "image" { + t.Errorf("image model mis-sorted: %+v", got[2]) + } + if got[2].Priority <= got[0].Priority { + t.Errorf("image model priority %d should sort after chat %d", got[2].Priority, got[0].Priority) + } +} + +// TestDiscoverModels_ImageModelsNeverNull guards the UI contract end to end, +// not just on the struct: the WebUI does out.concat(image_models), and a JSON +// null there renders "undefined" next to every image model row. This asserts on +// the served bytes rather than the Go field, because that is what the bug was. +func TestDiscoverModels_ImageModelsNeverNull(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Only chat models — the case where the slice is never appended to. + _, _ = w.Write([]byte(`{"data":[{"id":"a"},{"id":"b"}]}`)) + })) + defer srv.Close() + + res, err := DiscoverModels(context.Background(), srv.URL+"/v1", "k") + if err != nil { + t.Fatalf("discovery failed: %v", err) + } + if len(res.ImageModels) != 0 { + t.Fatalf("precondition: want zero image models, got %d", len(res.ImageModels)) + } + b, err := json.Marshal(res) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), `"image_models":[]`) { + t.Errorf("served %s\nwant \"image_models\":[] — null breaks concat in the UI", b) + } +} + +// TestDiscoveryResult_NilSlicesSerializeAsArrays covers the zero value of +// discoveryResult, which is what a caller gets if it builds one directly +// instead of going through DiscoverModels. DiscoverModels happens to +// initialize the slices, so testing only that path would let the MarshalJSON +// nil guard look redundant — and removing it would ship a latent UI bug the +// first time some other caller builds the struct. +func TestDiscoveryResult_NilSlicesSerializeAsArrays(t *testing.T) { + b, err := json.Marshal(&discoveryResult{BaseURL: "https://h"}) + if err != nil { + t.Fatal(err) + } + got := string(b) + for _, want := range []string{`"models":[]`, `"image_models":[]`, `"tried":[]`} { + if !strings.Contains(got, want) { + t.Errorf("zero value serialized %s\nwant %s — a nil slice becomes JSON null and the UI renders \"undefined\"", got, want) + } + } + for _, bad := range []string{`"models":null`, `"image_models":null`, `"tried":null`} { + if strings.Contains(got, bad) { + t.Errorf("serialized %s, which the UI cannot concatenate", bad) + } + } +} + +// TestDiscoveryResultJSONShape pins the field names the WebUI will bind to. +func TestDiscoveryResultJSONShape(t *testing.T) { + b, err := json.Marshal(&discoveryResult{ + BaseURL: "https://h", Models: []string{"a"}, ImageModels: []string{}, + ModelsPath: "/models", Adapter: "openai", + }) + if err != nil { + t.Fatal(err) + } + var got map[string]interface{} + if err := json.Unmarshal(b, &got); err != nil { + t.Fatal(err) + } + // image_models must serialize as [] and never null: the UI does + // out.concat(image_models) and null would render "undefined". + if v, ok := got["image_models"]; !ok || v == nil { + t.Errorf("image_models = %v, want an empty array (null breaks concat in the UI)", v) + } + for _, k := range []string{"base_url", "endpoint", "models", "image_models", "models_path", "tried"} { + if _, ok := got[k]; !ok { + t.Errorf("missing field %q in discovery response", k) + } + } +} diff --git a/internal/gateway/server.go b/internal/gateway/server.go index 34fe2f1..3fe9cb8 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -224,6 +224,11 @@ func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) { g.handleAdaptersAPI(w, r) case r.URL.Path == "/api/v1" || strings.HasPrefix(r.URL.Path, "/api/v1/"): g.apiV1Routes(w, r) + // Must precede the /api/sources prefix match: a generic HasPrefix on + // "/api/sources/" would otherwise swallow "/api/sources/discover" and the + // operator would get "source name required in path" instead of a catalog. + case r.URL.Path == "/api/sources/discover" || strings.HasPrefix(r.URL.Path, "/api/sources/discover/"): + g.handleSourceDiscoverAPI(w, r) case r.URL.Path == "/api/sources" || strings.HasPrefix(r.URL.Path, "/api/sources/"): g.handleSourcesAPI(w, r) case r.URL.Path == "/api/source_templates" || strings.HasPrefix(r.URL.Path, "/api/source_templates/"): diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index e6e36ac..3b97d94 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -989,6 +989,16 @@ mModels: "模型列表", mAddModel: "+ 模型", mMeta: "Meta", + discScan: "自动探测模型", + discScanning: "探测中…", + discHint: "填好 URL 和密钥后点「自动探测模型」,网关会拉取上游的模型列表并填入下方;仍需点保存才会生效。", + discNeedUrl: "请先填写 URL", + discEmpty: "上游返回的模型列表为空", + discFound: "已找到 %d 个模型", + discChatN: "对话 %d 个", + discImageN: "图像 %d 个", + discUnsaved: "尚未保存,点保存后生效", + discFailed: "探测失败", mSave: "保存", mCancel: "取消", toastCopied: "已复制", @@ -1281,6 +1291,16 @@ mModels: "Models", mAddModel: "+ model", mMeta: "Meta", + discScan: "Auto-discover", + discScanning: "Scanning…", + discHint: "Fill in the URL and key, then press Auto-discover: the gateway fetches the upstream catalog and fills the list below. You still have to save.", + discNeedUrl: "Enter the base URL first", + discEmpty: "The upstream returned an empty model list", + discFound: "Found %d models", + discChatN: "%d chat", + discImageN: "%d image", + discUnsaved: "not saved yet — press Save", + discFailed: "Discovery failed", mSave: "Save", mCancel: "Cancel", toastCopied: "Copied", @@ -3134,6 +3154,7 @@ recsState.keyNames = keyNames || {}; .join("") + ""; wrap.innerHTML = `

${esc(name ? t("modalEdit") + ": " + name : t("modalNew"))} + ${!name ? `` : ""}

@@ -3160,6 +3181,7 @@ recsState.keyNames = keyNames || {};
+
${t("discHint")}
@@ -3208,6 +3230,83 @@ recsState.keyNames = keyNames || {}; modelRow({ id: "", priority: 0, kind: "chat" }, div.children.length), ); } + // srcDiscover asks the gateway to probe the upstream for its catalog and + // fills the model list from the answer. It never writes anything: the + // operator still has to press Save, because a wrong base_url produces a + // plausible-looking but useless source, and writing automatically would + // overwrite a working configuration with it. + async function srcDiscover() { + const urlEl = $("#s-url"); + const keyEl = $("#s-key"); + const baseUrl = (urlEl && urlEl.value.trim()) || ""; + const apiKey = (keyEl && keyEl.value.trim()) || ""; + const hint = $("#s-disc-hint"); + const say = (msg) => { + if (hint) hint.textContent = msg; + }; + if (!baseUrl) { + toast(t("discNeedUrl")); + return; + } + const btn = document.querySelector('button[onclick="srcDiscover()"]'); + const restore = btn ? btn.textContent : null; + if (btn) { + btn.disabled = true; + btn.textContent = t("discScanning"); + } + say(t("discScanning")); + try { + const r = await api("/api/sources/discover", { + method: "POST", + body: { base_url: baseUrl, api_key: apiKey }, + }); + // The server normalized the URL form. Showing the operator exactly + // what will be saved is the whole point of probing server-side + // instead of guessing in the browser. + if (r.base_url && urlEl) urlEl.value = r.base_url; + if (r.endpoint && $("#s-ep")) $("#s-ep").value = r.endpoint; + + // Array.isArray, not a truthy check: a JSON null here would make + // .concat fail and leave the form looking empty with no error. + const chat = Array.isArray(r.models) ? r.models : []; + const image = Array.isArray(r.image_models) ? r.image_models : []; + const all = chat.concat(image); + if (!all.length) { + say(t("discEmpty")); + return; + } + const box = $("#s-models"); + if (box) { + box.innerHTML = all + .map((id, i) => + modelRow( + { + id: id, + priority: image.indexOf(id) >= 0 ? 70 : 50, + kind: image.indexOf(id) >= 0 ? "image" : "chat", + }, + i, + ), + ) + .join(""); + } + const bits = [t("discFound").replace("%d", String(all.length))]; + if (chat.length) bits.push(t("discChatN").replace("%d", String(chat.length))); + if (image.length) bits.push(t("discImageN").replace("%d", String(image.length))); + if (r.note) bits.push(r.note); + for (const w of Array.isArray(r.warnings) ? r.warnings : []) bits.push(w); + bits.push(t("discUnsaved")); + say(bits.join(" · ")); + } catch (e) { + say(t("discFailed") + (e && e.message ? ": " + e.message : "")); + } finally { + if (btn) { + btn.disabled = false; + btn.textContent = restore; + } + } + } + async function saveSource(btn) { const models = [...document.querySelectorAll("#s-models .model-row")] .map((row) => ({ @@ -3481,6 +3580,7 @@ recsState.keyNames = keyNames || {};
+
${t("discHint")}