feat(gateway): 源自动探测模型列表

只需填 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 个"存活",
实际是零个测试被执行——判据分不清"通过"和"没跑"就不是判据。
This commit is contained in:
root
2026-10-06 07:57:12 +08:00
committed by JianFeeeee
parent 94c1e7688f
commit 3860c7381f
8 changed files with 2019 additions and 0 deletions

View File

@ -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"},
},

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}
}

View File

@ -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{`<html>login</html>`, `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)
}
})
}

View File

@ -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": `<html><body>Sign in</body></html>`,
"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)
}
}
}

View File

@ -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/"):

View File

@ -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("") +
"</select>";
wrap.innerHTML = `<div class="card"><h2><span>${esc(name ? t("modalEdit") + ": " + name : t("modalNew"))}</span><span class="grow"></span>
<button class="ghost small" onclick="srcDiscover()">${t("discScan")}</button>
${!name ? `<button class="ghost small" onclick="srcFromTemplate()">${t("tplFrom")}</button>` : ""}
<button class="ghost small" onclick="srcSaveAsTemplate()">${t("tplSave")}</button></h2>
<div class="row">
@ -3160,6 +3181,7 @@ recsState.keyNames = keyNames || {};
</div>
<label>${t("mModels")}</label>
<div id="s-models"></div>
<div class="hint" id="s-disc-hint">${t("discHint")}</div>
<button class="ghost small" onclick="addModelRow()">${t("mAddModel")}</button>
<label>${t("mMeta")}</label>
<textarea id="s-meta" style="min-height:80px" placeholder='{"app_id":"x","app_secret":"y"}'>${esc(JSON.stringify(s.meta || {}, null, 2))}</textarea>
@ -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 || {};
</div>
<label>${t("mModels")}</label>
<div id="s-models"></div>
<div class="hint" id="s-disc-hint-tpl">${t("discHint")}</div>
<button class="ghost small" onclick="addModelRow()">${t("mAddModel")}</button>
<label>${t("mMeta")}</label>
<textarea id="s-meta" style="min-height:80px" placeholder='{"app_id":"x","app_secret":"y"}'>${esc(JSON.stringify(s.meta || {}, null, 2))}</textarea>