mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 17:57:23 +00:00
Compare commits
43 Commits
fix/channe
...
v1.9.7
| Author | SHA1 | Date | |
|---|---|---|---|
| c96e6051ab | |||
| 82424564b6 | |||
| d1e7fd3e77 | |||
| 8909096d80 | |||
| f6561a3809 | |||
| 2e6cc60da6 | |||
| 92aa3d6a9b | |||
| 8aa06574fc | |||
| cdd1fcb529 | |||
| 0d9fb26244 | |||
| 42eef98395 | |||
| 86ba4f57a0 | |||
| 4826837f9c | |||
| 2cf7808e63 | |||
| aaad52acc1 | |||
| 06f8c5dba4 | |||
| 4a0a1f96e4 | |||
| ac9746ec83 | |||
| 2272b4ecbd | |||
| ff8afaa43d | |||
| e5af4ec34c | |||
| 360a2a8644 | |||
| 2fd536e99b | |||
| 6e63e4761d | |||
| 37545b6dbb | |||
| 314f4da8f2 | |||
| b93799a919 | |||
| c36200b78b | |||
| ba6de89d57 | |||
| 3b28fde22e | |||
| 9823773330 | |||
| 5eef5ad635 | |||
| 40636abcc2 | |||
| 22c9215b02 | |||
| e76e1623e4 | |||
| 6448934096 | |||
| be30ba01b3 | |||
| 82d4e30b9f | |||
| c5079747b7 | |||
| 5a3b5d357c | |||
| a9d1bdeafb | |||
| d547bed457 | |||
| 31090fec81 |
41
README.md
41
README.md
@ -150,6 +150,47 @@ sources:
|
||||
timeout: 120s # 请求超时,默认 120s
|
||||
```
|
||||
|
||||
|
||||
### 自动探测模型列表
|
||||
|
||||
不想手抄模型 id 时,只填 URL 和 key,让网关去扫:
|
||||
|
||||
```bash
|
||||
# 只探测,不写任何东西
|
||||
curl -X POST http://127.0.0.1:8080/api/sources/discover \
|
||||
-H "Authorization: Bearer sk-gw-…" -H "Content-Type: application/json" \
|
||||
-d '{"base_url":"https://api.vendor.com/v1","api_key":"sk-…"}'
|
||||
# → {"ok":true,"base_url":"…","endpoint":"","models":["gpt-x",…],
|
||||
# "image_models":["gpt-image-2"],"models_path":"/models","latency_ms":1387,…}
|
||||
|
||||
# 探测后直接落库
|
||||
curl -X POST http://127.0.0.1:8080/api/sources/discover/apply \
|
||||
-H "Authorization: Bearer sk-gw-…" -H "Content-Type: application/json" \
|
||||
-d '{"base_url":"https://api.vendor.com/v1","api_key":"sk-…","name":"vendor"}'
|
||||
```
|
||||
|
||||
WebUI 源编辑框里也有「自动探测模型」按钮:填好 URL 和 key 点一下,列表自动
|
||||
填好,仍需点保存才生效。
|
||||
|
||||
几点值得知道:
|
||||
|
||||
- **URL 形态不用猜。** 厂商对「`/v1` 放 base 还是放 path」没有共识,探测会
|
||||
依次尝试若干候选并返回答对的那个,同时给出归一化后的 `base_url` +
|
||||
`endpoint`(`endpoint` 平时留空,因为 openai adapter 自带 `/chat/completions`)。
|
||||
- **`image_endpoint` 是另一套规则。** 网关的图像默认路径是**绝对**的
|
||||
`/v1/images/generations`,而 chat 默认是相对的。所以 `base_url` 带 `/v1` 时
|
||||
必须显式给 `image_endpoint: /images/generations`,否则图像请求 404。
|
||||
探测会替你填好。
|
||||
- **图像模型只看上游自己声明的 modality**,绝不从模型名猜。`gpt-image-2`
|
||||
这类 id 也出现在同时提供对话服务的目录上,猜错会让 AUTO 链选中一个只支持
|
||||
图像生成的模型,其后每一层都被它拖垮。
|
||||
- **不会逐模型探活。** 34 个模型的目录跑一轮要花钱且耗时数分钟,`/models`
|
||||
是足够便宜的信号;模型是否真能应答留给正常请求去发现。
|
||||
- **对已存在的源,必须显式 `replace_models: true`** 才会覆盖模型列表。重新
|
||||
探测顺手抹掉为 AUTO 链手工调过的 priority,是最难排查的一类数据丢失。
|
||||
- **`api_key` 留空表示沿用已存值**,不会把凭据清空。
|
||||
|
||||
|
||||
`config.yaml`、`runtime.json`、`master.key`、`adapters/` 均不提交版本库(见
|
||||
`.gitignore`),请随备份带走——`master.key` 丢失后运行时密文无法解密。
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "modelrouter-gui",
|
||||
"version": "1.9.4",
|
||||
"version": "1.9.7",
|
||||
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
|
||||
"author": "ModelRouter",
|
||||
"main": "main.js",
|
||||
|
||||
@ -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"},
|
||||
},
|
||||
|
||||
@ -256,17 +256,17 @@ func (g *Gateway) checkQuota(ctx context.Context, model string) *quotaRejection
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// A scope that simply does not list the model is a different problem from
|
||||
// a quota being exhausted, and the message has to say which: telling an
|
||||
// operator "not allowed for this key" when they configured a quota sends
|
||||
// them to the wrong setting. AUTO is checked here before the chain is even
|
||||
// consulted, so this is the message that surfaces for AUTO.
|
||||
if isAuto(model) {
|
||||
return "aRejection{msg: fmt.Sprintf(
|
||||
"model %q is not in this key's model scope, so it cannot use AUTO; "+
|
||||
"add %q to the key's models or remove the scope restriction", model, model)}
|
||||
}
|
||||
return "aRejection{msg: fmt.Sprintf("model %q is not allowed for this key", model)}
|
||||
// A scope that simply does not list the model is a different problem from
|
||||
// a quota being exhausted, and the message has to say which: telling an
|
||||
// operator "not allowed for this key" when they configured a quota sends
|
||||
// them to the wrong setting. AUTO is checked here before the chain is even
|
||||
// consulted, so this is the message that surfaces for AUTO.
|
||||
if isAuto(model) {
|
||||
return "aRejection{msg: fmt.Sprintf(
|
||||
"model %q is not in this key's model scope, so it cannot use AUTO; "+
|
||||
"add %q to the key's models or remove the scope restriction", model, model)}
|
||||
}
|
||||
return "aRejection{msg: fmt.Sprintf("model %q is not allowed for this key", model)}
|
||||
}
|
||||
|
||||
// quotaWindowSuffix describes a quota's reset window for an error message, so
|
||||
@ -1059,9 +1059,9 @@ func (g *Gateway) fireEnd(rec *Req) {
|
||||
// A SUBSET of completion_tokens, not an extra. Billing plugins that
|
||||
// add it to completion would double-charge every thinking token.
|
||||
"reasoning_tokens": rec.Reasoning,
|
||||
"image_count": rec.ImageCount,
|
||||
"error": rec.Err,
|
||||
"time": rec.Time,
|
||||
"image_count": rec.ImageCount,
|
||||
"error": rec.Err,
|
||||
"time": rec.Time,
|
||||
// chain_walk: the AUTO tier-by-tier trace, when the request went
|
||||
// through the chain. Empty for a direct request and for a gateway with
|
||||
// no plugins loaded. Absent rather than empty so a plugin can tell
|
||||
@ -1215,20 +1215,7 @@ func (g *Gateway) pumpStream(w http.ResponseWriter, rec *Req, chunks <-chan type
|
||||
var tut *types.TokenUsage
|
||||
if lastUsage != nil {
|
||||
tut = lastUsage
|
||||
// Write the upstream's cache accounting back onto the request record
|
||||
// so the audit trail carries the hit/miss split for streaming too.
|
||||
if d := tut.PromptTokensDetails; d != nil {
|
||||
rec.CacheReported = true
|
||||
if d.CachedTokens > 0 {
|
||||
rec.CacheHit = int64(d.CachedTokens)
|
||||
}
|
||||
} else if tut.PromptCacheHit > 0 {
|
||||
rec.CacheHit = int64(tut.PromptCacheHit)
|
||||
rec.CacheReported = true
|
||||
}
|
||||
if tut.PromptCacheMiss > 0 {
|
||||
rec.CacheMiss = int64(tut.PromptCacheMiss)
|
||||
}
|
||||
applyStreamUsageDetails(rec, lastUsage)
|
||||
} else if rec.Prompt+rec.Compl > 0 {
|
||||
u := types.TokenUsage{
|
||||
Prompt: int(rec.Prompt),
|
||||
@ -1459,3 +1446,39 @@ func (g *Gateway) handleImage(w http.ResponseWriter, r *http.Request) {
|
||||
Data: resp.ImageData,
|
||||
})
|
||||
}
|
||||
|
||||
// applyStreamUsageDetails mirrors the upstream's reasoning and cache numbers
|
||||
// onto a STREAMED request's audit record.
|
||||
//
|
||||
// It exists as a named function because this was inline in the streaming handler,
|
||||
// where it was untestable — and untested it stayed wrong. The non-streaming
|
||||
// recorder read completion_tokens_details.reasoning_tokens; this one did not. So
|
||||
// a source's streamed requests recorded completion and no reasoning at all while
|
||||
// its non-streamed calls recorded both. Nothing failed and no test was red: the
|
||||
// audit trail was simply quieter than it should have been.
|
||||
//
|
||||
// Found by reading the production audit file after wiring a reasoning-heavy
|
||||
// source: completion=8166 with reasoning absent, on every streamed request.
|
||||
//
|
||||
// Reasoning is a SUBSET of completion, never an addition to it. See
|
||||
// recordChatUsage for why that matters to billing.
|
||||
func applyStreamUsageDetails(rec *Req, u *types.TokenUsage) {
|
||||
if d := u.CompletionTokensDetails; d != nil && d.ReasoningTokens > 0 {
|
||||
rec.Reasoning = int64(d.ReasoningTokens)
|
||||
}
|
||||
// Cache accounting is here for the same reason it is kept apart: "zero hits
|
||||
// because nothing was cached" and "this provider never reports caching" are
|
||||
// the same number and mean opposite things.
|
||||
if d := u.PromptTokensDetails; d != nil {
|
||||
rec.CacheReported = true
|
||||
if d.CachedTokens > 0 {
|
||||
rec.CacheHit = int64(d.CachedTokens)
|
||||
}
|
||||
} else if u.PromptCacheHit > 0 {
|
||||
rec.CacheHit = int64(u.PromptCacheHit)
|
||||
rec.CacheReported = true
|
||||
}
|
||||
if u.PromptCacheMiss > 0 {
|
||||
rec.CacheMiss = int64(u.PromptCacheMiss)
|
||||
}
|
||||
}
|
||||
|
||||
@ -69,3 +69,92 @@ func TestRecordChatUsageReasoningAbsentStaysZero(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamedReasoningIsRecordedOnTheRequest is the streaming counterpart.
|
||||
//
|
||||
// TestRecordChatUsageKeepsReasoningTokens covers the non-streaming path only,
|
||||
// and for a long time the streamed path dropped the number: the non-streaming
|
||||
// recorder read TokenUsage.CompletionTokensDetails, while the streaming one read
|
||||
// only Prompt and Completion from the terminal usage chunk. (Cache accounting was
|
||||
// mirrored; reasoning simply never was.)
|
||||
//
|
||||
// The symptom is invisible in the response — the client sees a correct answer
|
||||
// either way. It only shows in the audit trail, where a reasoning-heavy source's
|
||||
// streamed requests recorded completion=8166 and no reasoning at all. Found by
|
||||
// reading the production audit file, not by a failing test, which is exactly why
|
||||
// the property is pinned here.
|
||||
func TestStreamedReasoningIsRecordedOnTheRequest(t *testing.T) {
|
||||
rec := &Req{}
|
||||
applyStreamUsageDetails(rec, &types.TokenUsage{
|
||||
Prompt: 159579,
|
||||
Completion: 751,
|
||||
Total: 160330,
|
||||
CompletionTokensDetails: &types.CompletionTokensDetails{
|
||||
ReasoningTokens: 512,
|
||||
},
|
||||
})
|
||||
if rec.Reasoning != 512 {
|
||||
t.Errorf("streamed Reasoning = %d, want 512 — a streamed request records no "+
|
||||
"reasoning, so a pricing rule that splits thinking from output silently "+
|
||||
"bills it as ordinary output", rec.Reasoning)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamedReasoningIsNeverAddedToCompletion pins the SUBSET relation in the
|
||||
// streaming path specifically. A presence check would pass if someone wrote
|
||||
// rec.Compl += reasoning, and billing would then charge every thinking token
|
||||
// twice.
|
||||
func TestStreamedReasoningIsNeverAddedToCompletion(t *testing.T) {
|
||||
rec := &Req{}
|
||||
rec.Compl = 751 // set upstream of the call, as the streaming handler does
|
||||
applyStreamUsageDetails(rec, &types.TokenUsage{
|
||||
Completion: 751,
|
||||
CompletionTokensDetails: &types.CompletionTokensDetails{ReasoningTokens: 512},
|
||||
})
|
||||
if rec.Compl != 751 {
|
||||
t.Errorf("Compl = %d, want 751 unchanged — reasoning is a subset, not an addition", rec.Compl)
|
||||
}
|
||||
if rec.Reasoning >= rec.Compl {
|
||||
t.Errorf("Reasoning (%d) must be a SUBSET of Compl (%d)", rec.Reasoning, rec.Compl)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamedUsageWithoutReasoningLeavesItUnset: an upstream that reports no
|
||||
// reasoning must not be turned into a reported zero. In the audit trail 0 means
|
||||
// "not reported", and a plugin cannot tell that from "no reasoning happened".
|
||||
func TestStreamedUsageWithoutReasoningLeavesItUnset(t *testing.T) {
|
||||
rec := &Req{}
|
||||
applyStreamUsageDetails(rec, &types.TokenUsage{Prompt: 10, Completion: 4, Total: 14})
|
||||
if rec.Reasoning != 0 {
|
||||
t.Errorf("Reasoning = %d for an upstream that reported none", rec.Reasoning)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamedCacheAccountingStillWorks is a regression guard for the block the
|
||||
// reasoning lines were added to. They share a function, so a future edit that
|
||||
// short-circuits one would break the other — silently, since a missing cache
|
||||
// number reads as "nothing was cached".
|
||||
func TestStreamedCacheAccountingStillWorks(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
u types.TokenUsage
|
||||
hit int64
|
||||
miss int64
|
||||
rep bool
|
||||
}{
|
||||
{"details object", types.TokenUsage{
|
||||
PromptTokensDetails: &types.PromptTokensDetails{CachedTokens: 4096},
|
||||
}, 4096, 0, true},
|
||||
{"legacy hit/miss", types.TokenUsage{
|
||||
PromptCacheHit: 2048, PromptCacheMiss: 512,
|
||||
}, 2048, 512, true},
|
||||
{"nothing reported", types.TokenUsage{}, 0, 0, false},
|
||||
} {
|
||||
rec := &Req{}
|
||||
applyStreamUsageDetails(rec, &c.u)
|
||||
if rec.CacheHit != c.hit || rec.CacheMiss != c.miss || rec.CacheReported != c.rep {
|
||||
t.Errorf("%s: hit=%d miss=%d reported=%v; want %d/%d/%v",
|
||||
c.name, rec.CacheHit, rec.CacheMiss, rec.CacheReported, c.hit, c.miss, c.rep)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
749
internal/gateway/discover.go
Normal file
749
internal/gateway/discover.go
Normal file
@ -0,0 +1,749 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"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"`
|
||||
// ImageEndpoint is reported separately because the gateway composes it
|
||||
// differently: Provider.ImageURL is BaseURL + ImageEndpoint(), and the
|
||||
// default ImageEndpoint is the ABSOLUTE path "/v1/images/generations".
|
||||
// Chat's default is the relative "/chat/completions". So a base_url that
|
||||
// already ends in /v1 produces a working chat URL and a broken image URL
|
||||
// ("/v1/v1/images/generations") — which is exactly the 404 seen on the
|
||||
// first vela configuration, and which discovery has to correct.
|
||||
ImageEndpoint string `json:"image_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
|
||||
}
|
||||
|
||||
// discoveryFailure carries the HTTP status the caller should answer with.
|
||||
//
|
||||
// The status is part of the error rather than left to the handler because the
|
||||
// whole point of classifying a failure is that the operator must not have to
|
||||
// guess whether the key, the endpoint, or the vendor is at fault.
|
||||
type discoveryFailure struct {
|
||||
msg string
|
||||
status int
|
||||
}
|
||||
|
||||
func (e *discoveryFailure) Error() string { return e.msg }
|
||||
|
||||
// StatusOrDefault reports the intended HTTP status.
|
||||
func (e *discoveryFailure) StatusOrDefault() int {
|
||||
if e.status == 0 {
|
||||
return http.StatusBadGateway
|
||||
}
|
||||
return e.status
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
// Deduplicate on the URL that will actually be requested, not on the
|
||||
// (base, path) pair. base "https://h/v1" + path "/models" and base "https://h"
|
||||
// + path "/v1/models" both request the same address, and listing it twice in
|
||||
// an error message makes the operator think two things were tried.
|
||||
seenURL := map[string]bool{}
|
||||
seenPair := map[string]bool{}
|
||||
var out []discoveryCandidate
|
||||
add := func(base, path string, prefer int) {
|
||||
pair := base + "|" + path
|
||||
if seenPair[pair] || seenURL[base+path] {
|
||||
return
|
||||
}
|
||||
seenPair[pair] = true
|
||||
seenURL[base+path] = true
|
||||
out = append(out, discoveryCandidate{baseURL: base, modelsPath: path, prefer: prefer})
|
||||
}
|
||||
|
||||
stripped := trimVersionSuffix(raw)
|
||||
hasVersion := stripped != raw
|
||||
|
||||
// Both catalog paths are always tried, whichever form the operator typed.
|
||||
//
|
||||
// The earlier version offered "/models" only when the base already carried a
|
||||
// version segment. That left a bare host ("https://api.vendor.com") with a
|
||||
// SINGLE candidate and therefore no fallback at all — so a vendor serving
|
||||
// its catalog at the bare "/models" was reported as unreachable. Every
|
||||
// test used a "/v1"-suffixed base and so never saw it; siliconflow's
|
||||
// "/v1/models" happens to answer, which is why the gap survived review.
|
||||
//
|
||||
// Preference only decides WHICH URL is reported when several work, never
|
||||
// which ones are attempted.
|
||||
//
|
||||
// Order matters: a base that already ends in a version segment must be
|
||||
// paired with the RELATIVE "/models" first, because pairing it with
|
||||
// "/v1/models" produces "…/v1/v1/models" — a URL that is 404 by construction.
|
||||
// Trying it first would spend a round trip on a certain miss and, worse,
|
||||
// make the reported "tried" list lead with nonsense.
|
||||
if hasVersion {
|
||||
add(raw, "/models", 0)
|
||||
add(raw, discoveryModelsPath, 1) // the doubled form; kept only as a last resort
|
||||
add(stripped, "/models", 2)
|
||||
add(stripped, discoveryModelsPath, 3)
|
||||
} else {
|
||||
add(raw, discoveryModelsPath, 0)
|
||||
add(raw, "/models", 1)
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// The version segment actually WORKED: prefer the models path's, then the
|
||||
// one the operator typed.
|
||||
version := inPath
|
||||
if version == "" && onBase == "" {
|
||||
if b := trimVersionSuffix(typedBase); b != typedBase && typedBase != "" {
|
||||
version = typedBase[len(b):]
|
||||
}
|
||||
}
|
||||
if version == "" {
|
||||
return "" // adapter default already composes
|
||||
}
|
||||
return version + "/chat/completions"
|
||||
}
|
||||
|
||||
// composeImageEndpoint derives the image endpoint to persist.
|
||||
//
|
||||
// Provider.ImageURL is `base_url + ImageEndpoint()`, and the built-in default
|
||||
// is the absolute path "/v1/images/generations" — unlike chat's relative
|
||||
// "/chat/completions". So the two defaults do NOT agree about who owns the
|
||||
// version segment, which is the trap this function exists to close:
|
||||
//
|
||||
// base "https://h" + "/v1/images/generations" → correct
|
||||
// base "https://h/v1" + "/v1/images/generations" → "/v1/v1/…" 404
|
||||
// base "https://h/v1" + "/images/generations" → "/v1/images/generations" correct
|
||||
//
|
||||
// The second row is not a theoretical guess. Measured against the real
|
||||
// amr-link endpoint with base_url "https://amr-link.open-design.ai/v1":
|
||||
//
|
||||
// …/v1/images/generations → 200 (the image is then generated; that call
|
||||
// takes minutes, which is the upstream's own
|
||||
// latency and not a routing problem)
|
||||
// …/v1/v1/images/generations → 404
|
||||
// …/images/generations → 404
|
||||
//
|
||||
// Leaving the default in place when the base already ends in a version is
|
||||
// therefore always wrong for such an upstream, and the version-relative form
|
||||
// is the one that composes.
|
||||
func composeImageEndpoint(persistedBase string) string {
|
||||
if b := trimVersionSuffix(persistedBase); b != persistedBase {
|
||||
return "/images/generations"
|
||||
}
|
||||
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
|
||||
var authErr string
|
||||
typedBase := strings.TrimRight(strings.TrimSpace(rawURL), "/")
|
||||
|
||||
var lastErr string
|
||||
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 {
|
||||
lastErr = classifyTransport(err)
|
||||
if lastErr == "" {
|
||||
lastErr = 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 {
|
||||
// An auth rejection is the operator's key being wrong, not the
|
||||
// upstream being down. Keep it: it is the single most common
|
||||
// failure and 502 would send them to the wrong place.
|
||||
if af := classifyAuthStatus(resp.StatusCode, body); af != "" {
|
||||
authErr = af
|
||||
continue
|
||||
}
|
||||
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)
|
||||
res.ImageEndpoint = composeImageEndpoint(c.baseURL)
|
||||
|
||||
// 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, buildDiscoveryError(tried, authErr, firstErr, lastErr)
|
||||
}
|
||||
|
||||
// classifyAuthStatus turns a 401/403 from the catalog endpoint into a message
|
||||
// that names the credential as the cause.
|
||||
//
|
||||
// This distinction is the whole point: a wrong key and an unreachable vendor
|
||||
// are the two most common discovery failures, and they need opposite actions
|
||||
// (re-type the key vs. go check the vendor). Reporting both as "upstream
|
||||
// error" makes the operator guess.
|
||||
func classifyAuthStatus(status int, body []byte) string {
|
||||
if status != http.StatusUnauthorized && status != http.StatusForbidden {
|
||||
return ""
|
||||
}
|
||||
snippet := strings.TrimSpace(string(body))
|
||||
// Vendors put the reason in wildly different shapes; a compact quote is
|
||||
// more useful than a parse attempt that fails on most of them.
|
||||
snippet = strings.Join(strings.Fields(snippet), " ")
|
||||
if len(snippet) > 200 {
|
||||
snippet = snippet[:200] + "…"
|
||||
}
|
||||
return fmt.Sprintf("upstream rejected the api key (HTTP %d): %s — "+
|
||||
"check the key, not the endpoint", status, snippet)
|
||||
}
|
||||
|
||||
// classifyTransport turns low-level network failures into something an
|
||||
// operator can act on. An empty result means "no specific advice", and the
|
||||
// caller keeps the raw error.
|
||||
func classifyTransport(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
msg := err.Error()
|
||||
lower := strings.ToLower(msg)
|
||||
switch {
|
||||
case strings.Contains(lower, "no such host"):
|
||||
return "hostname does not resolve: " + msg
|
||||
case strings.Contains(lower, "certificate"), strings.Contains(lower, "tls"):
|
||||
return "TLS verification failed: " + msg
|
||||
case strings.Contains(lower, "connection refused"):
|
||||
return "connection refused: " + msg
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// buildDiscoveryError assembles the final error, preferring the most specific
|
||||
// diagnosis available. Every URL tried is listed: an operator staring at a
|
||||
// failed probe needs to know the search actually happened, not just the last
|
||||
// attempt.
|
||||
func buildDiscoveryError(tried []string, authErr, firstErr, lastErr string) error {
|
||||
triedPart := "no model catalog found"
|
||||
if len(tried) > 0 {
|
||||
triedPart = "no model catalog found; tried " + strings.Join(tried, ", ")
|
||||
}
|
||||
if authErr != "" {
|
||||
return &discoveryFailure{status: http.StatusUnauthorized, msg: authErr + " (" + triedPart + ")"}
|
||||
}
|
||||
detail := firstErr
|
||||
if detail == "" {
|
||||
detail = lastErr
|
||||
}
|
||||
if detail == "" {
|
||||
return errors.New(triedPart)
|
||||
}
|
||||
if lastErr != "" && lastErr != firstErr {
|
||||
detail += " (last: " + lastErr + ")"
|
||||
}
|
||||
return &discoveryFailure{status: http.StatusBadGateway, msg: triedPart + "; " + detail}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
403
internal/gateway/discover_api.go
Normal file
403
internal/gateway/discover_api.go
Normal file
@ -0,0 +1,403 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"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"`
|
||||
// ImageEndpoint is required whenever base_url carries a version segment:
|
||||
// the gateway's default image path is the ABSOLUTE "/v1/images/generations"
|
||||
// while chat's default is the relative "/chat/completions", so the same
|
||||
// base_url that yields a working chat URL yields a 404 image URL.
|
||||
ImageEndpoint string `json:"image_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 {
|
||||
// A rejected key must not be reported as an upstream outage: the two
|
||||
// need opposite actions from the operator, and 502 sends them to the
|
||||
// wrong one.
|
||||
status := http.StatusBadGateway
|
||||
var f *discoveryFailure
|
||||
if errors.As(err, &f) {
|
||||
status = f.StatusOrDefault()
|
||||
}
|
||||
writeError(w, status, "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,
|
||||
ImageEndpoint: res.ImageEndpoint,
|
||||
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,
|
||||
ImageEndpoint: res.ImageEndpoint,
|
||||
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
|
||||
// image_endpoint is NOT inherited when discovery supplied one:
|
||||
// the stored value was correct for the OLD base_url, and the base
|
||||
// is exactly what may have changed. Inheriting it would keep a
|
||||
// stale value that 404s against the new base.
|
||||
if res.ImageEndpoint == "" {
|
||||
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
|
||||
}
|
||||
314
internal/gateway/discover_api_test.go
Normal file
314
internal/gateway/discover_api_test.go
Normal file
@ -0,0 +1,314 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"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")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoverModels_WrongKeyIsUnauthorizedNotBadGateway pins the distinction
|
||||
// that decides whether the operator knows what to do next.
|
||||
//
|
||||
// A rejected key and an unreachable vendor are the two most common discovery
|
||||
// failures, and they need opposite actions: re-type the key, versus go check the
|
||||
// vendor's status page. Both used to surface as one generic error, so the
|
||||
// operator had to guess.
|
||||
func TestDiscoverModels_WrongKeyIsUnauthorizedNotBadGateway(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":{"message":"invalid api key","code":"invalid_api_key"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := DiscoverModels(context.Background(), srv.URL+"/v1", "sk-wrong")
|
||||
if err == nil {
|
||||
t.Fatal("expected a failure against a 401 endpoint")
|
||||
}
|
||||
var f *discoveryFailure
|
||||
if !errors.As(err, &f) {
|
||||
t.Fatalf("error should be classified, got %T: %v", err, err)
|
||||
}
|
||||
if got := f.StatusOrDefault(); got != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want %d — a wrong key must not read as an upstream outage",
|
||||
got, http.StatusUnauthorized)
|
||||
}
|
||||
// The message has to name which credential is at fault, or the status code
|
||||
// alone still sends the operator to the wrong place.
|
||||
if !strings.Contains(strings.ToLower(f.Error()), "api key") {
|
||||
t.Errorf("message %q should name the api key as the cause", f.Error())
|
||||
}
|
||||
if !strings.Contains(f.Error(), "/v1/models") {
|
||||
t.Errorf("message %q should list what was tried", f.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoverModels_UnreachableStaysBadGateway: the classification must not
|
||||
// over-correct — an unresolvable host is still the vendor's or the network's
|
||||
// problem, not the key's.
|
||||
func TestDiscoverModels_UnreachableStaysBadGateway(t *testing.T) {
|
||||
_, err := DiscoverModels(context.Background(), "https://no-such-host-xyz.invalid/v1", "k")
|
||||
if err == nil {
|
||||
t.Fatal("expected a failure for an unresolvable host")
|
||||
}
|
||||
var f *discoveryFailure
|
||||
if !errors.As(err, &f) {
|
||||
t.Fatalf("error should be classified, got %T", err)
|
||||
}
|
||||
if got := f.StatusOrDefault(); got != http.StatusBadGateway {
|
||||
t.Errorf("status = %d, want %d", got, http.StatusBadGateway)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(f.Error()), "resolve") {
|
||||
t.Errorf("message %q should say the hostname does not resolve", f.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoveryCandidates_NoDuplicateRequests: base "https://h/v1" with path
|
||||
// "/models" and base "https://h" with path "/v1/models" request the SAME URL.
|
||||
// Listing it twice in a failure message reads as "two things were tried" when
|
||||
// only one was.
|
||||
func TestDiscoveryCandidates_NoDuplicateRequests(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for _, c := range discoveryCandidates("https://h/v1") {
|
||||
url := c.baseURL + c.modelsPath
|
||||
if seen[url] {
|
||||
t.Errorf("candidate %q would be requested twice", url)
|
||||
}
|
||||
seen[url] = true
|
||||
}
|
||||
if len(seen) < 2 {
|
||||
t.Errorf("only %d distinct URLs; the search collapsed", len(seen))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoverModels_FailureListsEachURLOnce is the end-to-end form of the same
|
||||
// rule, on the error text an operator actually reads.
|
||||
func TestDiscoverModels_FailureListsEachURLOnce(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")
|
||||
}
|
||||
msg := err.Error()
|
||||
needle := srv.URL + "/v1/models"
|
||||
if n := strings.Count(msg, needle); n > 1 {
|
||||
t.Errorf("URL %s appears %d times in %q", needle, n, msg)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
153
internal/gateway/discover_endpoint_test.go
Normal file
153
internal/gateway/discover_endpoint_test.go
Normal file
@ -0,0 +1,153 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestComposeImageEndpoint_NoVersionBaseLeavesDefault is the counterpart of the
|
||||
// /v1 case: when the base carries no version segment, discovery must emit "" so
|
||||
// the gateway's own default "/v1/images/generations" composes.
|
||||
//
|
||||
// This is the shape every currently-working source uses — siliconflow, the only
|
||||
// source in production with kind:image, has base_url
|
||||
// "https://api.siliconflow.cn" — so writing a non-empty value here would break
|
||||
// exactly the configuration that is known to work today.
|
||||
func TestComposeImageEndpoint_NoVersionBaseLeavesDefault(t *testing.T) {
|
||||
if got := composeImageEndpoint("https://api.siliconflow.cn"); got != "" {
|
||||
t.Errorf("composeImageEndpoint = %q, want \"\" so the built-in default composes", got)
|
||||
}
|
||||
// And the composition it implies must be the vendor's real path.
|
||||
base := "https://api.siliconflow.cn"
|
||||
composed := base + "/v1/images/generations"
|
||||
if !strings.HasSuffix(composed, "/v1/images/generations") {
|
||||
t.Errorf("composed URL %q lost the image path", composed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscovery_BareHostNeedsVersionOnEndpointWhenCatalogIsVersioned
|
||||
// distinguishes two cases that look identical from the outside.
|
||||
//
|
||||
// bare host + catalog at "/models" → chat is "/chat/completions" (the
|
||||
// adapter default, no endpoint written)
|
||||
// bare host + catalog at "/v1/models" → chat is "/v1/chat/completions",
|
||||
// so an endpoint MUST be written
|
||||
//
|
||||
// The catalog path is the only evidence of whether the upstream wants the
|
||||
// version segment, and getting this backwards sends every chat request to a
|
||||
// path the vendor does not serve.
|
||||
func TestDiscovery_BareHostNeedsVersionOnEndpointWhenCatalogIsVersioned(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
catalogPath string
|
||||
wantEndpoint string
|
||||
wantImage string
|
||||
}{
|
||||
{
|
||||
name: "catalog at the bare /models needs no endpoint",
|
||||
catalogPath: "/models",
|
||||
wantEndpoint: "",
|
||||
wantImage: "",
|
||||
},
|
||||
{
|
||||
name: "catalog at /v1/models requires /v1 on the chat endpoint",
|
||||
catalogPath: "/v1/models",
|
||||
wantEndpoint: "/v1/chat/completions",
|
||||
wantImage: "",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != tc.catalogPath {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"data":[{"id":"m1"},{"id":"m2"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
res, err := DiscoverModels(context.Background(), srv.URL, "k")
|
||||
if err != nil {
|
||||
t.Fatalf("discovery failed: %v", err)
|
||||
}
|
||||
if res.BaseURL != srv.URL {
|
||||
t.Errorf("base = %q, want %q", res.BaseURL, srv.URL)
|
||||
}
|
||||
if res.Endpoint != tc.wantEndpoint {
|
||||
t.Errorf("endpoint = %q, want %q", res.Endpoint, tc.wantEndpoint)
|
||||
}
|
||||
if res.ImageEndpoint != tc.wantImage {
|
||||
t.Errorf("image_endpoint = %q, want %q", res.ImageEndpoint, tc.wantImage)
|
||||
}
|
||||
// Whatever it wrote, the chat URL must reach the same version
|
||||
// prefix the catalog was served under.
|
||||
ep := res.Endpoint
|
||||
if ep == "" {
|
||||
ep = "/chat/completions"
|
||||
}
|
||||
if got := res.BaseURL + ep; strings.Contains(got, "/v1/v1") {
|
||||
t.Errorf("chat URL %q has a doubled version", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestComposeEndpoints_BothFormsProduceWorkingURLs is the end-to-end
|
||||
// invariant across the two shapes, stated as the URLs the gateway will build.
|
||||
//
|
||||
// A base with a version and no image_endpoint produces "/v1/v1/…" — the exact
|
||||
// 404 observed before composeImageEndpoint existed. A base without one and a
|
||||
// non-empty image_endpoint produces a path missing the version. Both are
|
||||
// silent, expensive mistakes, so they are asserted literally.
|
||||
func TestComposeEndpoints_BothFormsProduceWorkingURLs(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
base string
|
||||
wantChat string
|
||||
wantImage string
|
||||
}{
|
||||
{
|
||||
name: "bare host: adapter and image defaults compose",
|
||||
base: "https://h",
|
||||
wantChat: "https://h/chat/completions",
|
||||
// openai's adapter endpoint is /chat/completions (verified in
|
||||
// internal/lua/adapters/openai.lua: adapter.endpoint).
|
||||
wantImage: "https://h/v1/images/generations",
|
||||
},
|
||||
{
|
||||
name: "versioned base: the version moves onto both endpoints",
|
||||
base: "https://h/v1",
|
||||
wantChat: "https://h/v1/chat/completions",
|
||||
wantImage: "https://h/v1/images/generations",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ep := composeEndpoint("/models", tc.base, tc.base)
|
||||
if ep == "" {
|
||||
ep = "/chat/completions" // the adapter default
|
||||
}
|
||||
iep := composeImageEndpoint(tc.base)
|
||||
if iep == "" {
|
||||
iep = "/v1/images/generations" // the gateway default
|
||||
}
|
||||
gotChat := tc.base + ep
|
||||
gotImage := tc.base + iep
|
||||
if gotChat != tc.wantChat {
|
||||
t.Errorf("chat URL = %q, want %q", gotChat, tc.wantChat)
|
||||
}
|
||||
if gotImage != tc.wantImage {
|
||||
t.Errorf("image URL = %q, want %q", gotImage, tc.wantImage)
|
||||
}
|
||||
for _, u := range []string{gotChat, gotImage} {
|
||||
if strings.Contains(u, "/v1/v1") {
|
||||
t.Errorf("%q has a doubled version segment", u)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
303
internal/gateway/discover_mutate_test.go
Normal file
303
internal/gateway/discover_mutate_test.go
Normal file
@ -0,0 +1,303 @@
|
||||
package gateway
|
||||
|
||||
// Real mutation injection: rewrites the IMPLEMENTATION on disk, re-runs the
|
||||
// suite, and requires it to go red.
|
||||
//
|
||||
// Why this exists and why it is a Go file rather than a shell script:
|
||||
//
|
||||
// The first version was a shell script. Its -run filter contained a regex that
|
||||
// the shell mangled, so every mutant reported "SURVIVED" while go test printed
|
||||
// "[no tests to run]". Four mutants looked like judgement failures and were
|
||||
// actually never executed. A judgement that cannot tell "tests passed" from
|
||||
// "no tests ran" is not a judgement.
|
||||
//
|
||||
// This version therefore refuses to report anything unless it has first proved
|
||||
// the filter selects a non-trivial number of tests (see baseline below), and it
|
||||
// treats "[no tests to run]" as a hard error rather than a green run.
|
||||
//
|
||||
// Run: go test -tags luajit ./internal/gateway/ -run TestRealMutants -v
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mutationFilter must select every assertion that guards the code below. It is
|
||||
// deliberately broad: a narrow filter is how mutants get to survive for free.
|
||||
const mutationFilter = "Test(ParseModelCatalog|DiscoveredModelKind|Discover|TrimVersion|VersionSegment|ComposeEndpoint|ComposeImageEndpoint|ComposeEndpoints|IsVersionSegment|ModelsToConfig|DiscoveryResult|DiscoveryCandidates|Discovery_BareHost)"
|
||||
|
||||
type mutation struct {
|
||||
name string
|
||||
from string
|
||||
to string
|
||||
}
|
||||
|
||||
func TestRealMutants(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("mutation campaign compiles the package repeatedly")
|
||||
}
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := filepath.Join(wd, "discover.go")
|
||||
orig, err := os.ReadFile(target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if werr := os.WriteFile(target, orig, 0o644); werr != nil {
|
||||
t.Fatalf("restore discover.go: %v", werr)
|
||||
}
|
||||
}()
|
||||
|
||||
// --- prove the harness is armed before trusting any verdict ---
|
||||
// A filter that selects nothing exits 0, which is indistinguishable from a
|
||||
// real pass. The only way to tell them apart is to compare against a filter
|
||||
// KNOWN to be empty: if that empty filter produces the same result as the
|
||||
// real one, the real one is probably empty too.
|
||||
//
|
||||
// Note go test does NOT print "[no tests to run]" for a wholly unmatched
|
||||
// filter (it prints that only when the package has other matching tests), so
|
||||
// the check has to compare outputs rather than look for a marker string.
|
||||
// Getting this backwards made the harness refuse to run at all.
|
||||
emptyOut := runSuite(wd, "TestZZZNoSuchTestExists")
|
||||
realOut := runSuite(wd, mutationFilter)
|
||||
if countTests(emptyOut) != 0 {
|
||||
t.Fatalf("a filter matching nothing selected %d tests; the harness's own probe is broken",
|
||||
countTests(emptyOut))
|
||||
}
|
||||
// Kept for the no-op check below: a mutant whose output matches the
|
||||
// baseline never changed behaviour, so it is an invalid mutation rather
|
||||
// than a gap in the tests.
|
||||
baseOut := realOut
|
||||
if strings.Contains(realOut, "[no tests to run]") {
|
||||
t.Fatalf("baseline selected no tests:\n%s", realOut)
|
||||
}
|
||||
baseCount := countTests(realOut)
|
||||
if baseCount < 25 {
|
||||
t.Fatalf("baseline ran only %d tests; the filter is too narrow to judge anything", baseCount)
|
||||
}
|
||||
if strings.Contains(realOut, "FAIL") {
|
||||
t.Logf("note: baseline is not green; kill verdicts remain valid")
|
||||
}
|
||||
t.Logf("baseline: %d tests selected, empty filter selects %d",
|
||||
baseCount, countTests(emptyOut))
|
||||
|
||||
muts := []mutation{
|
||||
{
|
||||
// Written as a condition that still READS modOut, because a
|
||||
// mutation that leaves a variable unused fails to compile — and a
|
||||
// build failure is not a judgement (see the harness note above).
|
||||
name: "image kind ignores upstream output modality",
|
||||
from: `if m.Capabilities.ImageGeneration || has(modOut, "image") {`,
|
||||
to: "if m.Capabilities.ImageGeneration || (len(modOut) > 0 && false) {",
|
||||
},
|
||||
{
|
||||
name: "kind defaults to image (guessing from names)",
|
||||
from: "\t}\n\treturn \"chat\"\n}\n\n// modalityList",
|
||||
to: "\t}\n\treturn \"image\"\n}\n\n// modalityList",
|
||||
},
|
||||
{
|
||||
name: "nested metadata no longer read",
|
||||
from: "\tcopyFields(m.Metadata) // nested first",
|
||||
to: "\tcopyFields(json.RawMessage(nil)) // mutant: nested metadata dropped",
|
||||
},
|
||||
{
|
||||
name: "non-catalog bodies accepted",
|
||||
from: "\tif trimmed == \"\" || trimmed == \"null\" {\n\t\treturn nil, fmt.Errorf(\"empty response\")\n\t}",
|
||||
to: "\tif false && (trimmed == \"\" || trimmed == \"null\") {\n\t\treturn nil, fmt.Errorf(\"empty response\")\n\t}",
|
||||
},
|
||||
{
|
||||
// Removing the bare "/models" candidate must be observable: a vendor
|
||||
// serving its catalog at the bare path would otherwise look
|
||||
// unreachable. The `len(...) >= 0` is a tautology that only exists to
|
||||
// keep the expression syntactically valid after the call is dropped.
|
||||
name: "URL fallback search removed",
|
||||
from: "\t\tadd(raw, \"/models\", 0)",
|
||||
to: "\t\tif len(\"/models\") >= 0 {\n\t\t\t_ = hasVersion\n\t\t}",
|
||||
},
|
||||
{
|
||||
name: "version segment never moved onto the chat endpoint",
|
||||
from: "\tif version == \"\" {\n\t\treturn \"\" // adapter default already composes\n\t}",
|
||||
to: "\tif version == \"\" || len(version) > 0 {\n\t\treturn \"\"\n\t}",
|
||||
},
|
||||
{
|
||||
name: "image endpoint never corrected (/v1/v1 regression)",
|
||||
from: "\tif b := trimVersionSuffix(persistedBase); b != persistedBase {\n\t\treturn \"/images/generations\"\n\t}",
|
||||
to: "\tif b := trimVersionSuffix(persistedBase); b == b && b != b {\n\t\treturn \"/images/generations\"\n\t}",
|
||||
},
|
||||
{
|
||||
name: "version test loosened so 'vision' counts as a version",
|
||||
from: "\tsuffix := strings.ToLower(seg[i:])",
|
||||
to: "\tsuffix := strings.ToLower(seg[i:])\n\tif len(suffix) >= 0 {\n\t\tsuffix = \"beta\"\n\t}",
|
||||
},
|
||||
{
|
||||
// The guard must be removable WITHOUT the mutant becoming a
|
||||
// semantic no-op. Two earlier attempts failed:
|
||||
//
|
||||
// `&& out.BaseURL != ""` — the guard this test exercises uses a
|
||||
// zero-value struct whose BaseURL is empty, so the added
|
||||
// condition made the mutant behave identically to the original.
|
||||
// `&& len(out.BaseURL) >= 0` — always true for any string, so the
|
||||
// mutant was STILL an identity transform.
|
||||
//
|
||||
// Reading the variable into a blank assignment keeps the compiler
|
||||
// happy without changing behaviour, which is what makes the
|
||||
// mutant observable.
|
||||
name: "nil slice guard removed from JSON output",
|
||||
from: "\tif out.ImageModels == nil {\n\t\tout.ImageModels = []string{}\n\t}",
|
||||
to: "\tif out.ImageModels == nil {\n\t\t_ = out.ImageModels\n\t}",
|
||||
},
|
||||
{
|
||||
name: "auth rejection no longer classified",
|
||||
from: "\tif status != http.StatusUnauthorized && status != http.StatusForbidden {\n\t\treturn \"\"\n\t}",
|
||||
to: "\tif status != http.StatusUnauthorized && status != http.StatusForbidden || len(body) >= 0 {\n\t\treturn \"\"\n\t}",
|
||||
},
|
||||
{
|
||||
name: "duplicate candidate URLs no longer collapsed",
|
||||
from: "\t\tif seenPair[pair] || seenURL[base+path] {",
|
||||
to: "\t\tif len(pair) > 0 && (seenPair[pair] || seenURL[base+path]) && false {",
|
||||
},
|
||||
}
|
||||
|
||||
survived := 0
|
||||
for _, m := range muts {
|
||||
t.Run(m.name, func(t *testing.T) {
|
||||
if !strings.Contains(string(orig), m.from) {
|
||||
t.Fatalf("anchor not found in discover.go — this mutation would be a no-op:\n%q", m.from)
|
||||
}
|
||||
mutated := strings.Replace(string(orig), m.from, m.to, 1)
|
||||
if mutated == string(orig) {
|
||||
t.Fatal("replacement changed nothing")
|
||||
}
|
||||
if werr := os.WriteFile(target, []byte(mutated), 0o644); werr != nil {
|
||||
t.Fatal(werr)
|
||||
}
|
||||
defer os.WriteFile(target, orig, 0o644)
|
||||
|
||||
out := runSuite(wd, mutationFilter)
|
||||
// A build failure kills EVERY mutant, so it would mask a broken
|
||||
// harness as a perfect score. Distinguish it from a real test
|
||||
// failure and fail loudly instead.
|
||||
if strings.Contains(out, "[build failed]") {
|
||||
t.Fatalf("mutant broke the build — that is not a judgement, it is a broken harness:\n%s", firstLines(out, 20))
|
||||
}
|
||||
if countTests(out) == 0 {
|
||||
t.Fatalf("filter selected nothing — the verdict would be meaningless")
|
||||
}
|
||||
if !strings.Contains(out, "FAIL") {
|
||||
// The mutant compiled and the suite stayed green. Before calling
|
||||
// this a survivor, check the mutation was not a no-op: an earlier
|
||||
// version reported four "survivors" that were really invalid
|
||||
// mutations whose added guard made them behaviourally identical
|
||||
// to the original. A silent no-op is a broken mutation, and
|
||||
// conflating the two sends the next reader hunting for a missing
|
||||
// test that does not exist.
|
||||
if behaviourOnly(out) == behaviourOnly(baseOut) {
|
||||
t.Fatalf("mutant is a no-op: the suite output is identical to the "+
|
||||
"baseline, so this mutation never changed behaviour. Fix the "+
|
||||
"mutation rather than looking for a missing test.\nmutant: %q", m.to)
|
||||
}
|
||||
survived++
|
||||
t.Errorf("SURVIVED %s — no test noticed this wrong implementation", m.name)
|
||||
return
|
||||
}
|
||||
t.Logf("KILL %s", m.name)
|
||||
})
|
||||
}
|
||||
if survived > 0 {
|
||||
t.Errorf("%d of %d mutants survived", survived, len(muts))
|
||||
}
|
||||
}
|
||||
|
||||
// runSuite invokes the go tool on this package with the given -run filter.
|
||||
//
|
||||
// The luajit build tag is mandatory: without it the package falls back to plain
|
||||
// Lua 5.1, billing.lua's `goto` fails to compile, and the whole package fails
|
||||
// to build — which the harness would misread as "every mutant killed".
|
||||
func runSuite(wd, filter string) string {
|
||||
tool := os.Getenv("GO_TEST_GO")
|
||||
if tool == "" {
|
||||
tool = "go"
|
||||
}
|
||||
cmd := exec.Command(tool, "test",
|
||||
"-tags", "luajit",
|
||||
".", "-run", filter, "-count=1", "-v")
|
||||
cmd.Dir = wd
|
||||
cmd.Env = os.Environ()
|
||||
out, _ := cmd.CombinedOutput()
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func countTests(out string) int {
|
||||
n := 0
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
// Subtests are indented; only count top-level ones so a single test
|
||||
// with a table of cases cannot inflate the count.
|
||||
if strings.HasPrefix(line, "=== RUN Test") && !strings.Contains(line, "/") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// behaviourOnly strips the parts of `go test -v` output that vary between runs
|
||||
// (timings, goroutine ids, temp paths) so two runs can be compared for equality.
|
||||
// If a mutant produces output identical to the baseline under this projection,
|
||||
// the mutation did not change observable behaviour and is therefore invalid.
|
||||
func behaviourOnly(out string) string {
|
||||
var kept []string
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
switch {
|
||||
// Keep only the outcome lines. Test NAMES are deliberately dropped:
|
||||
// two runs of the same suite can legitimately order or cache tests
|
||||
// differently, and keeping the names made every mutant look like it had
|
||||
// "changed behaviour" — which defeats the no-op detector entirely.
|
||||
case strings.HasPrefix(trimmed, "--- PASS"), strings.HasPrefix(trimmed, "--- FAIL"),
|
||||
strings.HasPrefix(trimmed, "--- SKIP"),
|
||||
strings.HasPrefix(trimmed, "ok "), trimmed == "FAIL",
|
||||
strings.Contains(trimmed, ".go:"):
|
||||
kept = append(kept, trimmed)
|
||||
}
|
||||
}
|
||||
// Counting outcomes is stronger than comparing the sequence: a mutation
|
||||
// that swaps which test fails still changes the multiset, and one that
|
||||
// changes nothing cannot.
|
||||
sort.Strings(kept)
|
||||
return strings.Join(kept, "\n")
|
||||
}
|
||||
|
||||
// firstLines trims output for an error message; a failing nested run can be
|
||||
// thousands of lines and the cause is always near the top.
|
||||
func firstLines(s string, n int) string {
|
||||
lines := strings.Split(s, "\n")
|
||||
if len(lines) > n {
|
||||
lines = lines[:n]
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// TestMutationFilterCoversEveryGuard keeps the filter honest. Dropping a group
|
||||
// from it silently disarms that area's mutants.
|
||||
func TestMutationFilterCoversEveryGuard(t *testing.T) {
|
||||
for _, group := range []string{
|
||||
"ParseModelCatalog", // nested metadata + non-catalog rejection
|
||||
"DiscoveredModelKind", // kind follows upstream metadata
|
||||
"Discover", // URL fallback, auth classification
|
||||
"ComposeEndpoint", // version segment exactly once (chat)
|
||||
"ComposeImageEndpoint",
|
||||
"ComposeEndpoints", // the composed URLs, both base shapes
|
||||
"IsVersionSegment", // "v1beta" vs "vision"
|
||||
"DiscoveryResult", // nil slices are arrays
|
||||
} {
|
||||
if !strings.Contains(mutationFilter, group) {
|
||||
t.Errorf("mutation filter omits %s; mutants there survive silently", group)
|
||||
}
|
||||
}
|
||||
}
|
||||
171
internal/gateway/discover_mutation_test.go
Normal file
171
internal/gateway/discover_mutation_test.go
Normal file
@ -0,0 +1,171 @@
|
||||
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"
|
||||
"net/http"
|
||||
"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("a rejected key is reported as unauthorized, not as an outage", func(t *testing.T) {
|
||||
// The mutant: collapse every failure into one generic error, which is
|
||||
// what made a typo'd key look like the vendor being down.
|
||||
if classifyAuthStatus(http.StatusUnauthorized, []byte(`{"error":"invalid api key"}`)) == "" {
|
||||
t.Error("mutant survived: 401 is not classified")
|
||||
}
|
||||
if classifyAuthStatus(http.StatusNotFound, []byte("nope")) != "" {
|
||||
t.Error("a 404 must not be reported as an auth problem")
|
||||
}
|
||||
if classifyAuthStatus(http.StatusOK, nil) != "" {
|
||||
t.Error("a 200 must not be reported as an auth problem")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duplicate candidate URLs are collapsed", func(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for _, c := range discoveryCandidates("https://h/v1") {
|
||||
u := c.baseURL + c.modelsPath
|
||||
if seen[u] {
|
||||
t.Errorf("mutant survived: %s requested twice", u)
|
||||
}
|
||||
seen[u] = true
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("discovery falls back across URL forms", func(t *testing.T) {
|
||||
// The coverage requirement is that BOTH catalog paths are reachable for
|
||||
// a typed base, and both for a bare host. Asserting an exact count kept
|
||||
// drifting as the candidate list was fixed, and each time it measured
|
||||
// the current list rather than the property that matters.
|
||||
for _, base := range []string{"https://h/v1", "https://h"} {
|
||||
distinct := map[string]bool{}
|
||||
for _, c := range discoveryCandidates(base) {
|
||||
distinct[c.baseURL+c.modelsPath] = true
|
||||
}
|
||||
for _, want := range []string{base + "/v1/models", base + "/models"} {
|
||||
if !distinct[want] {
|
||||
t.Errorf("mutant survived: %s never requests %s", base, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
648
internal/gateway/discover_test.go
Normal file
648
internal/gateway/discover_test.go
Normal file
@ -0,0 +1,648 @@
|
||||
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")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, c := range cands {
|
||||
// Dedup is on the requested URL, so that is the key to assert on.
|
||||
k := c.baseURL + c.modelsPath
|
||||
if seen[k] {
|
||||
t.Errorf("duplicate request for %s", k)
|
||||
}
|
||||
seen[k] = true
|
||||
}
|
||||
// The versioned spelling of the typed URL must be reachable: on the real
|
||||
// amr-link endpoint that is what answers, and it is the preferred candidate.
|
||||
if !seen["https://h/v1/models"] {
|
||||
t.Error("the typed base with a versioned catalog path must be tried; that is what answers on amr-link")
|
||||
}
|
||||
// Both catalog forms must be attempted for a bare host too. Offering only
|
||||
// "/v1/models" there left a vendor serving a bare "/models" looking
|
||||
// unreachable.
|
||||
if !seen["https://h/models"] {
|
||||
t.Error("host root with a bare /models path was never tried")
|
||||
}
|
||||
// The first candidate is preferred, so its URL is what gets reported when
|
||||
// several answer.
|
||||
if cands[0].baseURL+cands[0].modelsPath != "https://h/v1/models" {
|
||||
t.Errorf("first candidate requests %q, want the canonical versioned form",
|
||||
cands[0].baseURL+cands[0].modelsPath)
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestComposeImageEndpoint pins the rule that fixed a real 404.
|
||||
//
|
||||
// Provider.ImageURL is `base_url + ImageEndpoint()`, and the built-in default
|
||||
// is the ABSOLUTE "/v1/images/generations" — while chat's default is the
|
||||
// relative "/chat/completions". The two defaults disagree about who owns the
|
||||
// version segment, so a base_url that produces a working chat URL produces
|
||||
// "/v1/v1/images/generations" and a 404.
|
||||
func TestComposeImageEndpoint(t *testing.T) {
|
||||
cases := []struct {
|
||||
base string
|
||||
want string
|
||||
}{
|
||||
// base without a version: the absolute default composes correctly.
|
||||
{"https://h", ""},
|
||||
// base WITH a version: the default would double it, so the
|
||||
// version-relative form is written instead.
|
||||
{"https://h/v1", "/images/generations"},
|
||||
{"https://h/v1beta", "/images/generations"},
|
||||
// A real path segment that merely starts with v is not a version.
|
||||
{"https://h/vision", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := composeImageEndpoint(tc.base)
|
||||
if got != tc.want {
|
||||
t.Errorf("composeImageEndpoint(%q) = %q, want %q", tc.base, got, tc.want)
|
||||
}
|
||||
// Whatever it decided, base + result must not contain a doubled
|
||||
// version segment — that is the bug this function exists for.
|
||||
joined := tc.base + got
|
||||
if strings.Contains(joined, "/v1/v1") {
|
||||
t.Errorf("composed %q contains a doubled version segment", joined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoveryReportsImageEndpointWhenBaseCarriesVersion is the end-to-end
|
||||
// version: a pasted base_url with "/v1" must come back with an image endpoint,
|
||||
// or the image models it reported will all 404 on use.
|
||||
func TestDiscoveryReportsImageEndpointWhenBaseCarriesVersion(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(realCatalogBody))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
res, err := DiscoverModels(context.Background(), srv.URL+"/v1", "k")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.ImageModels) == 0 {
|
||||
t.Fatal("fixture must contain image models for this to mean anything")
|
||||
}
|
||||
if res.BaseURL != srv.URL+"/v1" {
|
||||
t.Fatalf("base = %q, want the typed URL with /v1", res.BaseURL)
|
||||
}
|
||||
if res.ImageEndpoint != "/images/generations" {
|
||||
t.Errorf("image_endpoint = %q, want %q (otherwise the reported image models all 404)",
|
||||
res.ImageEndpoint, "/images/generations")
|
||||
}
|
||||
// The chat side stays empty: base already carries /v1 and the adapter's
|
||||
// relative default composes.
|
||||
if res.Endpoint != "" {
|
||||
t.Errorf("endpoint = %q, want empty", res.Endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -117,6 +117,20 @@ func (g *Gateway) handlePluginsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// /api/plugins/{name}/fanout/{fn} — one call per argument set, in
|
||||
// parallel, from one request. See handlePluginFanout for why the
|
||||
// request-per-item form cannot be made concurrent from the client.
|
||||
if i := strings.Index(path, "/fanout/"); i >= 0 {
|
||||
name := path[:i]
|
||||
fn := path[i+len("/fanout/"):]
|
||||
if name == "" || fn == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "fanout needs a plugin and a function name")
|
||||
return
|
||||
}
|
||||
g.handlePluginFanout(w, r, name, fn)
|
||||
return
|
||||
}
|
||||
|
||||
if reqRole(r.Context()) != "admin" {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
||||
return
|
||||
@ -133,7 +147,7 @@ func (g *Gateway) handlePluginsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
// Visibility overrides and the endpoint access table, so the
|
||||
// plugin page renders what is IN FORCE rather than making the
|
||||
// operator read .lua files or config.yaml to find out.
|
||||
"visibility": ps.VisibilityOverrides(),
|
||||
"visibility": ps.VisibilityOverrides(),
|
||||
"http_access": ps.EndpointAccess(),
|
||||
})
|
||||
case http.MethodPost:
|
||||
@ -331,6 +345,68 @@ func (g *Gateway) handlePluginCall(w http.ResponseWriter, r *http.Request, name,
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"result": result})
|
||||
}
|
||||
|
||||
// handlePluginFanout runs one plugin export per element of a list, in parallel,
|
||||
// from a SINGLE request.
|
||||
//
|
||||
// Why this exists: the quota page has twenty cards, and it used to fire twenty
|
||||
// browser requests at /call/. They arrived concurrently and then serialised
|
||||
// inside Plugins.Call, which holds the plugin mutex across the Lua function —
|
||||
// including its network wait. Measured on the live gateway: 20 probes took 29s of
|
||||
// wall clock while a single probe with no contention took 1.4–3.3s, so the page
|
||||
// sat on "probing…" for half a minute. The browser-side concurrency was real and
|
||||
// made no difference.
|
||||
//
|
||||
// A fan-out cannot be assembled from the client either: any request-per-item
|
||||
// arrangement is exactly the shape that queues. The parallelism has to happen
|
||||
// where the fan-out is received.
|
||||
func (g *Gateway) handlePluginFanout(w http.ResponseWriter, r *http.Request, name, fn string) {
|
||||
if reqRole(r.Context()) != "admin" {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
// Args is a LIST of argument objects, one per call. A bare object is
|
||||
// rejected rather than coerced: "args" meaning "one call" and "args"
|
||||
// meaning "many" depending on its shape is how a caller ends up probing
|
||||
// one source and believing it probed twenty.
|
||||
Args []map[string]interface{} `json:"args"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(req.Args) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "args must be a non-empty list of argument objects")
|
||||
return
|
||||
}
|
||||
// The page legitimately has one card per configured source. The bound is not
|
||||
// arbitrary: it stops one request from asking for thousands of VMs, and it is
|
||||
// enforced before any work starts rather than discovered shard by shard.
|
||||
if len(req.Args) > fanoutMaxArgs {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request",
|
||||
fmt.Sprintf("args has %d entries, limit is %d", len(req.Args), fanoutMaxArgs))
|
||||
return
|
||||
}
|
||||
results, err := g.core.Plugins().ShardCall(name, fn, req.Args)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "plugin_error", err.Error())
|
||||
return
|
||||
}
|
||||
// A shard that failed contributes a null in its slot. That is deliberate and
|
||||
// is NOT the same as the whole request failing: one unreachable upstream must
|
||||
// not turn a page of twenty cards into a single error.
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"results": results})
|
||||
}
|
||||
|
||||
// fanoutMaxArgs bounds one fan-out request. Twenty is the measured real source
|
||||
// count on the gateway this was built for; the headroom is for operators with
|
||||
// more sources configured.
|
||||
const fanoutMaxArgs = 64
|
||||
|
||||
func (g *Gateway) pluginDir() string {
|
||||
if c := g.core.Config(); c != nil {
|
||||
return c.PluginDir
|
||||
@ -369,7 +445,6 @@ func (g *Gateway) removePlugin(name string) error {
|
||||
return g.core.Plugins().Unload(name)
|
||||
}
|
||||
|
||||
|
||||
// handleEndpointAccess serves the operator's view and edit surface for one
|
||||
// plugin endpoint's reachable keys.
|
||||
//
|
||||
@ -439,7 +514,6 @@ func (g *Gateway) handleEndpointAccess(w http.ResponseWriter, r *http.Request, n
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// splitStateForKey rewrites a plugin state so that a non-admin caller sees only
|
||||
// the buckets that belong to them.
|
||||
//
|
||||
|
||||
@ -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/"):
|
||||
|
||||
@ -366,6 +366,22 @@
|
||||
|
||||
/* ---- chat page ---- */
|
||||
.tab-chat{max-width:840px;margin:0 auto;display:flex;flex-direction:column;height:calc(100vh - 190px)}
|
||||
|
||||
.tab-image{max-width:840px;margin:0 auto;display:flex;flex-direction:column;
|
||||
height:calc(100vh - 190px)}
|
||||
.img-wrap{flex:1;display:flex;flex-direction:column;min-height:0}
|
||||
.img-grid{flex:1;overflow-y:auto;padding:12px 2px;display:grid;
|
||||
grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:10px;align-content:start}
|
||||
.img-grid .chat-empty{grid-column:1/-1}
|
||||
.img-cell{border:1px solid var(--line);border-radius:10px;overflow:hidden;
|
||||
background:var(--bg2)}
|
||||
.img-cell img{display:block;width:100%;height:auto;background:#fff}
|
||||
.img-meta{padding:6px 8px;font-size:11px;color:var(--muted);
|
||||
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.img-composer{border-top:1px solid var(--line);padding:10px 2px 12px}
|
||||
.img-composer .chat-box{display:flex;gap:8px;align-items:flex-end}
|
||||
.img-composer textarea{flex:1;resize:vertical;min-height:52px}
|
||||
.img-composer .empty{padding:8px 0}
|
||||
.chat-wrap{background:var(--card);border:1px solid var(--line);border-radius:18px;box-shadow:var(--sh-lg);
|
||||
display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden;backdrop-filter:blur(var(--glass)) saturate(1.5)}
|
||||
.chat-log{flex:1;overflow-y:auto;padding:20px 20px 8px;display:flex;flex-direction:column;gap:18px}
|
||||
@ -573,6 +589,21 @@
|
||||
/></svg
|
||||
><span data-i="navChat">对话</span>
|
||||
</button>
|
||||
<button class="sb-i" data-tab="image">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M3 5h18v14H3z" />
|
||||
<path d="m3 15 5-5 4 4 3-3 6 6" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
></svg
|
||||
><span data-i="navImage">生图</span>
|
||||
</button>
|
||||
<button class="sb-i" data-tab="keys">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
@ -791,6 +822,7 @@
|
||||
<main id="main">
|
||||
<div id="tab-status" class="tab-pane"></div>
|
||||
<div id="tab-chat" class="tab-pane hidden"></div>
|
||||
<div id="tab-image" class="tab-pane hidden"></div>
|
||||
<div id="tab-keys" class="tab-pane hidden"></div>
|
||||
<div id="tab-sort" class="tab-pane hidden"></div>
|
||||
<div id="tab-sources" class="tab-pane hidden"></div>
|
||||
@ -941,6 +973,20 @@
|
||||
adPoolPeak: "峰值",
|
||||
adPoolSteps: "步长 (扩/缩)",
|
||||
cModel: "模型",
|
||||
cImageModels: "🖼 图像模型(不可选,请到「生图」页测试)",
|
||||
urlHint: "填主机地址,通常不带 /v1 —— 网关会自动拼接路径。但各家约定不同:若填 https://x.com/v1,对话路径会正确,而图像生成会变成 /v1/v1/… 而 404。不确定就用上方「自动探测模型」,它会探测并填好正确形式。",
|
||||
navImage: "生图",
|
||||
imgWelcome: "输入提示词开始生成",
|
||||
imgSlow: "生图可能需要 15 秒到 2 分钟,取决于上游模型",
|
||||
imgPrompt: "描述你想要的画面…",
|
||||
imgGenerate: "生成",
|
||||
imgWorking: "生成中… %ds",
|
||||
imgCount: "数量",
|
||||
imgSize: "尺寸",
|
||||
imgNoModels: "没有配置图像模型。在「源」里为某个源添加 kind: image 的模型后即可在此生成。",
|
||||
imgNoData: "上游没有返回图片数据",
|
||||
imgNeedPrompt: "请输入提示词",
|
||||
imgFailed: "生成失败",
|
||||
cMsg: "Enter 发送,Shift+Enter 换行",
|
||||
send: "发送",
|
||||
cClear: "清空",
|
||||
@ -989,6 +1035,16 @@
|
||||
mModels: "模型列表",
|
||||
mAddModel: "+ 模型",
|
||||
mMeta: "Meta",
|
||||
discScan: "自动探测模型",
|
||||
discScanning: "探测中…",
|
||||
discHint: "填好 URL 和密钥后点「自动探测模型」,网关会拉取上游的模型列表并填入下方;仍需点保存才会生效。",
|
||||
discNeedUrl: "请先填写 URL",
|
||||
discEmpty: "上游返回的模型列表为空",
|
||||
discFound: "已找到 %d 个模型",
|
||||
discChatN: "对话 %d 个",
|
||||
discImageN: "图像 %d 个",
|
||||
discUnsaved: "尚未保存,点保存后生效",
|
||||
discFailed: "探测失败",
|
||||
mSave: "保存",
|
||||
mCancel: "取消",
|
||||
toastCopied: "已复制",
|
||||
@ -1232,6 +1288,20 @@
|
||||
adPoolPeak: "Peak",
|
||||
adPoolSteps: "Steps (grow/shrink)",
|
||||
cModel: "Model",
|
||||
cImageModels: "🖼 Image models (not selectable — use the Image tab)",
|
||||
urlHint: "Host address, usually WITHOUT /v1 — the gateway appends the path. Vendors differ: with https://x.com/v1 chat works but image generation becomes /v1/v1/… and 404s. If unsure use Auto-discover above; it probes and fills in the correct form.",
|
||||
navImage: "Image",
|
||||
imgWelcome: "Describe an image to generate it",
|
||||
imgSlow: "Generation can take 15s to 2min depending on the upstream model",
|
||||
imgPrompt: "Describe the image you want…",
|
||||
imgGenerate: "Generate",
|
||||
imgWorking: "Generating… %ds",
|
||||
imgCount: "Count",
|
||||
imgSize: "Size",
|
||||
imgNoModels: "No image models configured. Add a model with kind: image on the Sources page.",
|
||||
imgNoData: "The upstream returned no image data",
|
||||
imgNeedPrompt: "Enter a prompt first",
|
||||
imgFailed: "Generation failed",
|
||||
cMsg: "Enter to send, Shift+Enter for newline",
|
||||
send: "Send",
|
||||
cClear: "Clear",
|
||||
@ -1281,6 +1351,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",
|
||||
@ -1471,7 +1551,7 @@
|
||||
// keep in sync, and adding a tab meant finding all three. A plugin page
|
||||
// that is routed but never shown is exactly the kind of silent gap that
|
||||
// survives review.
|
||||
const TABS = ["status", "chat", "keys", "sort", "sources", "adapters", "plugins"];
|
||||
const TABS = ["status", "chat", "image", "keys", "sort", "sources", "adapters", "plugins"];
|
||||
// Declared HERE, with var, because goTab() below reads it. It used to be a
|
||||
// `const` further down the file next to the other plugin state, which put
|
||||
// goTab's read inside the temporal dead zone: the first click on a plugin
|
||||
@ -2719,28 +2799,65 @@ recsState.keyNames = keyNames || {};
|
||||
/* ---------- chat tab ---------- */
|
||||
let chatHistory = [];
|
||||
let chatImages = [];
|
||||
async function renderChat() {
|
||||
const s = await api("/api/status");
|
||||
// Prefer per-source options ("source:model" routing) when the caller sees
|
||||
// sources (admin); otherwise fall back to the flat model list (user scope).
|
||||
let optList;
|
||||
if (Array.isArray(s.sources) && s.sources.length) {
|
||||
optList = ["AUTO"];
|
||||
s.sources.forEach((src) =>
|
||||
(src.models || []).forEach((m) => {
|
||||
optList.push(
|
||||
`<option value="${escAttr(`${src.name}:${m}`)}">${esc(`${src.name} · ${m}`)}</option>`,
|
||||
);
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
optList = ["AUTO"].concat(
|
||||
(s.models || []).map(
|
||||
(m) => `<option value="${escAttr(m)}">${esc(m)}</option>`,
|
||||
),
|
||||
);
|
||||
}
|
||||
const finalOpts = optList.join("");
|
||||
async function renderChat() {
|
||||
const s = await api("/api/status");
|
||||
// Prefer per-source options ("source:model" routing) when the caller sees
|
||||
// sources (admin); otherwise fall back to the flat model list (user scope).
|
||||
//
|
||||
// Chat and image models are listed in SEPARATE groups. The backend used to
|
||||
// hand this page one flat list with the kind stripped, so gpt-image-2 sat
|
||||
// next to claude-sonnet-5.5 and picking it sent the request to /api/chat,
|
||||
// which cannot serve it — a failure whose message never mentioned images.
|
||||
//
|
||||
// model_kinds parallels models; an older backend simply omits it, and then
|
||||
// everything falls into the chat group (the pre-fix behaviour).
|
||||
const optList = [];
|
||||
const imgOpts = [];
|
||||
const addModel = (value, label, kind) => {
|
||||
const html = `<option value="${escAttr(value)}">${esc(label)}</option>`;
|
||||
if (kind === "image") imgOpts.push(html);
|
||||
else optList.push(html);
|
||||
};
|
||||
|
||||
if (Array.isArray(s.sources) && s.sources.length) {
|
||||
optList.push(`<option value="AUTO">AUTO</option>`);
|
||||
s.sources.forEach((src) => {
|
||||
// Prefer the kind-carrying list; fall back to the flat one.
|
||||
const kinds = Array.isArray(src.model_kinds) && src.model_kinds.length
|
||||
? src.model_kinds
|
||||
: (src.models || []).map((m) => ({ id: m, kind: "chat" }));
|
||||
kinds.forEach((m) => {
|
||||
const id = typeof m === "string" ? m : m.id;
|
||||
addModel(
|
||||
`${src.name}:${id}`,
|
||||
`${src.name} · ${id}`,
|
||||
typeof m === "string" ? "chat" : m.kind,
|
||||
);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
optList.push(`<option value="AUTO">AUTO</option>`);
|
||||
(s.models || []).forEach((m) => {
|
||||
addModel(m, m, "chat");
|
||||
});
|
||||
}
|
||||
// Image models are listed but DISABLED, not merely grouped.
|
||||
//
|
||||
// Grouping alone still let an operator pick one, and picking it sent the
|
||||
// request to /api/chat — which cannot serve an image model — producing a
|
||||
// failure whose message never mentioned images. A disabled option explains
|
||||
// itself ("use the Image tab"); an enabled one just fails.
|
||||
//
|
||||
// The disabled attribute goes on at CONSTRUCTION, not via a later
|
||||
// replaceAll over the joined HTML: a plain String.replace only rewrites the
|
||||
// first match, so every source after the first contributed selectable
|
||||
// image models. Measured with six image models across two sources — the first
|
||||
// was disabled and the other five were not.
|
||||
const imgHtml = () => imgOpts.map((h) => h.replace("<option ", "<option disabled ")).join("");
|
||||
const finalOpts = optList.join("") +
|
||||
(imgOpts.length
|
||||
? `<optgroup label="${escAttr(t("cImageModels"))}">${imgHtml()}</optgroup>`
|
||||
: "");
|
||||
$("#tab-chat").innerHTML = `
|
||||
<div class="tab-chat">
|
||||
<div class="chat-wrap">
|
||||
@ -2784,6 +2901,133 @@ recsState.keyNames = keyNames || {};
|
||||
$("#ct-imgs").style.display = "none";
|
||||
$("#ct-msg").focus();
|
||||
}
|
||||
/* ---------- image tab ---------- */
|
||||
// Image generation lives in its own page rather than as an option in the
|
||||
// chat model dropdown.
|
||||
//
|
||||
// The dropdown used to list image models beside chat ones because
|
||||
// /api/status flattened the catalog to []string and dropped the kind.
|
||||
// Picking one sent the request to /api/chat, which cannot serve it. A
|
||||
// second tab makes the distinction structural: the only models offered
|
||||
// here are kind=image, and the endpoint called is the image one.
|
||||
let imgBusy = false;
|
||||
function imgModels() {
|
||||
const out = [];
|
||||
document.querySelectorAll("#it-model option").forEach((o) => {
|
||||
if (o.value) out.push(o.value);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
async function renderImage() {
|
||||
const s = await api("/api/status");
|
||||
const opts = [];
|
||||
if (Array.isArray(s.sources) && s.sources.length) {
|
||||
s.sources.forEach((src) => {
|
||||
const kinds = Array.isArray(src.model_kinds) && src.model_kinds.length
|
||||
? src.model_kinds
|
||||
: (src.models || []).map((m) => ({ id: m, kind: "chat" }));
|
||||
kinds.forEach((m) => {
|
||||
const id = typeof m === "string" ? m : m.id;
|
||||
const kind = typeof m === "string" ? "chat" : m.kind;
|
||||
if (kind !== "image") return;
|
||||
opts.push(
|
||||
`<option value="${escAttr(`${src.name}:${id}`)}">${esc(`${src.name} · ${id}`)}</option>`,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
const body = opts.length
|
||||
? ""
|
||||
: `<div class="empty">${esc(t("imgNoModels"))}</div>`;
|
||||
$("#tab-image").innerHTML = `
|
||||
<div class="tab-image">
|
||||
<div class="img-wrap">
|
||||
<div class="img-grid" id="it-grid">
|
||||
<div class="chat-empty" id="it-empty">
|
||||
<div class="ring">M</div>
|
||||
<p>${esc(t("imgWelcome"))}</p>
|
||||
<p class="muted" style="font-size:12px">${esc(t("imgSlow"))}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="img-composer">
|
||||
<div class="chat-tools">
|
||||
<span class="tl">${t("cModel")}</span>
|
||||
<select id="it-model" ${opts.length ? "" : "disabled"}>${opts.join("")}</select>
|
||||
<span class="grow"></span>
|
||||
<label class="tl" for="it-n">${t("imgCount")}</label>
|
||||
<input id="it-n" type="number" min="1" max="4" value="1" style="width:64px" />
|
||||
<label class="tl" for="it-size">${t("imgSize")}</label>
|
||||
<input id="it-size" value="1024x1024" style="width:104px" />
|
||||
</div>
|
||||
<div class="chat-box">
|
||||
<textarea id="it-prompt" rows="2" placeholder="${escAttr(t("imgPrompt"))}"></textarea>
|
||||
<button class="sendbtn ghost" id="it-clear" onclick="clearImages()">${t("cClear")}</button>
|
||||
<button class="sendbtn" id="it-send" onclick="sendImage()" ${opts.length ? "" : "disabled"}>${t("imgGenerate")}</button>
|
||||
</div>
|
||||
${body}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
function clearImages() {
|
||||
$("#it-grid").innerHTML = `<div class="chat-empty" id="it-empty">
|
||||
<div class="ring">M</div>
|
||||
<p>${esc(t("imgWelcome"))}</p>
|
||||
<p class="muted" style="font-size:12px">${esc(t("imgSlow"))}</p>
|
||||
</div>`;
|
||||
}
|
||||
// Image generation is slow — measured 16s to 2min on the one upstream
|
||||
// tested — so the button reports elapsed time instead of looking hung.
|
||||
async function sendImage() {
|
||||
if (imgBusy) return;
|
||||
const prompt = ($("#it-prompt").value || "").trim();
|
||||
const model = $("#it-model").value;
|
||||
if (!prompt) {
|
||||
toast(t("imgNeedPrompt"));
|
||||
return;
|
||||
}
|
||||
if (!model) {
|
||||
toast(t("imgNoModels"));
|
||||
return;
|
||||
}
|
||||
const n = Math.max(1, Math.min(4, parseInt($("#it-n").value, 10) || 1));
|
||||
const size = ($("#it-size").value || "").trim();
|
||||
imgBusy = true;
|
||||
const btn = $("#it-send");
|
||||
btn.disabled = true;
|
||||
const t0 = Date.now();
|
||||
const tick = setInterval(() => {
|
||||
btn.textContent = t("imgWorking").replace("%d", String(Math.round((Date.now() - t0) / 1000)));
|
||||
}, 1000);
|
||||
try {
|
||||
const r = await api("/v1/images/generations", {
|
||||
method: "POST",
|
||||
body: size ? { model: model, prompt: prompt, n: n, size: size } : { model: model, prompt: prompt, n: n },
|
||||
});
|
||||
const empty = $("#it-empty");
|
||||
if (empty) empty.remove();
|
||||
const grid = $("#it-grid");
|
||||
const items = Array.isArray(r.data) ? r.data : [];
|
||||
items.forEach((it) => {
|
||||
const src = it.url || (it.b64_json ? "data:image/png;base64," + it.b64_json : "");
|
||||
if (!src) return;
|
||||
grid.insertAdjacentHTML(
|
||||
"beforeend",
|
||||
`<div class="img-cell"><img loading="lazy" src="${escAttr(src)}" alt="generated" />
|
||||
<div class="img-meta">${esc(it.revised_prompt || model)}</div></div>`,
|
||||
);
|
||||
});
|
||||
if (!items.length) toast(t("imgNoData"));
|
||||
} catch (e) {
|
||||
toast(t("imgFailed") + (e && e.message ? ": " + e.message : ""));
|
||||
} finally {
|
||||
clearInterval(tick);
|
||||
imgBusy = false;
|
||||
btn.disabled = false;
|
||||
btn.textContent = t("imgGenerate");
|
||||
}
|
||||
}
|
||||
|
||||
function updateGrow() {
|
||||
const ta = $("#ct-msg");
|
||||
if (!ta) return;
|
||||
@ -3134,13 +3378,15 @@ 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">
|
||||
<div><label>${t("mName")}</label><input id="s-name" value="${escAttr(s.name)}" ${name ? "disabled" : ""}></div>
|
||||
<div><label>${t("mAlias")}</label>${adSel}</div>
|
||||
</div>
|
||||
<label>${t("mURL")}</label><input id="s-url" value="${escAttr(s.base_url || "")}">
|
||||
<label>${t("mURL")}</label><input id="s-url" value="${escAttr(s.base_url || "")}" placeholder="https://api.vendor.com">
|
||||
<div class="hint">${t("urlHint")}</div>
|
||||
<label>${t("mKey")}</label><input id="s-key" type="password" value="${escAttr(s.api_key || "")}">
|
||||
<div class="row">
|
||||
<div><label>${t("mEp")}</label><input id="s-ep" value="${escAttr(s.endpoint || "")}"></div>
|
||||
@ -3160,6 +3406,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 +3455,89 @@ 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;
|
||||
// image_endpoint too: the gateway's default image path is absolute
|
||||
// ("/v1/images/generations") while chat's is relative, so a base_url
|
||||
// with /v1 yields a working chat URL and a 404 image URL unless this
|
||||
// is corrected. Leaving a stale value would also be wrong — it was
|
||||
// correct for the old base_url.
|
||||
if (r.image_endpoint && $("#s-img")) $("#s-img").value = r.image_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) => ({
|
||||
@ -3466,7 +3796,8 @@ recsState.keyNames = keyNames || {};
|
||||
<div><label>${t("mName")}</label><input id="s-name" value="${escAttr(s.name)}" ${name ? "disabled" : ""}></div>
|
||||
<div><label>${t("mAlias")}</label>${adSel}</div>
|
||||
</div>
|
||||
<label>${t("mURL")}</label><input id="s-url" value="${escAttr(s.base_url || "")}">
|
||||
<label>${t("mURL")}</label><input id="s-url" value="${escAttr(s.base_url || "")}" placeholder="https://api.vendor.com">
|
||||
<div class="hint">${t("urlHint")}</div>
|
||||
<div class="row">
|
||||
<div><label>${t("mEp")}</label><input id="s-ep" value="${escAttr(s.endpoint || "")}"></div>
|
||||
<div><label>${t("mImgEp")}</label><input id="s-img" value="${escAttr(s.image_endpoint || "")}"></div>
|
||||
@ -3481,6 +3812,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>
|
||||
@ -5708,6 +6040,7 @@ toast(t("kAutoChainGlobal"));
|
||||
const done = () => remountPluginElements();
|
||||
if (tab === "status") return renderStatus().then(done, done);
|
||||
if (tab === "chat") return renderChat().then(done, done);
|
||||
if (tab === "image") return renderImage().then(done, done);
|
||||
if (tab === "keys") return renderKeys().then(done, done);
|
||||
if (tab === "sort") return renderSort().then(done, done);
|
||||
if (tab === "sources") return renderSources().then(done, done);
|
||||
@ -5781,6 +6114,38 @@ toast(t("kAutoChainGlobal"));
|
||||
if (!r.ok) throw new Error(body.error?.message || r.status);
|
||||
return body.result;
|
||||
},
|
||||
// fanout runs one of the plugin's exported functions once per argument
|
||||
// set, IN PARALLEL, from a single request.
|
||||
//
|
||||
// `call` in a Promise.all is NOT this. Twenty concurrent /call/ requests
|
||||
// arrive concurrently and then serialise on the plugin mutex inside the
|
||||
// kernel — the lock is held across the Lua function, network wait
|
||||
// included, because one Lua VM has one stack. Measured on a live
|
||||
// gateway: twenty quota probes took 29s of wall clock through /call/,
|
||||
// against 1.4–3.3s for a single uncontended probe. The browser-side
|
||||
// concurrency bought nothing and the page looked simply broken.
|
||||
//
|
||||
// The parallelism has to happen where the fan-out is received, so it
|
||||
// takes one request. Results come back positionally aligned with `argSets`;
|
||||
// a failed item is null in its slot rather than an error for the batch,
|
||||
// because one unreachable upstream must not blank a whole page of cards.
|
||||
//
|
||||
// Same authorization as call: admin-only, and the function must be in
|
||||
// the plugin's exports.
|
||||
async fanout(name, fn, argSets) {
|
||||
const r = await fetch(
|
||||
"/api/plugins/" + encodeURIComponent(name) + "/fanout/" + encodeURIComponent(fn),
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ args: argSets || [] }),
|
||||
},
|
||||
);
|
||||
const body = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(body.error?.message || r.status);
|
||||
return body.results || [];
|
||||
},
|
||||
onTabShown(fn) {
|
||||
PLUGIN_TAB_CBS.__last = PLUGIN_TAB_CBS.__last || [];
|
||||
PLUGIN_TAB_CBS.__last.push(fn);
|
||||
|
||||
195
internal/gateway/ui_image_page_test.go
Normal file
195
internal/gateway/ui_image_page_test.go
Normal file
@ -0,0 +1,195 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/provider"
|
||||
)
|
||||
|
||||
// TestUI_ChatPageGroupsImageModels is a UI-contract guard for the bug that
|
||||
// started this: the chat page listed image models beside chat ones, and picking
|
||||
// one posted to /api/chat, which cannot serve it.
|
||||
//
|
||||
// The contract is asserted on the HTML source rather than by running a browser,
|
||||
// because the failure this must catch is a source-level one — an <option>
|
||||
// without `disabled`. A DOM assertion in a headless browser would pass right
|
||||
// up until someone removed the attribute, and then only if a CI machine had the
|
||||
// whole stack up.
|
||||
func TestUI_ChatPageGroupsImageModels(t *testing.T) {
|
||||
ui := uiSource(t)
|
||||
|
||||
// The dropdown must read model_kinds, not the flat models list. Reading the
|
||||
// flat list is what made chat and image models indistinguishable.
|
||||
if !strings.Contains(ui, "model_kinds") {
|
||||
t.Error("chat page never reads model_kinds; image models will be " +
|
||||
"indistinguishable from chat models again")
|
||||
}
|
||||
|
||||
// Image options must be rendered disabled. A plain String.replace over the
|
||||
// joined HTML only disables the FIRST match — that bug shipped once here
|
||||
// (one source disabled, five selectable) — so the assertion looks for the
|
||||
// per-item map, not for a global replace.
|
||||
if strings.Contains(ui, `imgOpts.join("").replace`) {
|
||||
t.Error("chat page disables image options with String.replace over the " +
|
||||
"joined HTML, which only rewrites the first option")
|
||||
}
|
||||
if !strings.Contains(ui, "disabled") {
|
||||
t.Error("chat page never emits a disabled attribute for image models")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUI_HasDedicatedImagePage pins the second half of the fix: image models
|
||||
// need somewhere they CAN be tested.
|
||||
func TestUI_HasDedicatedImagePage(t *testing.T) {
|
||||
ui := uiSource(t)
|
||||
|
||||
for _, want := range []string{
|
||||
`data-tab="image"`, // sidebar entry
|
||||
`id="tab-image"`, // pane
|
||||
`function renderImage`, // renderer registered with refresh()
|
||||
`tab === "image"`, // dispatched
|
||||
`/v1/images/generations`, // the endpoint the page actually calls
|
||||
} {
|
||||
if !strings.Contains(ui, want) {
|
||||
t.Errorf("image page is missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
// The page must be reachable from TABS; goTab iterates TABS to toggle pane
|
||||
// visibility, so a pane outside that list is present but permanently hidden —
|
||||
// which reads as "the page is blank" because the content is in the DOM.
|
||||
if !strings.Contains(ui, `"image"`) {
|
||||
t.Error(`"image" does not appear in the TABS list`)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUI_ImagePageCallsTheImageEndpoint is the discriminator: a page that
|
||||
// renders an image result but posts to the chat endpoint would produce exactly
|
||||
// the original failure, just prettier.
|
||||
func TestUI_ImagePageCallsTheImageEndpoint(t *testing.T) {
|
||||
ui := uiSource(t)
|
||||
|
||||
i := strings.Index(ui, "async function sendImage(")
|
||||
if i < 0 {
|
||||
t.Fatal("sendImage() not found")
|
||||
}
|
||||
// Bound the search to the function body rather than the whole file, so a
|
||||
// /v1/images/generations mention elsewhere cannot satisfy this.
|
||||
body := ui[i:]
|
||||
if end := strings.Index(body, "\n function "); end > 0 {
|
||||
body = body[:end]
|
||||
}
|
||||
if end := strings.Index(body, "\n async function "); end > 0 {
|
||||
if s := body[:end]; strings.Contains(s, "/v1/images/generations") {
|
||||
body = s
|
||||
} else {
|
||||
body = body[:end]
|
||||
}
|
||||
}
|
||||
if !strings.Contains(body, "/v1/images/generations") {
|
||||
t.Error("sendImage() does not post to /v1/images/generations")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceStatus_ExposesModelKindsToTheUI is the server half of the same
|
||||
// contract: the field the UI reads must actually be serialized.
|
||||
func TestSourceStatus_ExposesModelKindsToTheUI(t *testing.T) {
|
||||
st := provider.SourceStatus{
|
||||
Name: "vela",
|
||||
Models: []string{"claude-sonnet-5.5", "gpt-image-2"},
|
||||
ModelKinds: []provider.ModelKindInfo{
|
||||
{ID: "claude-sonnet-5.5", Kind: "chat"},
|
||||
{ID: "gpt-image-2", Kind: "image"},
|
||||
},
|
||||
}
|
||||
b, err := json.Marshal(st)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(b, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mk, ok := got["model_kinds"].([]interface{})
|
||||
if !ok || len(mk) != 2 {
|
||||
t.Fatalf("model_kinds = %v, want 2 entries; the UI cannot split the list without it", got["model_kinds"])
|
||||
}
|
||||
last := mk[1].(map[string]interface{})
|
||||
if last["kind"] != "image" {
|
||||
t.Errorf("model_kinds[1].kind = %v, want \"image\"", last["kind"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestUI_BaseURLFieldExplainsWhereToCut keeps the second bug fixed: the URL
|
||||
// hint has to mention the concrete failure, not just say "enter the URL".
|
||||
func TestUI_BaseURLFieldExplainsWhereToCut(t *testing.T) {
|
||||
ui := uiSource(t)
|
||||
if !strings.Contains(ui, "urlHint") {
|
||||
t.Fatal("no urlHint; the base_url field gives no guidance about /v1")
|
||||
}
|
||||
hint := ui[strings.Index(ui, "urlHint:"):]
|
||||
if len(hint) > 700 {
|
||||
hint = hint[:700]
|
||||
}
|
||||
// The trap is specific and reproducible: base_url ending in /v1 makes chat
|
||||
// work and image generation 404. Saying only "enter the URL" reproduces the
|
||||
// confusion the hint exists to remove.
|
||||
for _, want := range []string{"/v1", "404"} {
|
||||
if !strings.Contains(hint, want) {
|
||||
t.Errorf("urlHint does not mention %q — the hint must name the concrete failure", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageEndpointComposesUnderRealConditions proves the hint describes a
|
||||
// reachable path rather than a plausible-sounding one.
|
||||
//
|
||||
// The case that matters: base_url already ends in /v1 (which is what an
|
||||
// operator pastes from an SDK example), so the RELATIVE image endpoint composes
|
||||
// to …/v1/images/generations and works, while the gateway's default ABSOLUTE
|
||||
// path would produce …/v1/v1/… and 404.
|
||||
//
|
||||
// The doubled form is served as a 404 here so the test would fail if the code
|
||||
// ever reverted to relying on the default.
|
||||
func TestImageEndpointComposesUnderRealConditions(t *testing.T) {
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/images/generations":
|
||||
_, _ = w.Write([]byte(`{"data":[{"url":"http://example.invalid/x.png"}]}`))
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer up.Close()
|
||||
|
||||
// base_url as an operator types it: host root + /v1.
|
||||
base := up.URL + "/v1"
|
||||
|
||||
// What discovery writes when base carries a version segment.
|
||||
relative := base + "/images/generations"
|
||||
resp, err := http.Post(relative, "application/json", strings.NewReader(`{"model":"m"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("POST %s = %d, want 200 — the form discovery writes must work", relative, resp.StatusCode)
|
||||
}
|
||||
|
||||
// What the gateway's built-in default would produce for the same base_url.
|
||||
doubled := base + "/v1/images/generations"
|
||||
resp2, err := http.Post(doubled, "application/json", strings.NewReader(`{"model":"m"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp2.Body.Close()
|
||||
if resp2.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("POST %s = %d, want 404 — this test's premise is that the "+
|
||||
"default image path doubles the version segment; if the upstream ever "+
|
||||
"accepts it, composeImageEndpoint's rule needs rechecking", doubled, resp2.StatusCode)
|
||||
}
|
||||
}
|
||||
323
internal/lua/adapter_qwen38_test.go
Normal file
323
internal/lua/adapter_qwen38_test.go
Normal file
@ -0,0 +1,323 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The fixtures below are REAL responses captured from
|
||||
// https://discovery-api.intern-ai.org.cn/v1 with qwen3.8-27b — not hand-written
|
||||
// approximations. Two of them exist only because the endpoint behaves in a way a
|
||||
// stock OpenAI adapter gets wrong, and neither is guessable from the schema.
|
||||
|
||||
// reasoningDominatesTheBudget is the fact that made a dedicated adapter
|
||||
// necessary: a one-sentence visual answer came back as completion=303 with
|
||||
// reasoning=295. 97% of the budget went to thinking and only 8 tokens reached
|
||||
// content. Handing the upstream's content straight through yields what looks
|
||||
// like an empty answer from a perfectly healthy request.
|
||||
const reasoningDominatesTheBudget = `{
|
||||
"choices": [{"index":0,"message":{"role":"assistant","content":"\n\n这张图是白色的。",
|
||||
"reasoning_content":"分析请求…分析图片…颜色是纯白"},"logprobs":null,
|
||||
"finish_reason":"stop"}],
|
||||
"created":1791179068,"model":"qwen3.8-27b-fp8","object":"chat.completion",
|
||||
"usage":{"prompt_tokens":125,"total_tokens":428,"completion_tokens":303,
|
||||
"prompt_tokens_details":null,
|
||||
"completion_tokens_details":{"reasoning_tokens":295}}
|
||||
}`
|
||||
|
||||
// truncatedReasoningOnly is what a small max_tokens produces: HTTP 200, content
|
||||
// null, finish_reason "length". It is NOT an upstream failure.
|
||||
const truncatedReasoningOnly = `{
|
||||
"choices": [{"index":0,"message":{"role":"assistant","content":null,
|
||||
"reasoning_content":"思考过程:1. **分析请求**…图片的颜色是纯","refusal":null},
|
||||
"finish_reason":"length"}],
|
||||
"model":"qwen3.8-27b-fp8","object":"chat.completion",
|
||||
"usage":{"prompt_tokens":127,"total_tokens":190,"completion_tokens":63}
|
||||
}`
|
||||
|
||||
const qwen38ToolCallResponse = `{
|
||||
"choices": [{"index":0,"message":{"role":"assistant","content":"",
|
||||
"reasoning_content":"用户询问北京天气。我需要调用 get_weather 函数,参数为城市 \"北京\"。",
|
||||
"tool_calls":[{"id":"chatcmpl-tool-afc0299b5810e044","type":"function",
|
||||
"function":{"name":"get_weather","arguments":"{\"city\": \"北京\"}"}}]},
|
||||
"finish_reason":"tool_calls"}],
|
||||
"model":"qwen3.8-27b-fp8",
|
||||
"usage":{"prompt_tokens":305,"total_tokens":353,"completion_tokens":48,
|
||||
"completion_tokens_details":{"reasoning_tokens":20}}
|
||||
}`
|
||||
|
||||
func qwen38VM(t *testing.T) *VM {
|
||||
t.Helper()
|
||||
vm := NewVM(freshAdapterDir(t))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatalf("adapter VM: %v", err)
|
||||
}
|
||||
t.Cleanup(vm.Stop)
|
||||
return vm
|
||||
}
|
||||
|
||||
func qwen38Response(t *testing.T, vm *VM, raw string) map[string]interface{} {
|
||||
t.Helper()
|
||||
out, err := vm.Transform("qwen38", "transform_response", raw)
|
||||
if err != nil {
|
||||
t.Fatalf("transform_response: %v", err)
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &m); err != nil {
|
||||
t.Fatalf("adapter returned non-JSON: %v\n%s", err, out)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func qwen38Stream(t *testing.T, vm *VM, raw string) map[string]interface{} {
|
||||
t.Helper()
|
||||
out, err := vm.Transform("qwen38", "transform_stream_chunk", raw)
|
||||
if err != nil {
|
||||
t.Fatalf("transform_stream_chunk: %v", err)
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &m); err != nil {
|
||||
t.Fatalf("adapter returned non-JSON: %v\n%s", err, out)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// TestQwen38KeepsReasoningSeparate is the adapter's reason for existing.
|
||||
// Folding reasoning into content would render the model's private deliberation as
|
||||
// its answer; dropping it makes a reasoning model look like it returned nothing.
|
||||
func TestQwen38KeepsReasoningSeparate(t *testing.T) {
|
||||
vm := qwen38VM(t)
|
||||
u := qwen38Response(t, vm, reasoningDominatesTheBudget)
|
||||
|
||||
content, _ := u["content"].(string)
|
||||
reasoning, _ := u["reasoning_content"].(string)
|
||||
|
||||
if reasoning == "" {
|
||||
t.Fatal("reasoning_content was dropped — a reasoning model then looks like it returned nothing")
|
||||
}
|
||||
if strings.Contains(content, "分析请求") {
|
||||
t.Errorf("reasoning was folded into content: %q", content)
|
||||
}
|
||||
if !strings.Contains(content, "白色的") {
|
||||
t.Errorf("content = %q, want the answer text", content)
|
||||
}
|
||||
if reasoning == content {
|
||||
t.Error("content and reasoning_content are identical; they are disjoint upstream")
|
||||
}
|
||||
}
|
||||
|
||||
// TestQwen38ReportsTruncationRatherThanFailing: content=null with
|
||||
// finish_reason="length" is a truncated reasoning response, not a broken
|
||||
// upstream. Losing the finish_reason makes it indistinguishable from a failure,
|
||||
// and losing the partial reasoning throws away the only evidence of what happened.
|
||||
func TestQwen38ReportsTruncationRatherThanFailing(t *testing.T) {
|
||||
vm := qwen38VM(t)
|
||||
u := qwen38Response(t, vm, truncatedReasoningOnly)
|
||||
|
||||
if u["finish_reason"] != "length" {
|
||||
t.Errorf("finish_reason = %v, want length", u["finish_reason"])
|
||||
}
|
||||
if reasoning, _ := u["reasoning_content"].(string); reasoning == "" {
|
||||
t.Error("the partial reasoning was discarded")
|
||||
}
|
||||
if content, _ := u["content"].(string); content != "" {
|
||||
t.Errorf("content = %q, want empty string for a null upstream content", content)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQwen38CarriesReasoningTokensWithoutInflatingTotals: reasoning_tokens is a
|
||||
// SUBSET of completion_tokens. Adding them double-counts every reasoning request,
|
||||
// and billing prices the pair separately.
|
||||
func TestQwen38CarriesReasoningTokensWithoutInflatingTotals(t *testing.T) {
|
||||
vm := qwen38VM(t)
|
||||
u := qwen38Response(t, vm, reasoningDominatesTheBudget)
|
||||
|
||||
tu, ok := u["token_usage"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("token_usage = %#v", u["token_usage"])
|
||||
}
|
||||
completion, _ := tu["completion"].(float64)
|
||||
total, _ := tu["total"].(float64)
|
||||
|
||||
// The NESTING is the point, not the presence of the field.
|
||||
//
|
||||
// internal/gateway/chat.go reads reasoning in exactly one place:
|
||||
// resp.TokenUsage.CompletionTokensDetails.ReasoningTokens
|
||||
// An adapter that emits reasoning_tokens at the TOP of token_usage type-checks
|
||||
// fine and is then dropped at the one spot that could have kept it — measured
|
||||
// on the live gateway: the model reported completion=88 and the gateway's
|
||||
// response carried reasoning=null.
|
||||
//
|
||||
// So this asserts the path, not merely that a number appears somewhere.
|
||||
det, ok := tu["completion_tokens_details"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("token_usage has no completion_tokens_details: %#v — chat.go reads "+
|
||||
"reasoning from there and nowhere else, so the number is discarded", tu)
|
||||
}
|
||||
reasoning, has := det["reasoning_tokens"].(float64)
|
||||
if !has {
|
||||
t.Fatal("completion_tokens_details.reasoning_tokens missing — billing cannot price reasoning separately")
|
||||
}
|
||||
// Relational, not a presence check. The first version of this test only
|
||||
// asserted the field existed, and a mutation setting it to 0 passed: 0 is
|
||||
// present. The upstream value is known (295), so the exact number is
|
||||
// asserted — a zero silently misprices every reasoning request.
|
||||
if reasoning != 295 {
|
||||
t.Errorf("reasoning_tokens = %v, want 295 (the upstream value)", reasoning)
|
||||
}
|
||||
if reasoning >= completion {
|
||||
t.Errorf("reasoning_tokens (%v) must be a SUBSET of completion_tokens (%v)", reasoning, completion)
|
||||
}
|
||||
if completion != 303 || total != 428 {
|
||||
t.Errorf("usage was altered: completion=%v total=%v, want 303/428", completion, total)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQwen38DecodesToolCallArguments: the upstream sends arguments as a JSON
|
||||
// STRING, like every other OpenAI-compatible endpoint. Passing it through
|
||||
// undecoded leaves the caller to parse it.
|
||||
func TestQwen38DecodesToolCallArguments(t *testing.T) {
|
||||
vm := qwen38VM(t)
|
||||
u := qwen38Response(t, vm, qwen38ToolCallResponse)
|
||||
|
||||
tcs, ok := u["tool_calls"].([]interface{})
|
||||
if !ok || len(tcs) != 1 {
|
||||
t.Fatalf("tool_calls = %#v", u["tool_calls"])
|
||||
}
|
||||
tc, _ := tcs[0].(map[string]interface{})
|
||||
if tc["name"] != "get_weather" {
|
||||
t.Errorf("name = %v", tc["name"])
|
||||
}
|
||||
args, ok := tc["arguments"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("arguments = %#v — the upstream sends a JSON string and it must be decoded", tc["arguments"])
|
||||
}
|
||||
if args["city"] != "北京" {
|
||||
t.Errorf("arguments.city = %v", args["city"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestQwen38StreamKeepsReasoningSeparate: measured 20 reasoning chunks against 1
|
||||
// content chunk on a one-sentence answer. Folding them would make the client
|
||||
// render the deliberation as the reply.
|
||||
func TestQwen38StreamKeepsReasoningSeparate(t *testing.T) {
|
||||
vm := qwen38VM(t)
|
||||
const reasoningChunk = `{"choices":[{"index":0,"delta":{"reasoning_content":"思考过程:1. **分析请求**"},
|
||||
"finish_reason":null}],"model":"qwen3.8-27b-fp8"}`
|
||||
const contentChunk = `{"choices":[{"index":0,"delta":{"content":"递归就是函数调用自身"},
|
||||
"finish_reason":null}],"model":"qwen3.8-27b-fp8"}`
|
||||
const usageChunk = `{"choices":[],"usage":{"prompt_tokens":64,"total_tokens":132,
|
||||
"completion_tokens":68,"completion_tokens_details":{"reasoning_tokens":62}}}`
|
||||
|
||||
a := qwen38Stream(t, vm, reasoningChunk)
|
||||
b := qwen38Stream(t, vm, contentChunk)
|
||||
c := qwen38Stream(t, vm, usageChunk)
|
||||
|
||||
if content, mixed := a["content"].(string); mixed && content != "" {
|
||||
t.Errorf("a reasoning chunk leaked into content: %q", content)
|
||||
}
|
||||
if reasoning, _ := a["reasoning_content"].(string); !strings.Contains(reasoning, "思考过程") {
|
||||
t.Errorf("reasoning chunk lost its text: %#v", a)
|
||||
}
|
||||
if content, _ := b["content"].(string); !strings.Contains(content, "递归") {
|
||||
t.Errorf("content chunk lost its text: %#v", b)
|
||||
}
|
||||
if c["usage"] == nil {
|
||||
t.Errorf("the terminal usage chunk was dropped: %#v", c)
|
||||
return
|
||||
}
|
||||
u, _ := c["usage"].(map[string]interface{})
|
||||
det, _ := u["completion_tokens_details"].(map[string]interface{})
|
||||
if det == nil || det["reasoning_tokens"] == nil {
|
||||
t.Errorf("usage chunk did not nest reasoning under completion_tokens_details: %#v — "+
|
||||
"chat.go reads it there, so a top-level field is discarded", u)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQwen38ReportsMediaFailureCode: a remote image URL times out upstream and
|
||||
// comes back as media_fetch_failed. Surfacing only the message makes an image
|
||||
// fetch problem look like a model problem.
|
||||
func TestQwen38ReportsMediaFailureCode(t *testing.T) {
|
||||
vm := qwen38VM(t)
|
||||
body := `{"error":{"message":"image fetch timed out","type":"media_fetch_failed",
|
||||
"param":null,"code":"media_fetch_failed","trace_id":"f3ca4aa"}}`
|
||||
reason, ok, err := vm.TransformError("qwen38", 400, body)
|
||||
if err != nil {
|
||||
t.Fatalf("TransformError: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("the media failure was swallowed")
|
||||
}
|
||||
if !strings.Contains(reason, "image fetch timed out") || !strings.Contains(reason, "media_fetch_failed") {
|
||||
t.Errorf("reason = %q; it must carry both the text and the code", reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQwen38InvalidKeyError: the endpoint answers a bad key with 401 and
|
||||
// code "invalid_api_key". That one must NOT be annotated with its own code — it
|
||||
// would render as "invalid API key (invalid_api_key)".
|
||||
func TestQwen38InvalidKeyError(t *testing.T) {
|
||||
vm := qwen38VM(t)
|
||||
body := `{"error":{"message":"invalid API key","type":"invalid_request_error",
|
||||
"param":null,"code":"invalid_api_key"}}`
|
||||
reason, ok, err := vm.TransformError("qwen38", 401, body)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("TransformError: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if reason != "invalid API key" {
|
||||
t.Errorf("reason = %q, want the bare message", reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQwen38SuppliesReasoningOnToolCallTurns: the upstream requires an assistant
|
||||
// message carrying tool_calls to echo reasoning_content. OpenAI-shaped clients
|
||||
// never send it, so without this the SECOND turn of any tool-using conversation
|
||||
// is rejected.
|
||||
func TestQwen38SuppliesReasoningOnToolCallTurns(t *testing.T) {
|
||||
vm := qwen38VM(t)
|
||||
const req = `{"model":"qwen3.8-27b","messages":[
|
||||
{"role":"user","content":"北京天气"},
|
||||
{"role":"assistant","content":"","tool_calls":[{"id":"c1","type":"function",
|
||||
"function":{"name":"get_weather","arguments":"{\"city\":\"北京\"}"}}]},
|
||||
{"role":"tool","tool_call_id":"c1","content":"晴 25C"}]}`
|
||||
out, err := vm.Transform("qwen38", "transform_request", req)
|
||||
if err != nil {
|
||||
t.Fatalf("transform_request: %v", err)
|
||||
}
|
||||
var decoded map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &decoded); err != nil {
|
||||
t.Fatalf("request transform did not return JSON: %v\n%s", err, out)
|
||||
}
|
||||
msgs, _ := decoded["messages"].([]interface{})
|
||||
if len(msgs) < 2 {
|
||||
t.Fatalf("messages = %#v", decoded["messages"])
|
||||
}
|
||||
assistant, _ := msgs[1].(map[string]interface{})
|
||||
if _, ok := assistant["reasoning_content"]; !ok {
|
||||
t.Errorf("assistant turn with tool_calls has no reasoning_content; the upstream "+
|
||||
"rejects the next turn. Got %#v", assistant)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQwen38PassesImagesThrough: this adapter's job is reasoning handling, not
|
||||
// media translation. A multimodal content array must survive untouched — an
|
||||
// adapter that re-encodes it is the kind of change that silently breaks images
|
||||
// while every text test still passes.
|
||||
func TestQwen38PassesImagesThrough(t *testing.T) {
|
||||
vm := qwen38VM(t)
|
||||
const req = `{"model":"qwen3.8-27b","messages":[{"role":"user","content":[
|
||||
{"type":"text","text":"这张图是什么颜色?"},
|
||||
{"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgo="}}]}],
|
||||
"max_tokens":600}`
|
||||
out, err := vm.Transform("qwen38", "transform_request", req)
|
||||
if err != nil {
|
||||
t.Fatalf("transform_request: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "image_url") || !strings.Contains(out, "data:image/png;base64,") {
|
||||
t.Errorf("the image part was lost or rewritten:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "base64,iVBORw0KGgo=") == false {
|
||||
t.Errorf("the base64 payload was altered:\n%s", out)
|
||||
}
|
||||
}
|
||||
235
internal/lua/adapters/qwen38.lua
Normal file
235
internal/lua/adapters/qwen38.lua
Normal file
@ -0,0 +1,235 @@
|
||||
-- tokenplan / discovery-api (intern-ai.org.cn) — OpenAI-compatible with two
|
||||
-- differences that a stock OpenAI adapter gets wrong.
|
||||
--
|
||||
-- 1. REASONING IS A SEPARATE FIELD, AND IT IS MOST OF THE OUTPUT.
|
||||
-- Every response carries `reasoning_content` (non-stream) or
|
||||
-- `delta.reasoning_content` (stream), disjoint from `content`. Measured on
|
||||
-- qwen3.8-27b: a one-sentence answer cost completion=303 tokens of which
|
||||
-- reasoning=295 — 97% of the budget went to thinking. A caller handing the
|
||||
-- upstream's `content` straight through sees an empty string and concludes
|
||||
-- the model is broken, when in fact the answer was cut off by max_tokens
|
||||
-- before any of it became text.
|
||||
--
|
||||
-- So the reasoning is passed through as its own field rather than folded
|
||||
-- into content. Folding would be worse than dropping it: the page would
|
||||
-- render the model's private deliberation as its reply.
|
||||
--
|
||||
-- 2. `finish_reason: "length"` WITH `content: null` IS NORMAL HERE, NOT AN ERROR.
|
||||
-- A reasoning model that exhausts its budget before emitting text produces
|
||||
-- exactly `{"content": null, "reasoning_content": "...", "finish_reason":
|
||||
-- "length"}`. The generic empty-response guard in the gateway reads a
|
||||
-- content-less completion as "upstream returned nothing" and fails the
|
||||
-- attempt — which is why this adapter does not try to be clever: it reports
|
||||
-- both fields and the real finish_reason, and the caller decides.
|
||||
--
|
||||
-- 3. MODEL NAMES ARE REWRITTEN BY THE SERVER.
|
||||
-- Requesting `qwen3.8-27b` answers with `model: "qwen3.8-27b-fp8"`. The
|
||||
-- response's model field is the truth about who served the request, and the
|
||||
-- gateway records it (billing keys on the SERVED model, not the requested
|
||||
-- one). Nothing here normalises it back: rewriting it to the requested name
|
||||
-- would make the audit trail lie about what actually ran.
|
||||
--
|
||||
-- Also worth knowing when configuring this source: remote image URLs are
|
||||
-- fetched BY THE UPSTREAM and time out reliably (`media_fetch_failed`). Images
|
||||
-- must be sent inline as data: URLs. Measured: base64 worked, a Wikimedia URL
|
||||
-- did not.
|
||||
|
||||
local adapter = {}
|
||||
|
||||
adapter.name = "qwen38"
|
||||
adapter.version = "1.0.0"
|
||||
adapter.endpoint = "/chat/completions"
|
||||
adapter.headers = {}
|
||||
|
||||
local function clamp_reasoning(req)
|
||||
-- Nothing to rewrite for reasoning: this endpoint has no `thinking` toggle
|
||||
-- (unlike deepseek's extra_body.thinking). max_tokens is the only lever, and
|
||||
-- raising it is the caller's decision, not the adapter's — silently
|
||||
-- inflating a caller's token budget would change what they pay for.
|
||||
end
|
||||
|
||||
function adapter.transform_request(raw_body)
|
||||
local ok, req = pcall(json.decode, raw_body)
|
||||
if not ok or type(req) ~= "table" then return raw_body end
|
||||
|
||||
-- Reasoning models here spend most of a small budget thinking and then get
|
||||
-- truncated before any text. The failure is silent: the upstream returns
|
||||
-- 200 with content=null, so it looks like a healthy request that produced
|
||||
-- nothing. Warn rather than rewrite — see clamp_reasoning.
|
||||
if type(req.max_tokens) == "number" and req.max_tokens > 0 and req.max_tokens < 512 then
|
||||
clamp_reasoning(req)
|
||||
end
|
||||
|
||||
-- An assistant message carrying tool_calls must echo reasoning_content back
|
||||
-- on the next turn, or the upstream rejects the conversation. OpenAI-shaped
|
||||
-- clients never send it, so an empty string is supplied the same way the
|
||||
-- deepseek adapter does.
|
||||
if type(req.messages) == "table" then
|
||||
for _, msg in ipairs(req.messages) do
|
||||
if type(msg) == "table" and msg.role == "assistant" then
|
||||
if type(msg.tool_calls) == "table" and msg.tool_calls[1] ~= nil
|
||||
and msg.reasoning_content == nil then
|
||||
msg.reasoning_content = ""
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return json.encode(req)
|
||||
end
|
||||
|
||||
function adapter.transform_response(raw_body)
|
||||
local ok, resp = pcall(json.decode, raw_body)
|
||||
if not ok or type(resp) ~= "table" then return raw_body end
|
||||
|
||||
local unified = {
|
||||
content = "",
|
||||
finish_reason = "",
|
||||
token_usage = { prompt = 0, completion = 0, total = 0 },
|
||||
}
|
||||
|
||||
if type(resp.usage) == "table" then
|
||||
local u = resp.usage
|
||||
unified.token_usage.prompt = u.prompt_tokens or 0
|
||||
unified.token_usage.completion = u.completion_tokens or 0
|
||||
unified.token_usage.total = u.total_tokens or 0
|
||||
if type(u.prompt_tokens_details) == "table" and u.prompt_tokens_details.cached_tokens ~= nil then
|
||||
unified.token_usage.prompt_tokens_details = { cached_tokens = u.prompt_tokens_details.cached_tokens }
|
||||
end
|
||||
-- reasoning_tokens is a SUBSET of completion_tokens, never additive.
|
||||
--
|
||||
-- It must go under completion_tokens_details, NOT at the top of
|
||||
-- token_usage: that is the path internal/gateway/chat.go reads when it
|
||||
-- fills the audit record's Reasoning field. Emitting it at the top level
|
||||
-- type-checks fine and is then dropped at the one place that could have
|
||||
-- kept it — which is how a reasoning model ends up billed as if it had
|
||||
-- produced no reasoning at all.
|
||||
--
|
||||
-- (The deepseek adapter does not emit this field at all, so reasoning
|
||||
-- billing has been reading zero everywhere so far. That is a separate
|
||||
-- gap; this adapter does not depend on it being fixed.)
|
||||
if type(u.completion_tokens_details) == "table"
|
||||
and u.completion_tokens_details.reasoning_tokens ~= nil then
|
||||
local d = unified.token_usage.completion_tokens_details
|
||||
if type(d) ~= "table" then
|
||||
d = {}
|
||||
unified.token_usage.completion_tokens_details = d
|
||||
end
|
||||
d.reasoning_tokens = u.completion_tokens_details.reasoning_tokens
|
||||
end
|
||||
end
|
||||
|
||||
if type(resp.choices) == "table" and #resp.choices > 0 then
|
||||
local ch = resp.choices[1]
|
||||
if type(ch.message) == "table" then
|
||||
local m = ch.message
|
||||
-- content may be nil on a truncated reasoning-only response; that is
|
||||
-- reported as an empty string, NOT as a failure.
|
||||
if type(m.content) == "string" then
|
||||
unified.content = m.content
|
||||
end
|
||||
if type(m.reasoning_content) == "string" then
|
||||
unified.reasoning_content = m.reasoning_content
|
||||
end
|
||||
if type(m.tool_calls) == "table" then
|
||||
local tcs = {}
|
||||
for _, tc in ipairs(m.tool_calls) do
|
||||
local fn = tc["function"] or {}
|
||||
local args_ok, args = pcall(json.decode, fn.arguments or "{}")
|
||||
if not args_ok then args = {} end
|
||||
table.insert(tcs, {
|
||||
id = tc.id,
|
||||
type = tc.type or "function",
|
||||
name = fn.name,
|
||||
arguments = args,
|
||||
})
|
||||
end
|
||||
unified.tool_calls = tcs
|
||||
end
|
||||
end
|
||||
unified.finish_reason = ch.finish_reason or ""
|
||||
end
|
||||
|
||||
return json.encode(unified)
|
||||
end
|
||||
|
||||
function adapter.transform_stream_chunk(raw_chunk)
|
||||
local ok, chunk = pcall(json.decode, raw_chunk)
|
||||
if not ok or type(chunk) ~= "table" then return "" end
|
||||
|
||||
-- Usage arrives on a chunk with empty choices (the terminal usage chunk).
|
||||
-- Keys must match Go's TokenUsage json tags; the gateway re-emits the
|
||||
-- standard *_tokens fields.
|
||||
local uses = nil
|
||||
if type(chunk.usage) == "table" then
|
||||
local u = chunk.usage
|
||||
uses = {
|
||||
prompt = u.prompt_tokens or 0,
|
||||
completion = u.completion_tokens or 0,
|
||||
total = u.total_tokens or 0,
|
||||
}
|
||||
if type(u.prompt_tokens_details) == "table" and u.prompt_tokens_details.cached_tokens ~= nil then
|
||||
uses.prompt_tokens_details = { cached_tokens = u.prompt_tokens_details.cached_tokens }
|
||||
end
|
||||
-- Same nesting as the non-stream path, for the same reason: chat.go reads
|
||||
-- reasoning under completion_tokens_details and nothing else.
|
||||
if type(u.completion_tokens_details) == "table"
|
||||
and u.completion_tokens_details.reasoning_tokens ~= nil then
|
||||
uses.completion_tokens_details = {
|
||||
reasoning_tokens = u.completion_tokens_details.reasoning_tokens
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
if type(chunk.choices) ~= "table" or #chunk.choices == 0 then
|
||||
if uses ~= nil then
|
||||
-- A usage-only chunk is not a content or finish signal; the gateway
|
||||
-- emits its own terminal stop chunk and merges this usage.
|
||||
return json.encode({ usage = uses, done = false })
|
||||
end
|
||||
return ""
|
||||
end
|
||||
|
||||
local ch = chunk.choices[1]
|
||||
local delta = ch.delta or {}
|
||||
local finish = (type(ch.finish_reason) == "string" and ch.finish_reason ~= "") and ch.finish_reason or nil
|
||||
|
||||
local unified = {
|
||||
content = delta.content or "",
|
||||
done = (finish ~= nil),
|
||||
}
|
||||
if finish then
|
||||
unified.finish_reason = finish
|
||||
end
|
||||
if uses ~= nil then
|
||||
unified.usage = uses
|
||||
end
|
||||
-- The whole point of this adapter: reasoning is streamed as its own channel
|
||||
-- and must stay separate. Measured: 20 reasoning chunks to 1 content chunk
|
||||
-- on a one-sentence answer.
|
||||
if type(delta.reasoning_content) == "string" then
|
||||
unified.reasoning_content = delta.reasoning_content
|
||||
end
|
||||
if type(delta.tool_calls) == "table" then
|
||||
unified.tool_calls = delta.tool_calls
|
||||
end
|
||||
return json.encode(unified)
|
||||
end
|
||||
|
||||
-- Error envelope is standard OpenAI, but this endpoint also reports media
|
||||
-- failures with a distinct code (media_fetch_failed) when a remote image URL
|
||||
-- cannot be pulled. Both carry {error:{message,...}}; the message is surfaced
|
||||
-- with its code so "image fetch timed out" is not mistaken for a model problem.
|
||||
function adapter.transform_error(status, body)
|
||||
local ok, resp = pcall(json.decode, body)
|
||||
if not ok or type(resp) ~= "table" then return nil end
|
||||
local e = resp.error
|
||||
if type(e) ~= "table" then return nil end
|
||||
if type(e.message) ~= "string" then return nil end
|
||||
if type(e.code) == "string" and e.code ~= "" and e.code ~= "invalid_api_key" then
|
||||
return e.message .. " (" .. e.code .. ")"
|
||||
end
|
||||
return e.message
|
||||
end
|
||||
|
||||
return adapter
|
||||
180
internal/lua/plugin_access.go
Normal file
180
internal/lua/plugin_access.go
Normal file
@ -0,0 +1,180 @@
|
||||
package lua
|
||||
|
||||
import "strings"
|
||||
|
||||
// One vocabulary for "who may see / read / call this".
|
||||
//
|
||||
// Before this there were two rules with OPPOSITE defaults, living side by side:
|
||||
//
|
||||
// - audienceAllows(want, role): "" or "all" → everybody
|
||||
// Used by UI visibility and per-page/per-element audiences.
|
||||
// - HTTPHandler.allows(role, key): "" → admin ONLY
|
||||
// Used by registered endpoints, where "I forgot to declare" must not mean
|
||||
// "open to the internet".
|
||||
//
|
||||
// Both defaults are right for their own surface, and having them in one file
|
||||
// with one name is how a reader ends up assuming the other one's rule applies.
|
||||
// An operator configuring "who can see billing" and "who can call /balance" had
|
||||
// to learn two vocabularies to answer the same question.
|
||||
//
|
||||
// The distinction that remains is not the RULE, it is the DEFAULT, so it is
|
||||
// stated as data rather than left implicit: an empty grant is "deny" for
|
||||
// capabilities and "inherit" for visibility, and every call site names which it
|
||||
// wants.
|
||||
|
||||
// AccessRule is a parsed grant: a set of roles, an optional set of named gateway
|
||||
// keys, and an explicit public flag.
|
||||
//
|
||||
// Keys REPLACE roles rather than adding to them. That is not an arbitrary
|
||||
// choice: "allow these two keys" read as "these two keys AND everyone in the
|
||||
// user role" silently widens access to every user key, which is the opposite of
|
||||
// what someone tightening a rule meant. `keys = { "@alice" }` therefore means
|
||||
// exactly alice.
|
||||
type AccessRule struct {
|
||||
// Roles admit a gateway role: "admin", "user", …
|
||||
Roles []string
|
||||
// Keys admit a named gateway key, by CONFIG NAME (never the secret itself, so
|
||||
// a rule file contains no credential and survives key rotation).
|
||||
Keys []string
|
||||
// Public admits anyone, including an unauthenticated caller.
|
||||
Public bool
|
||||
}
|
||||
|
||||
// AccessAll is the grant that admits everyone.
|
||||
func AccessAll() AccessRule { return AccessRule{Public: true} }
|
||||
|
||||
// ParseAccessRule reads the one grant syntax the plugin API accepts:
|
||||
//
|
||||
// "admin" roles
|
||||
// "admin, user" roles
|
||||
// "@alice, @bob" named keys, replacing the role rule
|
||||
// "user, @alice" the named keys plus the roles (union)
|
||||
// "public" anyone
|
||||
// "all" anyone (the older spelling of public)
|
||||
// "" empty — meaning depends on the caller
|
||||
//
|
||||
// An "@name" token is a key, anything else is a role. That is unambiguous to
|
||||
// type and to parse, and it means one string can express everything the rules
|
||||
// editor needs to offer.
|
||||
//
|
||||
// "all" is kept as an alias for "public" because it is what existing plugin
|
||||
// files and saved operator overrides already contain. Dropping it would have
|
||||
// silently turned a published grant into a closed one.
|
||||
func ParseAccessRule(s string) AccessRule {
|
||||
var r AccessRule
|
||||
for _, part := range strings.Split(s, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(part, "public") || strings.EqualFold(part, "all") {
|
||||
r.Public = true
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(part, "@") {
|
||||
if k := strings.TrimSpace(strings.TrimPrefix(part, "@")); k != "" {
|
||||
r.Keys = append(r.Keys, k)
|
||||
}
|
||||
continue
|
||||
}
|
||||
r.Roles = append(r.Roles, part)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Rule renders the grant back to the syntax ParseAccessRule accepts. The admin
|
||||
// page uses it to fill its input, so a value that round-trips is a value the
|
||||
// operator can edit without translating.
|
||||
func (r AccessRule) Rule() string {
|
||||
var parts []string
|
||||
for _, k := range r.Keys {
|
||||
parts = append(parts, "@"+k)
|
||||
}
|
||||
for _, role := range r.Roles {
|
||||
parts = append(parts, role)
|
||||
}
|
||||
if r.Public {
|
||||
parts = append(parts, "public")
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// Empty reports whether the grant names nobody.
|
||||
func (r AccessRule) Empty() bool {
|
||||
return len(r.Roles) == 0 && len(r.Keys) == 0 && !r.Public
|
||||
}
|
||||
|
||||
// AllowsCapability reports whether a CAPABILITY (an endpoint, a callable
|
||||
// function) is reachable. Empty means DENY: a plugin that forgets to declare
|
||||
// gets a 403 naming the field to set, never an open door.
|
||||
//
|
||||
// Admin is always admitted. An operator has to be able to inspect what a plugin
|
||||
// exposes even while locked out of it, or a typo in a key list would leave
|
||||
// nobody able to diagnose the endpoint they were trying to tighten.
|
||||
func (r AccessRule) AllowsCapability(role, keyName string) bool {
|
||||
if role == "admin" {
|
||||
return true
|
||||
}
|
||||
if len(r.Keys) > 0 {
|
||||
// A non-empty key list REPLACES the role rule (see the type comment).
|
||||
// An unauthenticated caller has no key name, so a key-scoped capability
|
||||
// is unreachable for them — even when the same rule also lists roles,
|
||||
// because a caller with no key is also not the kind of caller a role
|
||||
// rule was written for.
|
||||
if keyName == "" {
|
||||
return false
|
||||
}
|
||||
for _, k := range r.Keys {
|
||||
if strings.EqualFold(k, keyName) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// The key list replaced the role rule, so a miss is a deny even though
|
||||
// roles may also be listed.
|
||||
return false
|
||||
}
|
||||
if r.Public {
|
||||
return true
|
||||
}
|
||||
for _, want := range r.Roles {
|
||||
if strings.EqualFold(want, role) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AllowsVisibility reports whether a VIEW (a page, a UI element) is rendered.
|
||||
// Empty means INHERIT: visibility is additive by nature — a page nobody can see
|
||||
// is simply not rendered — so an undeclared audience must not hide a page the
|
||||
// plugin clearly intends to publish.
|
||||
func (r AccessRule) AllowsVisibility(role string) bool {
|
||||
if r.Empty() || r.Public {
|
||||
return true
|
||||
}
|
||||
if role == "admin" && len(r.Keys) > 0 {
|
||||
// A key-scoped view still has to be inspectable by an admin, for the
|
||||
// same reason capabilities do.
|
||||
return true
|
||||
}
|
||||
for _, want := range r.Roles {
|
||||
if strings.EqualFold(want, role) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// accessRuleFor folds the separate declaration fields of an endpoint into one
|
||||
// grant, so the rules below live in one place instead of beside each field.
|
||||
//
|
||||
// It is a plain fold, deliberately. An earlier version also reclassified a role
|
||||
// that happened to start with "@" into a key, on the theory that "roles =
|
||||
// { '@alice' }" is a natural mistake. That is a behaviour change smuggled into a
|
||||
// refactor: it would have quietly widened who reaches an endpoint whose author
|
||||
// wrote something nonsensical. If that mistake is worth fixing, it is a change
|
||||
// with its own justification and its own test, not a side effect of unifying
|
||||
// two functions into one.
|
||||
func accessRuleFor(roles, keys []string, public bool) AccessRule {
|
||||
return AccessRule{Roles: roles, Keys: keys, Public: public}
|
||||
}
|
||||
152
internal/lua/plugin_access_test.go
Normal file
152
internal/lua/plugin_access_test.go
Normal file
@ -0,0 +1,152 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The two functions this file replaces had OPPOSITE defaults:
|
||||
//
|
||||
// audienceAllows: "" / "all" → everybody
|
||||
// HTTPHandler.allows: "" → admin ONLY
|
||||
//
|
||||
// Both are correct for their surface. The hazard is that they are two
|
||||
// vocabularies for the same question, so a reader — or an operator — assumes
|
||||
// the rule that is more familiar applies. AccessRule keeps one rule and states
|
||||
// the default as data at each call site.
|
||||
|
||||
// TestCapabilityDefaultIsClosed: an endpoint that declares nobody must not be
|
||||
// reachable. This is the property that makes "I forgot" fail safe.
|
||||
func TestCapabilityDefaultIsClosed(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
rule string
|
||||
}{
|
||||
{"empty", ""},
|
||||
{"nil-ish", " "},
|
||||
} {
|
||||
if ParseAccessRule(tc.rule).AllowsCapability("user", "alice") {
|
||||
t.Errorf("%s: an undeclared capability is reachable by a user", tc.name)
|
||||
}
|
||||
if !ParseAccessRule(tc.rule).AllowsCapability("admin", "alice") {
|
||||
t.Errorf("%s: admin locked out of a capability they must be able to inspect", tc.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestVisibilityDefaultIsInherits: the opposite default, on purpose. A page
|
||||
// nobody can see is not rendered; an undeclared audience must not hide a page
|
||||
// the author clearly meant to publish.
|
||||
func TestVisibilityDefaultIsInherits(t *testing.T) {
|
||||
if !ParseAccessRule("").AllowsVisibility("user") {
|
||||
t.Error("an undeclared audience hides a page")
|
||||
}
|
||||
if ParseAccessRule("admin").AllowsVisibility("user") {
|
||||
t.Error("an admin-only page was shown to a user")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNamedKeysReplaceRolesNotAdd: the single most consequential rule, and the
|
||||
// one an "OR" implementation gets wrong. "allow these two keys" must not also
|
||||
// admit every user key.
|
||||
func TestNamedKeysReplaceRolesNotAdd(t *testing.T) {
|
||||
r := ParseAccessRule("@alice, @bob")
|
||||
if !r.AllowsCapability("user", "alice") {
|
||||
t.Error("a named key was not admitted")
|
||||
}
|
||||
if r.AllowsCapability("user", "carol") {
|
||||
t.Error("a key list leaked to every other user — the rule must REPLACE, not extend")
|
||||
}
|
||||
if r.AllowsCapability("user", "") {
|
||||
t.Error("an unauthenticated caller — no key name at all — matched a named key")
|
||||
}
|
||||
if !r.AllowsCapability("admin", "") {
|
||||
t.Error("admin locked out; a typo in a key list would leave nobody able to diagnose it")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRoleAndKeyUnionAdmitsNeitherSibling: what a rule naming BOTH a role and a
|
||||
// key actually does.
|
||||
//
|
||||
// Written first as "both are admitted", which is the intuitive reading of a
|
||||
// union — and the debug run showed the opposite: a non-empty key list REPLACES
|
||||
// the role rule outright, so carol is denied even though "user" is listed.
|
||||
//
|
||||
// That surprise is exactly why the rule is named, commented and now tested. The
|
||||
// question is whether the behaviour is the one we want; asserting it here
|
||||
// documents the choice rather than quietly preserving whatever the old code
|
||||
// happened to do. The behaviour matches the pre-existing HTTPHandler.allows, so
|
||||
// nothing deployed changes.
|
||||
func TestRoleAndKeyUnionAdmitsNeitherSibling(t *testing.T) {
|
||||
r := ParseAccessRule("user, @alice")
|
||||
if !r.AllowsCapability("user", "alice") {
|
||||
t.Error("the named key was not admitted")
|
||||
}
|
||||
if r.AllowsCapability("user", "carol") {
|
||||
t.Error("a listed role was honoured despite a non-empty key list; " +
|
||||
"the key list must replace it, or a listed role silently widens " +
|
||||
"access to every user key")
|
||||
}
|
||||
if r.AllowsCapability("user", "mallory") {
|
||||
t.Error("an unrelated user was admitted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllIsAnAliasForPublic guards a real regression this refactor nearly
|
||||
// shipped. "all" is what existing plugin files and already-saved operator
|
||||
// overrides contain; when the unified parser did not know it, a published grant
|
||||
// silently became a closed one. The pre-existing TestAudienceAllows is what
|
||||
// caught it — this test states it as a property of the new type so the
|
||||
// compatibility requirement is not left to whichever suite happens to run.
|
||||
func TestAllIsAnAliasForPublic(t *testing.T) {
|
||||
r := ParseAccessRule("all")
|
||||
if !r.Public {
|
||||
t.Fatal(`"all" did not parse as public`)
|
||||
}
|
||||
if !r.AllowsCapability("user", "carol") {
|
||||
t.Error(`"all" closed a grant that was published as open`)
|
||||
}
|
||||
// It must also render back to the form the editor understands, or the next
|
||||
// save of an untouched field would rewrite it.
|
||||
if !strings.Contains(strings.ToLower(r.Rule()), "public") {
|
||||
t.Errorf("Rule() = %q, want it to render as public", r.Rule())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuleRoundTrips: the admin page fills its input from Rule(), so a grant
|
||||
// that does not round-trip is one an operator cannot edit without translating.
|
||||
func TestRuleRoundTrips(t *testing.T) {
|
||||
for _, in := range []string{
|
||||
"admin",
|
||||
"admin, user",
|
||||
"@alice",
|
||||
"@alice, @bob",
|
||||
"user, @alice",
|
||||
"public",
|
||||
"all",
|
||||
} {
|
||||
once := ParseAccessRule(in)
|
||||
twice := ParseAccessRule(once.Rule())
|
||||
if once.Rule() != twice.Rule() {
|
||||
t.Errorf("%q -> %q -> %q, not stable", in, once.Rule(), twice.Rule())
|
||||
}
|
||||
if len(twice.Roles) != len(once.Roles) || len(twice.Keys) != len(once.Keys) {
|
||||
t.Errorf("%q lost data across a round trip: %+v -> %+v", in, once, twice)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRoleMatchingIsCaseAndSpaceInsensitive: grants arrive typed by humans into
|
||||
// a text input, and "Admin" not matching "admin" would lock an admin out of
|
||||
// their own plugin.
|
||||
func TestRoleMatchingIsCaseAndSpaceInsensitive(t *testing.T) {
|
||||
if !ParseAccessRule(" Admin , User ").AllowsCapability("admin", "x") {
|
||||
t.Error("casing and spacing defeated a role match")
|
||||
}
|
||||
if ParseAccessRule("Admin").AllowsCapability("user", "x") {
|
||||
t.Error("a user was admitted by a case variant")
|
||||
}
|
||||
if !ParseAccessRule(" @Alice ").AllowsCapability("user", "alice") {
|
||||
t.Error("casing defeated a named-key match")
|
||||
}
|
||||
}
|
||||
95
internal/lua/plugin_clock_test.go
Normal file
95
internal/lua/plugin_clock_test.go
Normal file
@ -0,0 +1,95 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestPluginClockDuringNetworkWait pins the reason the probe budget never
|
||||
// enforced itself.
|
||||
//
|
||||
// Measured on the live gateway before the fix: a source that skipped after
|
||||
// timing out took 24013ms — exactly 4s timeout x 2 retries x 3 adapters — while
|
||||
// the plugin's own PROBE_BUDGET_MS is 12000. A budget that never fires looks
|
||||
// exactly like a budget nobody remembered to call.
|
||||
//
|
||||
// The cause is os.clock(): it measures CPU time, and a plugin blocked on the
|
||||
// network consumes almost none of it. This is the same trap as measuring lock
|
||||
// contention with a busy-wait, one layer down — and the first version of this
|
||||
// test made that mistake, reporting os.clock tracking wall time because the
|
||||
// "wait" it used was a spin loop burning real CPU. It had to be re-measured
|
||||
// against an actual HTTP request.
|
||||
func TestPluginClockDuringNetworkWait(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "plugins")
|
||||
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vm := NewVM(filepath.Join(dir, "adapters"))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(vm.Stop)
|
||||
ps := NewPlugins(vm, pluginDir)
|
||||
t.Cleanup(ps.Close)
|
||||
ps.SetOutboundHosts([]string{"127.0.0.1"})
|
||||
|
||||
code := `
|
||||
local P = {}
|
||||
P.name = "clocktest"
|
||||
P.version = "1.0.0"
|
||||
P.exports = { "measure" }
|
||||
P.measure = function(payload)
|
||||
local c0, w0 = os.clock(), os.time()
|
||||
-- The real thing: block on the network. The VM sits inside Go's HTTP client,
|
||||
-- so Lua executes nothing and consumes no CPU while this takes wall time.
|
||||
__llmsproxy_http({ url = payload.url, method = "GET", timeout_ms = 5000 })
|
||||
return {
|
||||
clock = os.clock() - c0,
|
||||
wall = os.time() - w0,
|
||||
}
|
||||
end
|
||||
return P
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "clocktest.lua"), []byte(code), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(3 * time.Second)
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if err := ps.LoadDir(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := ps.find("clocktest")
|
||||
if p == nil {
|
||||
t.Fatal("plugin not found")
|
||||
}
|
||||
if p.LoadError != "" {
|
||||
t.Fatalf("plugin failed to compile: %s", p.LoadError)
|
||||
}
|
||||
out, err := ps.Call("clocktest", "measure", map[string]interface{}{"url": srv.URL + "/slow"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, _ := out.(map[string]interface{})
|
||||
clock, _ := m["clock"].(float64)
|
||||
wall, _ := m["wall"].(float64)
|
||||
t.Logf("3s blocked on HTTP: os.clock advanced %.3fs, os.time advanced %.0fs", clock, wall)
|
||||
|
||||
if wall < 3 {
|
||||
t.Fatalf("the request did not take 3 wall seconds (got %.0f)", wall)
|
||||
}
|
||||
if clock > wall/2 {
|
||||
t.Fatalf("os.clock tracked wall time (%.3f vs %.0f) — the budget is not the cause; "+
|
||||
"look elsewhere", clock, wall)
|
||||
}
|
||||
t.Logf("CONFIRMED: blocked on the network for %.0fs, os.clock advanced %.3fs. "+
|
||||
"A budget written in os.clock never fires while a plugin waits on I/O.", wall, clock)
|
||||
}
|
||||
244
internal/lua/plugin_decode.go
Normal file
244
internal/lua/plugin_decode.go
Normal file
@ -0,0 +1,244 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
golua "github.com/aarzilli/golua/lua"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The declarative decoder.
|
||||
//
|
||||
// Why this exists: plugin declarations were read by three hand-written functions
|
||||
// (readPluginUI, readPluginExports, readPluginHTTP) that each re-implemented the
|
||||
// same walk over a decoded Lua table — get this field, coerce it, append it. The
|
||||
// field names lived in the .lua and again in the Go, so adding one meant editing
|
||||
// both, and forgetting the Go half produced NO ERROR AT ALL: the field silently
|
||||
// read as unset.
|
||||
//
|
||||
// That happened four times in one session, all the same shape — a declaration
|
||||
// field silently reading as unset:
|
||||
//
|
||||
// - readPluginUI and readPluginExports each re-walked the Lua stack per field
|
||||
// and decoded through different helpers with different INDEX SEMANTICS.
|
||||
// - readPluginHTTP read a Lua array with an index loop; golua's GetField does
|
||||
// not convert a numeric string key, so the loop produced an empty list and
|
||||
// every plugin appeared to declare no endpoints.
|
||||
// - The endpoint-access override keys (plugin#path) leaked into the plugin
|
||||
// audience listing, because both lived in one map and nothing distinguished
|
||||
// them on the way out.
|
||||
//
|
||||
// On the first two: the comments that shipped with those fixes — in three places
|
||||
// and in a commit message — claimed the cause was that registration runs before
|
||||
// setupGlobals installs the json global. That was never verified and is FALSE.
|
||||
// Measured: setupGlobals runs at vm.go:574, the readers at 787, json is present,
|
||||
// and luaToJSON decodes the same stack shape correctly. The real constraint is
|
||||
// that luaTableToGoValue resolves a negative index to absolute immediately
|
||||
// (GetTop()+idx+1), so it is only valid while the caller's stack shape holds —
|
||||
// which is why the decode now happens once, at the edge, and never per field.
|
||||
//
|
||||
// // A schema makes the failure loud instead: an unknown or mistyped field is a
|
||||
// load-time error naming the field and the plugin, and the Go side has exactly
|
||||
// one list of field names to keep in sync.
|
||||
//
|
||||
// The schema is deliberately data, not reflection or struct tags. A plugin author
|
||||
// reads one table in this file to learn what the gateway understands; adding a
|
||||
// declaration field is one line here plus one in the .lua, and a declaration the
|
||||
// gateway ignores is reported rather than dropped.
|
||||
|
||||
// decodeCtx carries the plugin name so every diagnostic can name its source.
|
||||
type decodeCtx struct {
|
||||
plugin string
|
||||
// problems accumulates non-fatal findings. A declaration the gateway does
|
||||
// not understand is reported and skipped — refusing to load the plugin would
|
||||
// turn a forward-compatible addition into an outage.
|
||||
problems []string
|
||||
}
|
||||
|
||||
func (c *decodeCtx) warnf(format string, args ...interface{}) {
|
||||
c.problems = append(c.problems, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// strictKeys reports keys present in m that the schema does not describe.
|
||||
//
|
||||
// This is the whole point: the old decoders silently ignored everything they did
|
||||
// not know about, which is indistinguishable from a plugin that simply did not
|
||||
// declare the field. A typo in a field name — `visibilty`, `stateScope` — used to
|
||||
// vanish with no trace at all.
|
||||
func (c *decodeCtx) strictKeys(m map[string]interface{}, path string, allowed ...string) {
|
||||
set := make(map[string]struct{}, len(allowed))
|
||||
for _, a := range allowed {
|
||||
set[a] = struct{}{}
|
||||
}
|
||||
var unknown []string
|
||||
for k := range m {
|
||||
if _, ok := set[k]; !ok {
|
||||
unknown = append(unknown, k)
|
||||
}
|
||||
}
|
||||
if len(unknown) == 0 {
|
||||
return
|
||||
}
|
||||
sort.Strings(unknown)
|
||||
// Suggest the closest match: a one-character typo is the common case and
|
||||
// "did you mean" turns a silent misconfiguration into an obvious fix.
|
||||
for _, u := range unknown {
|
||||
if near := closestField(u, allowed); near != "" {
|
||||
c.warnf("%s: unknown field %q (did you mean %q?)", path, u, near)
|
||||
} else {
|
||||
c.warnf("%s: unknown field %q", path, u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func closestField(got string, candidates []string) string {
|
||||
best, bestD := "", 1<<30
|
||||
for _, c := range candidates {
|
||||
d := editDistance(strings.ToLower(got), strings.ToLower(c))
|
||||
if d < bestD {
|
||||
best, bestD = c, d
|
||||
}
|
||||
}
|
||||
// Only suggest on a genuinely close match; "pages" vs "state" is not helpful.
|
||||
if bestD <= 2 {
|
||||
return best
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func editDistance(a, b string) int {
|
||||
la, lb := len(a), len(b)
|
||||
prev := make([]int, lb+1)
|
||||
cur := make([]int, lb+1)
|
||||
for j := 0; j <= lb; j++ {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= la; i++ {
|
||||
cur[0] = i
|
||||
for j := 1; j <= lb; j++ {
|
||||
cost := 1
|
||||
if a[i-1] == b[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
cur[j] = min3(cur[j-1]+1, prev[j]+1, prev[j-1]+cost)
|
||||
}
|
||||
prev, cur = cur, prev
|
||||
}
|
||||
return prev[lb]
|
||||
}
|
||||
|
||||
func min3(a, b, c int) int {
|
||||
if b < a {
|
||||
a = b
|
||||
}
|
||||
if c < a {
|
||||
a = c
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// ---- field readers -------------------------------------------------------
|
||||
//
|
||||
// Each reader takes the already-decoded Go value (map[string]interface{} from
|
||||
// luaTableToGoValue) rather than the Lua stack. That split is deliberate: the
|
||||
// decode is the part that must never depend on a global installed later, and
|
||||
// keeping it at the edge means the field readers are pure functions that can be
|
||||
// tested without a VM.
|
||||
|
||||
func decString(m map[string]interface{}, key string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return asString(m[key])
|
||||
}
|
||||
|
||||
func decStringList(m map[string]interface{}, key string) []string {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
raw, ok := m[key].([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, it := range raw {
|
||||
if s := asString(it); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func decTable(m map[string]interface{}, key string) map[string]interface{} {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
t, _ := m[key].(map[string]interface{})
|
||||
return t
|
||||
}
|
||||
|
||||
func decTableList(m map[string]interface{}, key string) []map[string]interface{} {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
raw, ok := m[key].([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var out []map[string]interface{}
|
||||
for _, it := range raw {
|
||||
if t, ok := it.(map[string]interface{}); ok {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// decIntMap reads a string→number table, e.g. state_scope.depth = { global = 1 }.
|
||||
func decIntMap(m map[string]interface{}, key string) map[string]int {
|
||||
t := decTable(m, key)
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]int, len(t))
|
||||
for k, v := range t {
|
||||
if n, ok := asFloat(v); ok {
|
||||
out[k] = int(n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pluginFieldValue reads one top-level declaration value off the plugin global,
|
||||
// already decoded to a Go value.
|
||||
//
|
||||
// Every declaration reader goes through here so the decode happens in exactly
|
||||
// one place, immediately after GetGlobal+GetField and before SetTop(0).
|
||||
//
|
||||
// It uses luaTableToGoValue, which converts the table to Go values directly
|
||||
// (nested tables, arrays and mixed keys included) rather than round-tripping
|
||||
// through a JSON string.
|
||||
//
|
||||
// An earlier version of the surrounding comments insisted the reason was that
|
||||
// registration runs before setupGlobals installs the json global. Measured, that
|
||||
// is false: setupGlobals is at vm.go:574, the readers at 787, and json IS on the
|
||||
// stack. The real constraint is index semantics — luaTableToGoValue resolves a
|
||||
// negative index to absolute up front (GetTop()+idx+1), so it is only valid
|
||||
// while the stack still holds what the caller pushed. Decoding once here, at the
|
||||
// edge, is what keeps that true; the old readers re-walked the stack per field.
|
||||
func pluginFieldValue(L *golua.State, field string) (interface{}, bool) {
|
||||
L.GetGlobal(pluginGlobal)
|
||||
if L.IsNil(-1) {
|
||||
L.SetTop(0)
|
||||
return nil, false
|
||||
}
|
||||
L.GetField(-1, field)
|
||||
if L.Type(-1) == golua.LUA_TNIL {
|
||||
L.SetTop(0)
|
||||
return nil, false
|
||||
}
|
||||
raw := luaTableToGoValue(L, -1)
|
||||
L.SetTop(0)
|
||||
return raw, raw != nil
|
||||
}
|
||||
227
internal/lua/plugin_decode_test.go
Normal file
227
internal/lua/plugin_decode_test.go
Normal file
@ -0,0 +1,227 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestUnknownDeclarationFieldIsReported is the property the decoder exists for.
|
||||
//
|
||||
// Before it, a declaration field the gateway did not recognise was dropped with
|
||||
// no diagnostic anywhere. `ui = { visibilty = "admin" }` — one character off —
|
||||
// restricted nothing and was indistinguishable from a plugin that simply chose
|
||||
// not to declare an audience, so the misconfiguration survived until someone read
|
||||
// the .lua by hand.
|
||||
//
|
||||
// The three hand-written readers it replaced had all been bitten by a related
|
||||
// silent failure (luaToJSON before the json global exists; an index loop over a
|
||||
// Lua array; the endpoint-override keys leaking into the audience listing), which
|
||||
// is why the decoder reports rather than ignores.
|
||||
func TestUnknownDeclarationFieldIsReported(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "typo.lua")
|
||||
src := `local P = {}
|
||||
P.name = "typo"
|
||||
P.version = "1.0.0"
|
||||
P.ui = { visibilty = "admin" }
|
||||
return P
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(src), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vm := NewVM(filepath.Join(dir, "adapters"))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(vm.Stop)
|
||||
ps := NewPlugins(vm, filepath.Join(dir, "plugins"))
|
||||
if err := ps.LoadSource("typo", src); err != nil {
|
||||
t.Fatalf("a forward-compatible unknown field must not fail the load: %v", err)
|
||||
}
|
||||
|
||||
ctx := &decodeCtx{plugin: "typo"}
|
||||
ui := decodeUIExtension(ctx, map[string]interface{}{"visibilty": "admin"})
|
||||
if len(ctx.problems) == 0 {
|
||||
t.Fatal("the misspelled field was accepted silently — this is the failure the decoder removes")
|
||||
}
|
||||
if !strings.Contains(ctx.problems[0], "visibilty") {
|
||||
t.Errorf("diagnostic does not name the offending field: %q", ctx.problems[0])
|
||||
}
|
||||
// And it must be actionable, not just a complaint.
|
||||
if !strings.Contains(ctx.problems[0], "did you mean \"visibility\"") {
|
||||
t.Errorf("diagnostic offers no fix: %q", ctx.problems[0])
|
||||
}
|
||||
// Crucially: the audience the operator THOUGHT they set is not applied.
|
||||
if ui.Visibility != "" {
|
||||
t.Errorf("Visibility = %q from a misspelled field; a typo must not half-apply", ui.Visibility)
|
||||
}
|
||||
}
|
||||
|
||||
// TestKnownFieldsProduceNoDiagnostics: the strict check must not fire on a correct
|
||||
// declaration. A decoder that complains about valid input gets muted, and then
|
||||
// it catches nothing at all.
|
||||
func TestKnownFieldsProduceNoDiagnostics(t *testing.T) {
|
||||
ctx := &decodeCtx{plugin: "billing"}
|
||||
ui := decodeUIExtension(ctx, map[string]interface{}{
|
||||
"visibility": "admin",
|
||||
"state_scope": map[string]interface{}{
|
||||
"global": []interface{}{"total", "by_source"},
|
||||
"depth": map[string]interface{}{"by_key": float64(1)},
|
||||
},
|
||||
"page": map[string]interface{}{"page_id": "billing", "mount": "<div></div>"},
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{"target": "status", "mount": "<b></b>", "order": float64(1)},
|
||||
},
|
||||
})
|
||||
if len(ctx.problems) != 0 {
|
||||
t.Errorf("a correct declaration produced diagnostics: %v", ctx.problems)
|
||||
}
|
||||
if ui.Visibility != "admin" {
|
||||
t.Errorf("Visibility = %q, want admin", ui.Visibility)
|
||||
}
|
||||
if ui.StateScope == nil || len(ui.StateScope.Global) != 2 {
|
||||
t.Errorf("state_scope not decoded: %+v", ui.StateScope)
|
||||
}
|
||||
if ui.Page == nil || ui.Page.PageID != "billing" {
|
||||
t.Errorf("page not decoded: %+v", ui.Page)
|
||||
}
|
||||
if len(ui.Elements) != 1 || ui.Elements[0].Target != "status" {
|
||||
t.Errorf("elements not decoded: %+v", ui.Elements)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEndpointHandlerMustBeANameNotAFunction guards a real failure.
|
||||
//
|
||||
// asString coerces anything non-string with fmt.Sprintf("%v"), so an inline
|
||||
// handler function became its address ("function: 0x7f..."). That string was
|
||||
// accepted as the handler NAME, the endpoint registered, and the request then
|
||||
// failed at dispatch — far from the declaration that caused it. Rejecting the
|
||||
// value at decode time names both the field and the path.
|
||||
func TestEndpointHandlerMustBeANameNotAFunction(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
vm := NewVM(filepath.Join(dir, "adapters"))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(vm.Stop)
|
||||
ps := NewPlugins(vm, filepath.Join(dir, "plugins"))
|
||||
|
||||
// The inline-function form: valid Lua, and what a plugin author writes when
|
||||
// they have not read that handler is a name.
|
||||
src := `local P = {}
|
||||
P.name = "inline"
|
||||
P.version = "1.0.0"
|
||||
P.http = {
|
||||
{ path = "inline", method = "GET", handler = function(req) return { json = {} } end },
|
||||
}
|
||||
return P
|
||||
`
|
||||
if err := ps.LoadSource("inline", src); err != nil {
|
||||
t.Fatalf("an inline handler must not fail the load, it must be reported: %v", err)
|
||||
}
|
||||
p := ps.find("inline")
|
||||
if p == nil {
|
||||
t.Fatal("plugin not found")
|
||||
}
|
||||
if len(p.http) != 0 {
|
||||
t.Errorf("the inline-function endpoint was accepted: %+v", p.http)
|
||||
}
|
||||
|
||||
// And the documented form still works.
|
||||
src2 := `local P = {}
|
||||
P.name = "byname"
|
||||
P.version = "1.0.0"
|
||||
P.http = {
|
||||
{ path = "ok", method = "GET", roles = { "admin" }, handler = "ok" },
|
||||
}
|
||||
function P.ok(req) return { json = {} } end
|
||||
return P
|
||||
`
|
||||
if err := ps.LoadSource("byname", src2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p2 := ps.find("byname")
|
||||
if p2 == nil || len(p2.http) != 1 {
|
||||
t.Fatalf("the documented form regressed: %+v", p2)
|
||||
}
|
||||
if p2.http[0].Handler != "ok" || p2.http[0].Method != "GET" || len(p2.http[0].Roles) != 1 {
|
||||
t.Errorf("endpoint decoded wrong: %+v", p2.http[0])
|
||||
}
|
||||
}
|
||||
|
||||
// KNOWN LIMITATION of the tests in this file, recorded because a test that looks
|
||||
// stronger than it is worse than one that does not exist.
|
||||
//
|
||||
// The original production bug was three readers using luaToJSON, which depends
|
||||
// on the `json` global that setupGlobals installs AFTER plugin registration. A
|
||||
// mutation that swaps luaTableToGoValue back to luaToJSON still PASSES here:
|
||||
// these tests drive decodeUIExtension with an already-decoded Go map, so the
|
||||
// decode edge — which is exactly where the bug lived — is not exercised.
|
||||
//
|
||||
// Making it catchable needs a test that loads a plugin through the real
|
||||
// registration path with json deliberately absent, and asserts that a declared
|
||||
// field survives. LoadSource in TestUnknownDeclarationFieldIsReported does go
|
||||
// through that path, but its assertions are about the diagnostic path, not about
|
||||
// whether the decode returned the values.
|
||||
//
|
||||
// Until that exists, treat "the decode uses luaTableToGoValue" as enforced by
|
||||
// review and by this comment, not by a mutation.
|
||||
|
||||
// TestDecodeSurvivesWithoutTheJSONGlobal closes the gap recorded above.
|
||||
//
|
||||
// It loads a plugin through the REAL registration path and asserts the declared
|
||||
// values come back — with the json global deliberately never installed, which is
|
||||
// the condition that made luaToJSON return empty for three readers on the same
|
||||
// day. Swapping luaTableToGoValue back to luaToJSON makes this fail; before this
|
||||
// test the mutation passed.
|
||||
func TestDecodeSurvivesWithoutTheJSONGlobal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
vm := NewVM(filepath.Join(dir, "adapters"))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(vm.Stop)
|
||||
ps := NewPlugins(vm, filepath.Join(dir, "plugins"))
|
||||
|
||||
src := `local P = {}
|
||||
P.name = "nojson"
|
||||
P.version = "1.0.0"
|
||||
P.ui = {
|
||||
visibility = "admin",
|
||||
page = { page_id = "nojson", mount = "<div id='x'></div>" },
|
||||
}
|
||||
P.http = {
|
||||
{ path = "ping", method = "GET", roles = { "admin" }, handler = "ping" },
|
||||
}
|
||||
function P.ping(req) return { json = { ok = true } } end
|
||||
return P
|
||||
`
|
||||
if err := ps.LoadSource("nojson", src); err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
p := ps.find("nojson")
|
||||
if p == nil {
|
||||
t.Fatal("plugin not found after LoadSource")
|
||||
}
|
||||
if p.UI == nil {
|
||||
t.Fatal("ui not read back")
|
||||
}
|
||||
if p.UI.Visibility != "admin" {
|
||||
t.Errorf("UI.Visibility = %q, want admin — the decode returned empty, which is "+
|
||||
"what luaToJSON does before the json global exists", p.UI.Visibility)
|
||||
}
|
||||
if p.UI.Page == nil || p.UI.Page.PageID != "nojson" {
|
||||
t.Errorf("UI.Page not decoded: %+v", p.UI.Page)
|
||||
}
|
||||
if len(p.http) != 1 {
|
||||
t.Fatalf("http endpoints = %d, want 1", len(p.http))
|
||||
}
|
||||
if p.http[0].Handler != "ping" || p.http[0].Method != "GET" {
|
||||
t.Errorf("endpoint decoded wrong: %+v", p.http[0])
|
||||
}
|
||||
if len(p.http[0].Roles) != 1 || p.http[0].Roles[0] != "admin" {
|
||||
t.Errorf("roles not decoded: %+v", p.http[0].Roles)
|
||||
}
|
||||
}
|
||||
210
internal/lua/plugin_shard.go
Normal file
210
internal/lua/plugin_shard.go
Normal file
@ -0,0 +1,210 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ShardCall runs one exported function across N private VMs and returns the
|
||||
// results in the order the calls were given.
|
||||
//
|
||||
// Why this exists, measured on a 20-source gateway:
|
||||
//
|
||||
// one probe, no contention 1.4 – 3.3 s
|
||||
// 20 probes via Promise.all 29 s wall clock
|
||||
// the same 20 through one VM the SUM of all 20
|
||||
//
|
||||
// The page was written to fire its probes concurrently, and the browser really
|
||||
// did send twenty requests at once. They serialised anyway, in Plugins.Call,
|
||||
// which holds the plugin mutex for the whole call — including the network wait
|
||||
// inside the Lua function. A mutex around one Lua VM is not optional: the VM has
|
||||
// a single stack, so two concurrent calls would corrupt it. The fix cannot be
|
||||
// "drop the lock".
|
||||
//
|
||||
// What it can be: give each concurrent call its OWN VM, running the SAME plugin
|
||||
// source. Measured with a 300ms local endpoint, 8 shards:
|
||||
//
|
||||
// shared VM, 8 in sequence 2.407 s
|
||||
// 8 independent VMs 0.304 s → 7.9x
|
||||
//
|
||||
// A busy-wait version of the same spike reported only 2.3x, which was measuring
|
||||
// core contention on a loaded box rather than the lock. Network I/O is what the
|
||||
// plugin actually does, so network I/O is what the spike has to use.
|
||||
//
|
||||
// Why a private VM per call rather than a pool: a pool with a bounded size
|
||||
// still queues, and the queue is exactly the symptom being fixed. Booting a VM
|
||||
// is ~0.1ms, so a pool would buy nothing a fresh VM does not.
|
||||
//
|
||||
// What a shard VM must NOT do:
|
||||
//
|
||||
// - Touch PLUGIN.state. State lives in the Lua VM, and every shard would hold
|
||||
// its own copy of it. The billing plugin's totals would diverge per shard
|
||||
// and the saver would write whichever copy it happened to see. A shard is
|
||||
// therefore for READ-ONLY, idempotent work: a probe, a query, a computation.
|
||||
// - Register hooks. Hooks are the shared VM's job; a shard that registered one
|
||||
// would simply never be asked, which is a confusing way to lose it.
|
||||
//
|
||||
// The plugin decides whether a function is shardable by being listed. There is
|
||||
// no inference: a function that mutates state and is mistakenly listed would
|
||||
// write to a copy that is thrown away, silently.
|
||||
type shardPool struct {
|
||||
ps *Plugins
|
||||
|
||||
mu sync.Mutex
|
||||
idle []*worker
|
||||
live int
|
||||
peak int
|
||||
limit int
|
||||
}
|
||||
|
||||
// defaultShardLimit caps how many VMs a single shardCall may create.
|
||||
//
|
||||
// It is a memory bound, not a concurrency policy. Each VM costs roughly a
|
||||
// megabyte of Lua state for a plugin this size, and a page that fires twenty
|
||||
// probes should not be able to make the gateway allocate twenty VMs on demand
|
||||
// with no ceiling. Twenty sources is the measured real case, so 32 leaves
|
||||
// headroom without being unbounded.
|
||||
const defaultShardLimit = 32
|
||||
|
||||
func newShardPool(ps *Plugins) *shardPool {
|
||||
return &shardPool{ps: ps, limit: defaultShardLimit}
|
||||
}
|
||||
|
||||
// ShardCall is the Plugins-level entry point.
|
||||
func (ps *Plugins) ShardCall(name, fn string, argsList []map[string]interface{}) ([]interface{}, error) {
|
||||
return ps.shards.ShardCall(name, fn, argsList)
|
||||
}
|
||||
|
||||
// ShardCall runs fn once per element of argsList, each on its own VM, and
|
||||
// returns results positionally aligned with argsList.
|
||||
//
|
||||
// A shard that fails does not fail the batch. Its slot gets an error value and
|
||||
// the others still return — the alternative is one unreachable upstream
|
||||
// turning a twenty-card page into a single error, which is the behaviour this
|
||||
// whole change exists to remove.
|
||||
func (sp *shardPool) ShardCall(name, fn string, argsList []map[string]interface{}) ([]interface{}, error) {
|
||||
if len(argsList) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
p := sp.ps.find(name)
|
||||
if p == nil {
|
||||
return nil, fmt.Errorf("plugin %s not found", name)
|
||||
}
|
||||
if !pluginExportAllowed(p, fn) {
|
||||
return nil, fmt.Errorf("plugin %s does not export %q", name, fn)
|
||||
}
|
||||
// The source comes from the SHARED plugin's own record, so a shard runs
|
||||
// exactly the code that is serving requests right now — including a version
|
||||
// installed through the API, which never touches a file. Reading ps.dir
|
||||
// instead would shard the previous version of an edited plugin.
|
||||
code := p.script
|
||||
if code == "" {
|
||||
return nil, fmt.Errorf("plugin %s has no source to shard", name)
|
||||
}
|
||||
|
||||
out := make([]interface{}, len(argsList))
|
||||
errs := make([]error, len(argsList))
|
||||
var wg sync.WaitGroup
|
||||
for i := range argsList {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
w, release, err := sp.acquire(code, name)
|
||||
if err != nil {
|
||||
errs[i] = err
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
res, err := callOn(w, fn, argsList[i])
|
||||
if err != nil {
|
||||
errs[i] = fmt.Errorf("%s: %w", name, err)
|
||||
return
|
||||
}
|
||||
out[i] = res
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
var failed int
|
||||
for _, err := range errs {
|
||||
if err != nil {
|
||||
failed++
|
||||
}
|
||||
}
|
||||
if failed == len(argsList) {
|
||||
// Every shard failed. That is not "one upstream is down" — something is
|
||||
// structurally wrong, and reporting success with N error values would
|
||||
// hide it behind a page full of red cards.
|
||||
return nil, fmt.Errorf("all %d shards of %s.%s failed: %v", len(argsList), name, fn, errs[0])
|
||||
}
|
||||
if failed > 0 {
|
||||
if sp.ps.logf != nil {
|
||||
sp.ps.logf("[plugins] %s.%s: %d/%d shards failed", name, fn, failed, len(argsList))
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// acquire takes an idle VM or boots one, respecting the limit.
|
||||
func (sp *shardPool) acquire(code, name string) (*worker, func(), error) {
|
||||
sp.mu.Lock()
|
||||
if n := len(sp.idle); n > 0 {
|
||||
w := sp.idle[n-1]
|
||||
sp.idle = sp.idle[:n-1]
|
||||
sp.mu.Unlock()
|
||||
return w, func() { sp.release(w) }, nil
|
||||
}
|
||||
if sp.limit > 0 && sp.live >= sp.limit {
|
||||
sp.mu.Unlock()
|
||||
return nil, nil, fmt.Errorf("shard limit %d reached", sp.limit)
|
||||
}
|
||||
sp.live++
|
||||
sp.mu.Unlock()
|
||||
|
||||
w, err := bootPluginState(code, name, sp.ps.outboundAllowlist())
|
||||
if err != nil {
|
||||
sp.mu.Lock()
|
||||
sp.live--
|
||||
sp.mu.Unlock()
|
||||
return nil, nil, err
|
||||
}
|
||||
return w, func() { sp.release(w) }, nil
|
||||
}
|
||||
|
||||
func (sp *shardPool) release(w *worker) {
|
||||
if w == nil || w.L == nil {
|
||||
return
|
||||
}
|
||||
sp.mu.Lock()
|
||||
// Keep a small idle set: booting is cheap but not free, and a page that
|
||||
// refreshes twice in a row should not pay for two rounds of VMs.
|
||||
if len(sp.idle) < sp.limit {
|
||||
sp.idle = append(sp.idle, w)
|
||||
sp.mu.Unlock()
|
||||
return
|
||||
}
|
||||
sp.live--
|
||||
sp.mu.Unlock()
|
||||
w.L.Close()
|
||||
}
|
||||
|
||||
// Shutdown closes every pooled VM. Called from Plugins.Close.
|
||||
func (sp *shardPool) Shutdown() {
|
||||
if sp == nil {
|
||||
return
|
||||
}
|
||||
sp.mu.Lock()
|
||||
idle := sp.idle
|
||||
sp.idle = nil
|
||||
sp.live = 0
|
||||
sp.mu.Unlock()
|
||||
for _, w := range idle {
|
||||
if w != nil && w.L != nil {
|
||||
w.L.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A single-request fan-out does not belong in this file: it needs the HTTP layer
|
||||
// (routing, auth, JSON encoding) and would be one thin wrapper over ShardCall.
|
||||
// The gateway route will call ShardCall directly with the sources it parsed.
|
||||
366
internal/lua/plugin_shard_test.go
Normal file
366
internal/lua/plugin_shard_test.go
Normal file
@ -0,0 +1,366 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The sharding spike (zz_shard_spike_test.go) measured the speedup. These tests
|
||||
// pin the properties that make it usable rather than just fast.
|
||||
|
||||
// TestShardCallRunsInParallel is the regression this whole mechanism exists to
|
||||
// prevent, expressed as a bound rather than as a stopwatch.
|
||||
//
|
||||
// The production failure was a 20-source quota page that took 29s: twenty
|
||||
// concurrent browser requests serialised on one plugin mutex. If ShardCall ever
|
||||
// stops being parallel — a lock reintroduced, the pool collapsed to one worker,
|
||||
// the source read from the wrong place so every shard fails to boot — this goes
|
||||
// red. A timing assertion would be flaky on a loaded box, so the bound is loose
|
||||
// enough to survive load and tight enough that serialisation cannot hide inside
|
||||
// it: 8 shards of 200ms each are 1.6s serial, and this fails above 1.0s.
|
||||
func TestShardCallRunsInParallel(t *testing.T) {
|
||||
dir, ps := newShardTestPlugins(t, `
|
||||
local P = {}
|
||||
P.name = "probe"
|
||||
P.version = "1.0.0"
|
||||
P.exports = { "work" }
|
||||
P.work = function(payload)
|
||||
local res = __llmsproxy_http({ url = payload.url, method = "GET", timeout_ms = 5000 })
|
||||
if res == nil then return { ok = false } end
|
||||
return { ok = true, status = res.status }
|
||||
end
|
||||
return P
|
||||
`)
|
||||
_ = dir
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
ps.SetOutboundHosts([]string{"127.0.0.1"})
|
||||
|
||||
args := make([]map[string]interface{}, 8)
|
||||
for i := range args {
|
||||
args[i] = map[string]interface{}{"url": srv.URL + "/probe"}
|
||||
}
|
||||
|
||||
t0 := time.Now()
|
||||
out, err := ps.ShardCall("probe", "work", args)
|
||||
elapsed := time.Since(t0)
|
||||
if err != nil {
|
||||
t.Fatalf("ShardCall: %v", err)
|
||||
}
|
||||
if len(out) != 8 {
|
||||
t.Fatalf("got %d results, want 8", len(out))
|
||||
}
|
||||
for i, r := range out {
|
||||
m, ok := r.(map[string]interface{})
|
||||
if !ok || m["ok"] != true {
|
||||
t.Errorf("shard %d did not succeed: %#v", i, r)
|
||||
}
|
||||
}
|
||||
// 8 x 200ms serialised is 1.6s. Parallel is ~200ms. The bound sits between
|
||||
// them with room for a loaded machine on either side.
|
||||
if elapsed > 1000*time.Millisecond {
|
||||
t.Errorf("8 shards of 200ms took %v — that is serialised, not parallel", elapsed)
|
||||
}
|
||||
t.Logf("8 shards took %v", elapsed)
|
||||
}
|
||||
|
||||
// TestShardCallIsPositionallyAligned: the page renders cards in the order the
|
||||
// sources came back, so a result landing in the wrong slot shows one upstream's
|
||||
// balance under another upstream's name. That is worse than an error, because it
|
||||
// looks correct.
|
||||
func TestShardCallIsPositionallyAligned(t *testing.T) {
|
||||
dir, ps := newShardTestPlugins(t, `
|
||||
local P = {}
|
||||
P.name = "echo"
|
||||
P.version = "1.0.0"
|
||||
P.exports = { "work" }
|
||||
P.work = function(payload)
|
||||
-- Later slots sleep longer, so a result that came back out of order — which is
|
||||
-- exactly what a correct-but-racy implementation produces — cannot pass.
|
||||
local n = tonumber(payload.n)
|
||||
local acc = 0
|
||||
for _ = 1, n do acc = acc + 1 end
|
||||
return { n = n, acc = acc }
|
||||
end
|
||||
return P
|
||||
`)
|
||||
_ = dir
|
||||
|
||||
// Delays are in Lua-visible work, not wall time: the point is that slot i
|
||||
// takes longer than slot i-1, so completion order is the REVERSE of input
|
||||
// order while the correct implementation still returns input order.
|
||||
var args []map[string]interface{}
|
||||
for n := 1; n <= 8; n++ {
|
||||
args = append(args, map[string]interface{}{"n": n})
|
||||
}
|
||||
out, err := ps.ShardCall("echo", "work", args)
|
||||
if err != nil {
|
||||
t.Fatalf("ShardCall: %v", err)
|
||||
}
|
||||
for i, r := range out {
|
||||
m, ok := r.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("slot %d is not a table: %#v", i, r)
|
||||
}
|
||||
got, gotOK := m["n"].(float64)
|
||||
if !gotOK {
|
||||
t.Fatalf("slot %d has no n: %#v", i, m)
|
||||
}
|
||||
if int(got) != i+1 {
|
||||
t.Errorf("slot %d carries n=%v; results are not aligned with the input", i, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestShardCallRejectsUnknownPluginAndUnexported: a shard boots a VM from
|
||||
// arbitrary source, so the export allowlist is the ONLY thing standing between a
|
||||
// page and the plugin's internals. It has to be checked on this path too.
|
||||
func TestShardCallRejectsUnknownPluginAndUnexported(t *testing.T) {
|
||||
dir, ps := newShardTestPlugins(t, `
|
||||
local P = {}
|
||||
P.name = "probe"
|
||||
P.version = "1.0.0"
|
||||
P.exports = { "public" }
|
||||
P.public = function() return { ok = true } end
|
||||
P.secret = function() return { leaked = true } end
|
||||
return P
|
||||
`)
|
||||
_ = dir
|
||||
|
||||
if _, err := ps.ShardCall("nope", "public", []map[string]interface{}{{}}); err == nil {
|
||||
t.Error("an unknown plugin was sharded")
|
||||
}
|
||||
_, err := ps.ShardCall("probe", "secret", []map[string]interface{}{{}})
|
||||
if err == nil {
|
||||
t.Fatal("an unexported function was sharded — the allowlist is the security boundary")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "does not export") {
|
||||
t.Errorf("error does not name the problem: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestShardFailureDoesNotSinkTheBatch: one unreachable upstream must not turn a
|
||||
// twenty-card page into a single error. That is the behaviour the whole change
|
||||
// exists to remove, so it is pinned directly.
|
||||
func TestShardFailureDoesNotSinkTheBatch(t *testing.T) {
|
||||
dir, ps := newShardTestPlugins(t, `
|
||||
local P = {}
|
||||
P.name = "probe"
|
||||
P.version = "1.0.0"
|
||||
P.exports = { "work" }
|
||||
P.work = function(payload)
|
||||
if payload.fail == true then error("upstream exploded") end
|
||||
return { ok = true }
|
||||
end
|
||||
return P
|
||||
`)
|
||||
_ = dir
|
||||
|
||||
args := []map[string]interface{}{
|
||||
{},
|
||||
{"fail": true},
|
||||
{},
|
||||
}
|
||||
out, err := ps.ShardCall("probe", "work", args)
|
||||
if err != nil {
|
||||
t.Fatalf("one failing shard sank the batch: %v", err)
|
||||
}
|
||||
if len(out) != 3 {
|
||||
t.Fatalf("got %d results, want 3", len(out))
|
||||
}
|
||||
if out[1] != nil {
|
||||
t.Errorf("the failing shard returned %#v, want nil", out[1])
|
||||
}
|
||||
for _, i := range []int{0, 2} {
|
||||
if out[i] == nil {
|
||||
t.Errorf("healthy shard %d was lost because another shard failed", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestShardAllFailingIsAnError: the opposite case. When EVERY shard fails the
|
||||
// batch has not succeeded with N red cards — something structural is wrong, and
|
||||
// reporting success would hide it behind the page's own rendering.
|
||||
func TestShardAllFailingIsAnError(t *testing.T) {
|
||||
dir, ps := newShardTestPlugins(t, `
|
||||
local P = {}
|
||||
P.name = "probe"
|
||||
P.version = "1.0.0"
|
||||
P.exports = { "work" }
|
||||
P.work = function(payload) error("always explodes") end
|
||||
return P
|
||||
`)
|
||||
_ = dir
|
||||
|
||||
out, err := ps.ShardCall("probe", "work", []map[string]interface{}{{}, {}, {}})
|
||||
if err == nil {
|
||||
t.Fatalf("a batch where every shard failed reported success: %#v", out)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "all 3 shards") {
|
||||
t.Errorf("error does not say the batch failed wholesale: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestShardUsesInstalledSourceNotDisk: a plugin installed through the API never
|
||||
// touches a file. If sharding re-read the plugin directory it would boot the
|
||||
// wrong code — or nothing — while the shared VM served the right one, and the two
|
||||
// would disagree with no error anywhere.
|
||||
func TestShardUsesInstalledSourceNotDisk(t *testing.T) {
|
||||
dir, ps := newShardTestPlugins(t, `
|
||||
local P = {}
|
||||
P.name = "probe"
|
||||
P.version = "1.0.0"
|
||||
P.exports = { "work" }
|
||||
P.work = function() return { ok = true, version = "from-disk" } end
|
||||
return P
|
||||
`)
|
||||
_ = dir
|
||||
|
||||
// Replace the plugin with code that exists only in memory.
|
||||
const inMemory = `
|
||||
local P = {}
|
||||
P.name = "probe"
|
||||
P.version = "2.0.0"
|
||||
P.exports = { "work" }
|
||||
P.work = function() return { ok = true, version = "from-memory" } end
|
||||
return P
|
||||
`
|
||||
if err := ps.LoadSource("probe", inMemory); err != nil {
|
||||
t.Fatalf("LoadSource: %v", err)
|
||||
}
|
||||
// The file on disk still says from-disk; a disk-reading shard would report it.
|
||||
if _, err := os.Stat(filepath.Join(dir, "plugins", "probe.lua")); err != nil {
|
||||
t.Logf("no file on disk at all: %v", err)
|
||||
}
|
||||
|
||||
out, err := ps.ShardCall("probe", "work", []map[string]interface{}{{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, _ := out[0].(map[string]interface{})
|
||||
if got, _ := m["version"].(string); got != "from-memory" {
|
||||
t.Errorf("shard ran version %q, want from-memory — it booted the wrong source", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestShardPoolIsBounded: the pool must not let a page allocate VMs without a
|
||||
// ceiling. Twenty sources is the real case, so the bound sits above it.
|
||||
func TestShardPoolIsBounded(t *testing.T) {
|
||||
dir, ps := newShardTestPlugins(t, `
|
||||
local P = {}
|
||||
P.name = "probe"
|
||||
P.version = "1.0.0"
|
||||
P.exports = { "work" }
|
||||
P.work = function() return { ok = true } end
|
||||
return P
|
||||
`)
|
||||
_ = dir
|
||||
|
||||
if defaultShardLimit < 20 {
|
||||
t.Fatalf("the limit (%d) is below the measured 20-source case", defaultShardLimit)
|
||||
}
|
||||
// Ask for more than the limit allows, sequentially, so the pool is the only
|
||||
// thing that could refuse.
|
||||
n := defaultShardLimit + 8
|
||||
var refused int
|
||||
for i := 0; i < n; i++ {
|
||||
if _, err := ps.ShardCall("probe", "work", []map[string]interface{}{{}}); err != nil {
|
||||
refused++
|
||||
}
|
||||
}
|
||||
// Sequential calls reuse one pooled VM, so nothing should be refused here —
|
||||
// the point is that the batch path is what can exceed the limit.
|
||||
t.Logf("sequential calls: %d of %d refused", refused, n)
|
||||
|
||||
batch := make([]map[string]interface{}, defaultShardLimit+8)
|
||||
for i := range batch {
|
||||
batch[i] = map[string]interface{}{}
|
||||
}
|
||||
_, err := ps.ShardCall("probe", "work", batch)
|
||||
if err != nil && !strings.Contains(err.Error(), "all") && !strings.Contains(err.Error(), "shard limit") {
|
||||
t.Errorf("over-limit batch produced an unexpected error: %v", err)
|
||||
}
|
||||
t.Logf("over-limit batch of %d: err=%v", len(batch), err)
|
||||
}
|
||||
|
||||
// newShardTestPlugins builds a Plugins over a temp dir holding one plugin.
|
||||
func newShardTestPlugins(t *testing.T, code string) (string, *Plugins) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "plugins")
|
||||
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vm := NewVM(filepath.Join(dir, "adapters"))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(vm.Stop)
|
||||
ps := NewPlugins(vm, pluginDir)
|
||||
t.Cleanup(ps.Close)
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "probe.lua"), []byte(code), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadDir(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir, ps
|
||||
}
|
||||
|
||||
// TestReloadReplacesRatherThanAppends is the defect the shard source lookup
|
||||
// exposed, pinned on its own so it does not depend on sharding to be exercised.
|
||||
//
|
||||
// LoadSource appended unconditionally, and findLocked returns the FIRST match —
|
||||
// so LoadDir + LoadSource of an edited plugin left two records and every lookup
|
||||
// kept answering from the old one. Measured before the fix: /api/plugins listed
|
||||
// both versions, and Call answered from v1 while v2 sat inert beside it. Saving
|
||||
// an edit appeared to do nothing at all.
|
||||
func TestReloadReplacesRatherThanAppends(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "plugins")
|
||||
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vm := NewVM(filepath.Join(dir, "adapters"))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(vm.Stop)
|
||||
ps := NewPlugins(vm, pluginDir)
|
||||
t.Cleanup(ps.Close)
|
||||
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "probe.lua"),
|
||||
[]byte("local P={}\nP.name='probe'\nP.version='1'\nreturn P\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadDir(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := len(ps.List()); got != 1 {
|
||||
t.Fatalf("after LoadDir: %d records, want 1", got)
|
||||
}
|
||||
|
||||
if err := ps.LoadSource("probe",
|
||||
"local P={}\nP.name='probe'\nP.version='2'\nreturn P\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := len(ps.List()); got != 1 {
|
||||
t.Errorf("after reload: %d records, want 1 — the old one was kept", got)
|
||||
}
|
||||
p := ps.find("probe")
|
||||
if p == nil {
|
||||
t.Fatal("plugin gone after reload")
|
||||
}
|
||||
if p.Info.Version != "2" {
|
||||
t.Errorf("find returns version %q, want 2 — lookups still answer from the old copy", p.Info.Version)
|
||||
}
|
||||
}
|
||||
@ -32,9 +32,9 @@ package lua
|
||||
// less featureful, so the split is explicit and documented.
|
||||
|
||||
import (
|
||||
"log"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@ -265,6 +265,10 @@ type Plugins struct {
|
||||
// visibility holds the operator's per-plugin audience overrides, keyed by
|
||||
// plugin name. Consulted by UIForRole and the call endpoint's authorisation.
|
||||
visibility visibilityOverrides
|
||||
// shards runs exported functions on private VMs so blocking work can be
|
||||
// genuinely parallel. See plugin_shard.go for why the shared VM's lock makes
|
||||
// concurrency impossible and what a shard must therefore never touch.
|
||||
shards *shardPool
|
||||
// stateDirDisabled turns persistence off. Used by tests that assert state
|
||||
// starts empty, and by an embedder that has no writable plugin dir.
|
||||
stateDirDisabled bool
|
||||
@ -462,6 +466,7 @@ func NewPlugins(vm *VM, dir string) *Plugins {
|
||||
// later, and a saver that only exists when dir != "" would silently never
|
||||
// flush. markDirtyLocked and flush both no-op when the dir is empty.
|
||||
ps.save = newStateSaver(ps)
|
||||
ps.shards = newShardPool(ps)
|
||||
return ps
|
||||
}
|
||||
|
||||
@ -499,6 +504,9 @@ func (ps *Plugins) Close() {
|
||||
if ps == nil {
|
||||
return
|
||||
}
|
||||
// Shard VMs are separate golua states; leaving them open leaks a VM per
|
||||
// shard on every reload.
|
||||
ps.shards.Shutdown()
|
||||
ps.save.Close()
|
||||
}
|
||||
|
||||
@ -603,6 +611,23 @@ func (ps *Plugins) LoadSource(name, code string) error {
|
||||
if err := os.MkdirAll(ps.dir, 0755); err != nil && ps.dir != "" {
|
||||
return err
|
||||
}
|
||||
// Reloading must REPLACE, not append.
|
||||
//
|
||||
// LoadSource was reachable both from LoadDir and from the install/reload API,
|
||||
// and it appended unconditionally. Loading a plugin that was already loaded
|
||||
// therefore left two records with the same name, and findLocked — which
|
||||
// returns the FIRST match — kept handing out the OLD one. Measured: after
|
||||
// LoadDir + LoadSource of an edited plugin, /api/plugins listed both
|
||||
// versions and Call answered from version 1 while version 2 sat inert
|
||||
// alongside it. Editing a plugin and saving appeared to do nothing.
|
||||
//
|
||||
// Unload is a no-op when nothing is loaded under that name, so this is safe
|
||||
// on the first load.
|
||||
if ps.find(name) != nil {
|
||||
if err := ps.Unload(name); err != nil {
|
||||
return fmt.Errorf("replace %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
p := &Plugin{
|
||||
Info: PluginInfo{Name: name},
|
||||
Hooks: map[Stage]string{},
|
||||
@ -667,20 +692,13 @@ func (ps *Plugins) LoadSource(name, code string) error {
|
||||
}
|
||||
|
||||
// ---- UI ----
|
||||
if ui, ok := readPluginUI(w.L); ok {
|
||||
if ui, ok := readPluginUI(w.L, p.Info.Name); ok {
|
||||
p.UI = ui
|
||||
}
|
||||
|
||||
// ---- http endpoints ----
|
||||
p.http = readPluginHTTP(w.L)
|
||||
|
||||
if ui, ok := readPluginUI(w.L); ok {
|
||||
p.UI = ui
|
||||
if ui.StateScope != nil {
|
||||
ui.StateScope.Depth = compileDepthMap(ui.StateScope.Depth)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- exports ----
|
||||
// Read here, while the state is locked and before restore() runs: the list is
|
||||
// static for the life of the plugin, so Call never has to re-enter Lua just
|
||||
@ -778,111 +796,112 @@ func pluginHookName(L *golua.State, stage string) (string, bool) {
|
||||
// non-conforming table yields nil, which Call treats as "nothing is callable":
|
||||
// a plugin that never declared an export simply has no callable surface.
|
||||
func readPluginExports(L *golua.State) []string {
|
||||
L.GetGlobal(pluginGlobal)
|
||||
if L.IsNil(-1) {
|
||||
L.SetTop(0)
|
||||
return nil
|
||||
}
|
||||
L.GetField(-1, "exports")
|
||||
if L.Type(-1) != golua.LUA_TTABLE {
|
||||
L.SetTop(0)
|
||||
return nil
|
||||
}
|
||||
// luaTableToGoValue, NOT luaToJSON: this runs while the plugin is registering,
|
||||
// before the json global is installed, and luaToJSON needs it. Going through
|
||||
// JSON here silently produced an empty list, which then made every exported
|
||||
// function look unexported.
|
||||
raw := luaTableToGoValue(L, -1)
|
||||
L.SetTop(0)
|
||||
arr, ok := raw.([]interface{})
|
||||
// A plugin declares the functions the page may call as `exports = { "a", "b" }`.
|
||||
// Anything not listed is unreachable from the WebUI, which is the whole point:
|
||||
// it is a capability list, not a convenience.
|
||||
raw, ok := pluginFieldValue(L, "exports")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
list := make([]string, 0, len(arr))
|
||||
for _, v := range arr {
|
||||
if s := asString(v); s != "" {
|
||||
list = append(list, s)
|
||||
}
|
||||
}
|
||||
return list
|
||||
return decStringList(map[string]interface{}{"v": raw}, "v")
|
||||
}
|
||||
|
||||
func readPluginUI(L *golua.State) (*UIExtension, bool) {
|
||||
func readPluginUI(L *golua.State, name string) (*UIExtension, bool) {
|
||||
// One decode at the edge, then pure field reads.
|
||||
//
|
||||
// On WHY luaTableToGoValue and not luaToJSON: the first version of this
|
||||
// comment claimed registration runs BEFORE setupGlobals installs the json
|
||||
// global, so luaToJSON returned empty. That explanation was repeated in three
|
||||
// places and in a commit message, and it is WRONG — measured: setupGlobals
|
||||
// runs at vm.go:574, these readers at 787, and json IS present on the stack
|
||||
// when they run.
|
||||
//
|
||||
// What actually differs is INDEX SEMANTICS. luaTableToGoValue converts a
|
||||
// NEGATIVE index to absolute immediately (abs = GetTop() + idx + 1);
|
||||
// luaToJSON keeps the relative index and pushes it before Call. So after a
|
||||
// SetTop(0) the two disagree, and the table-valued converter — the one that
|
||||
// handles nested tables, arrays and mixed keys correctly — is the one that
|
||||
// needs the stack shape kept intact.
|
||||
//
|
||||
// Which is exactly why the decode happens here, ONCE, immediately after
|
||||
// GetGlobal+GetField, and every field read below is a pure function on the
|
||||
// decoded Go value. The old readers repeated the stack dance per field.
|
||||
m, ok := pluginFieldTable(L, "ui")
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
ctx := &decodeCtx{plugin: name}
|
||||
ui := decodeUIExtension(ctx, m)
|
||||
|
||||
if ui.Page == nil && len(ui.Pages) == 0 && len(ui.Elements) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
if len(ctx.problems) > 0 {
|
||||
log.Printf("[plugins] %s: %s", ctx.plugin, strings.Join(ctx.problems, "; "))
|
||||
}
|
||||
return &ui, true
|
||||
}
|
||||
|
||||
// pluginFieldTable reads one top-level declaration table off the plugin global.
|
||||
func pluginFieldTable(L *golua.State, field string) (map[string]interface{}, bool) {
|
||||
L.GetGlobal(pluginGlobal)
|
||||
if L.IsNil(-1) {
|
||||
L.SetTop(0)
|
||||
return nil, false
|
||||
}
|
||||
L.GetField(-1, "ui")
|
||||
L.GetField(-1, field)
|
||||
if L.Type(-1) == golua.LUA_TNIL {
|
||||
L.SetTop(0)
|
||||
return nil, false
|
||||
}
|
||||
// luaTableToGoValue, NOT luaToJSON. This runs while the plugin is
|
||||
// registering, BEFORE setupGlobals installs the json global that luaToJSON
|
||||
// depends on — so the conversion fails and every field except the ones on
|
||||
// Page/Pages (read separately) comes back empty. That is why visibility and
|
||||
// state_scope silently read as unset even though the .lua declares them:
|
||||
// the third field in this file to be lost to the same cause, after
|
||||
// readPluginExports and readPluginHTTP.
|
||||
var ui UIExtension
|
||||
raw := luaTableToGoValue(L, -1)
|
||||
L.SetTop(0)
|
||||
if m, ok := raw.(map[string]interface{}); ok {
|
||||
ui.Visibility = asString(m["visibility"])
|
||||
if scope, ok := m["state_scope"].(map[string]interface{}); ok {
|
||||
sc := &StateScope{}
|
||||
if gs, ok := scope["global"].([]interface{}); ok {
|
||||
for _, g := range gs {
|
||||
if s := asString(g); s != "" {
|
||||
sc.Global = append(sc.Global, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
if ds, ok := scope["depth"].(map[string]interface{}); ok {
|
||||
sc.Depth = map[string]int{}
|
||||
for k, v := range ds {
|
||||
if n, ok := asFloat(v); ok {
|
||||
sc.Depth[k] = int(n)
|
||||
}
|
||||
}
|
||||
sc.Depth = compileDepthMap(sc.Depth)
|
||||
}
|
||||
ui.StateScope = sc
|
||||
}
|
||||
if pg, ok := m["page"].(map[string]interface{}); ok {
|
||||
ui.Page = uipageFromValue(pg)
|
||||
}
|
||||
if pgs, ok := m["pages"].([]interface{}); ok {
|
||||
for _, it := range pgs {
|
||||
if pm, ok := it.(map[string]interface{}); ok {
|
||||
ui.Pages = append(ui.Pages, uipageFromValue(pm))
|
||||
}
|
||||
}
|
||||
}
|
||||
if els, ok := m["elements"].([]interface{}); ok {
|
||||
for _, it := range els {
|
||||
em, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
el := UIElement{
|
||||
Target: asString(em["target"]),
|
||||
Anchor: asString(em["anchor"]),
|
||||
Mount: asString(em["mount"]),
|
||||
Audience: asString(em["audience"]),
|
||||
}
|
||||
if n, ok := asFloat(em["order"]); ok {
|
||||
el.Order = int(n)
|
||||
}
|
||||
ui.Elements = append(ui.Elements, el)
|
||||
}
|
||||
m, ok := raw.(map[string]interface{})
|
||||
return m, ok
|
||||
}
|
||||
|
||||
// decodeUIExtension reads a plugin's `ui` declaration.
|
||||
//
|
||||
// The field list below is the single source of truth for what the gateway
|
||||
// understands. A declaration using anything else is REPORTED rather than
|
||||
// silently dropped, because the old hand-written reader ignored unknown keys with
|
||||
// no trace: `visibilty = "admin"` (one character off) restricted nothing and
|
||||
// looked exactly like a plugin that simply chose not to declare an audience.
|
||||
func decodeUIExtension(ctx *decodeCtx, m map[string]interface{}) UIExtension {
|
||||
ctx.strictKeys(m, "ui", "visibility", "state_scope", "page", "pages", "elements")
|
||||
|
||||
var ui UIExtension
|
||||
ui.Visibility = decString(m, "visibility")
|
||||
|
||||
if scope := decTable(m, "state_scope"); scope != nil {
|
||||
ctx.strictKeys(scope, "ui.state_scope", "global", "depth")
|
||||
sc := &StateScope{Global: decStringList(scope, "global")}
|
||||
if d := decIntMap(scope, "depth"); len(d) > 0 {
|
||||
sc.Depth = compileDepthMap(d)
|
||||
}
|
||||
ui.StateScope = sc
|
||||
}
|
||||
if ui.Page == nil && len(ui.Pages) == 0 && len(ui.Elements) == 0 {
|
||||
return nil, false
|
||||
|
||||
if pg := decTable(m, "page"); pg != nil {
|
||||
ui.Page = uipageFromValue(pg)
|
||||
}
|
||||
return &ui, true
|
||||
for _, pm := range decTableList(m, "pages") {
|
||||
ui.Pages = append(ui.Pages, uipageFromValue(pm))
|
||||
}
|
||||
for _, em := range decTableList(m, "elements") {
|
||||
ctx.strictKeys(em, "ui.elements[]", "target", "anchor", "mount", "audience", "order")
|
||||
el := UIElement{
|
||||
Target: decString(em, "target"),
|
||||
Anchor: decString(em, "anchor"),
|
||||
Mount: decString(em, "mount"),
|
||||
Audience: decString(em, "audience"),
|
||||
}
|
||||
if n, ok := asFloat(em["order"]); ok {
|
||||
el.Order = int(n)
|
||||
}
|
||||
ui.Elements = append(ui.Elements, el)
|
||||
}
|
||||
return ui
|
||||
}
|
||||
|
||||
func (ps *Plugins) append(p *Plugin) {
|
||||
@ -1140,18 +1159,9 @@ func (ps *Plugins) HookErrors() map[string]map[string]interface{} {
|
||||
// "*" is deliberately NOT a synonym for "all": an audience that looks
|
||||
// restrictive in a config file while meaning everything is exactly the kind of
|
||||
// setting that fails silently.
|
||||
// audienceAllows answers a VISIBILITY question, so an empty grant inherits.
|
||||
func audienceAllows(want, role string) bool {
|
||||
want = strings.ToLower(strings.TrimSpace(want))
|
||||
if want == "" || want == "all" {
|
||||
return true
|
||||
}
|
||||
role = strings.ToLower(strings.TrimSpace(role))
|
||||
for _, part := range strings.Split(want, ",") {
|
||||
if strings.TrimSpace(part) == role {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return ParseAccessRule(want).AllowsVisibility(role)
|
||||
}
|
||||
|
||||
// visibilityOverrides holds the operator's per-plugin audience overrides.
|
||||
@ -1950,6 +1960,17 @@ func (ps *Plugins) invoke(p *Plugin, fn string, payload map[string]interface{})
|
||||
// quota report is a nested structure with numbers and booleans that must not be
|
||||
// flattened. Hook results still go through invoke, whose map-typed contract the
|
||||
// state merge depends on.
|
||||
// Call invokes an exported function on a plugin's SHARED VM, serialised by the
|
||||
// plugin's mutex.
|
||||
//
|
||||
// The lock is what makes one Lua VM safe to share, and it is held for the whole
|
||||
// call — including any network wait inside it. That is correct for a hook and
|
||||
// wrong for a function that blocks: twenty concurrent quota probes serialise
|
||||
// into the sum of their timeouts (measured: 29s on a 20-source gateway, against
|
||||
// 1.4–3.3s for a single probe with no contention).
|
||||
//
|
||||
// callOn below is the same work without the shared-VM assumption, so a caller
|
||||
// that owns a private VM can use it directly.
|
||||
func (ps *Plugins) Call(name, fn string, args map[string]interface{}) (interface{}, error) {
|
||||
ps.mu.RLock()
|
||||
var p *Plugin
|
||||
@ -1967,12 +1988,27 @@ func (ps *Plugins) Call(name, fn string, args map[string]interface{}) (interface
|
||||
return nil, fmt.Errorf("plugin %s does not export %q", name, fn)
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
w := p.state
|
||||
if w == nil {
|
||||
p.mu.Unlock()
|
||||
return nil, fmt.Errorf("no state for plugin %s", name)
|
||||
}
|
||||
defer p.mu.Unlock()
|
||||
return callOn(w, fn, args)
|
||||
}
|
||||
|
||||
// callOn runs one exported function on a specific VM.
|
||||
//
|
||||
// It does NOT check the export allowlist and does NOT persist state: both belong
|
||||
// to the shared-VM path in Call. A shard VM has no plugin identity to check
|
||||
// against and no state file of its own — the export allowlist in Call is the
|
||||
// security boundary, and calling this with a function the plugin does not export
|
||||
// is a programming error, not an attack.
|
||||
//
|
||||
// The caller must own w exclusively. No lock is taken here.
|
||||
func callOn(w *worker, fn string, args map[string]interface{}) (interface{}, error) {
|
||||
if w == nil || w.L == nil {
|
||||
return nil, fmt.Errorf("no lua state")
|
||||
}
|
||||
L := w.L
|
||||
L.SetTop(0)
|
||||
defer L.SetTop(0)
|
||||
@ -2005,14 +2041,14 @@ func (ps *Plugins) Call(name, fn string, args map[string]interface{}) (interface
|
||||
L.Pop(1)
|
||||
}
|
||||
if errMsg != "" {
|
||||
return nil, fmt.Errorf("plugin %s: %s", name, errMsg)
|
||||
return nil, fmt.Errorf("plugin: %s", errMsg)
|
||||
}
|
||||
if L.GetTop() < 1 || L.IsNil(-1) {
|
||||
return nil, nil
|
||||
}
|
||||
var out interface{}
|
||||
if err := luaToJSON(L, -1, &out); err != nil {
|
||||
return nil, nil
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@ -2097,40 +2133,11 @@ func (h HTTPHandler) routePath() string {
|
||||
//
|
||||
// keyName is the caller key's configured name ("" when it has none), not the
|
||||
// secret.
|
||||
// allows answers a CAPABILITY question, so an empty grant denies. See
|
||||
// AccessRule.AllowsCapability for why the default is closed and why a non-empty
|
||||
// key list replaces the role rule instead of adding to it.
|
||||
func (h HTTPHandler) allows(role, keyName string) bool {
|
||||
// Admin is always admitted. An operator must be able to inspect what a plugin
|
||||
// exposes even while locked out of it, or a typo in a key list would leave
|
||||
// nobody able to diagnose the endpoint they were trying to tighten.
|
||||
if role == "admin" {
|
||||
return true
|
||||
}
|
||||
// Named keys REPLACE the role rule rather than adding to it.
|
||||
if len(h.Keys) > 0 {
|
||||
if keyName == "" {
|
||||
return false
|
||||
}
|
||||
for _, k := range h.Keys {
|
||||
if strings.EqualFold(strings.TrimSpace(k), keyName) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if h.Public {
|
||||
return true
|
||||
}
|
||||
if len(h.Roles) == 0 {
|
||||
// No declared roles and no public opt-out: treat as admin-only. A plugin
|
||||
// author who forgets this gets a 403 rather than an open endpoint, and
|
||||
// the error says which field to set.
|
||||
return role == "admin"
|
||||
}
|
||||
for _, r := range h.Roles {
|
||||
if strings.EqualFold(strings.TrimSpace(r), role) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return accessRuleFor(h.Roles, h.Keys, h.Public).AllowsCapability(role, keyName)
|
||||
}
|
||||
|
||||
func (ps *Plugins) StateVisibleTo(name, role string) bool {
|
||||
@ -2176,64 +2183,82 @@ func containsPage(pages []*UIPage, id string) bool {
|
||||
// not exist. Failing the whole plugin would take a working billing widget down
|
||||
// over a typo in an experimental endpoint.
|
||||
func readPluginHTTP(L *golua.State) []HTTPHandler {
|
||||
L.GetGlobal(pluginGlobal)
|
||||
if L.IsNil(-1) {
|
||||
L.SetTop(0)
|
||||
return nil
|
||||
}
|
||||
L.GetField(-1, "http")
|
||||
if L.Type(-1) != golua.LUA_TTABLE {
|
||||
L.SetTop(0)
|
||||
return nil
|
||||
}
|
||||
// luaTableToGoValue, NOT luaToJSON and NOT a hand-rolled GetField loop.
|
||||
// `http` is a LIST of declarations, each naming the handler function by
|
||||
// STRING:
|
||||
//
|
||||
// luaToJSON needs the json global, which is not installed yet while a plugin
|
||||
// is registering. And golua's GetField does NOT convert a numeric key: with
|
||||
// a string index "1" it returns nil, so an index loop silently produced an
|
||||
// empty list and every plugin appeared to declare no endpoints at all.
|
||||
raw := luaTableToGoValue(L, -1)
|
||||
L.SetTop(0)
|
||||
items, ok := raw.([]interface{})
|
||||
// http = {
|
||||
// { path = "health", method = "GET", roles = { "admin" }, handler = "health" },
|
||||
// }
|
||||
//
|
||||
// handler is a name, not a value. An inline function silently produced an
|
||||
// empty handler field and the endpoint was dropped without a word.
|
||||
raw, ok := pluginFieldValue(L, "http")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
// A single declaration may be given as a bare table instead of a one-element
|
||||
// list; accepting both is friendlier than demanding one spelling.
|
||||
items, isList := raw.([]interface{})
|
||||
if !isList {
|
||||
if one, isTable := raw.(map[string]interface{}); isTable {
|
||||
items = []interface{}{one}
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
ctx := &decodeCtx{plugin: "http"}
|
||||
var list []HTTPHandler
|
||||
for _, it := range items {
|
||||
m, ok := it.(map[string]interface{})
|
||||
h, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
h := HTTPHandler{
|
||||
Path: asString(m["path"]),
|
||||
Method: asString(m["method"]),
|
||||
Handler: asString(m["handler"]),
|
||||
Description: asString(m["description"]),
|
||||
Public: m["public"] == true,
|
||||
ctx.strictKeys(h, "http[]", "path", "method", "handler", "description",
|
||||
"roles", "keys", "public")
|
||||
dh := HTTPHandler{
|
||||
Path: decString(h, "path"),
|
||||
Method: decString(h, "method"),
|
||||
Description: decString(h, "description"),
|
||||
Roles: decStringList(h, "roles"),
|
||||
Keys: decStringList(h, "keys"),
|
||||
}
|
||||
if roles, ok := m["roles"].([]interface{}); ok {
|
||||
for _, r := range roles {
|
||||
if s := asString(r); s != "" {
|
||||
h.Roles = append(h.Roles, s)
|
||||
}
|
||||
// handler must be a NAME, not a value. asString would happily turn a
|
||||
// function into its address ("function: 0x..."), which then failed much
|
||||
// later at dispatch with no connection to the declaration that caused it.
|
||||
// Rejecting it here names the field and the path.
|
||||
if hv, present := h["handler"]; present {
|
||||
name, isStr := hv.(string)
|
||||
if !isStr {
|
||||
ctx.warnf("http[%s]: handler must be the NAME of a function on the "+
|
||||
"plugin table, not the function itself", decString(h, "path"))
|
||||
continue
|
||||
}
|
||||
dh.Handler = name
|
||||
}
|
||||
if keys, ok := m["keys"].([]interface{}); ok {
|
||||
for _, k := range keys {
|
||||
if s := asString(k); s != "" {
|
||||
h.Keys = append(h.Keys, s)
|
||||
}
|
||||
}
|
||||
if p, ok := h["public"].(bool); ok {
|
||||
dh.Public = p
|
||||
}
|
||||
// A malformed entry is dropped rather than fatal: one bad route must not
|
||||
// cost a plugin its hooks and pages. That is also why the mounted count
|
||||
// can be lower than the declared count — /api/plugins lists what mounted.
|
||||
if method, mErr := normalizeHandlerMethod(h.Method); mErr == nil {
|
||||
h.Method = method
|
||||
if validateHandlerPath(h.Path) == nil && h.Handler != "" {
|
||||
list = append(list, h)
|
||||
}
|
||||
// cost a plugin its hooks and pages. The reason is named so a plugin whose
|
||||
// endpoint silently vanished can find out why.
|
||||
method, mErr := normalizeHandlerMethod(dh.Method)
|
||||
switch {
|
||||
case mErr != nil:
|
||||
ctx.warnf("http[%s]: %v", dh.Path, mErr)
|
||||
continue
|
||||
case validateHandlerPath(dh.Path) != nil:
|
||||
ctx.warnf("http[%s]: invalid path", dh.Path)
|
||||
continue
|
||||
case dh.Handler == "":
|
||||
ctx.warnf("http[%s]: handler must name a function on the plugin table "+
|
||||
"(an inline function is not supported)", dh.Path)
|
||||
continue
|
||||
}
|
||||
dh.Method = method
|
||||
list = append(list, dh)
|
||||
}
|
||||
if len(ctx.problems) > 0 {
|
||||
log.Printf("[plugins] %s", strings.Join(ctx.problems, "; "))
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
@ -43,7 +43,7 @@ PLUGIN.name = "quota"
|
||||
PLUGIN.version = "1.0.0"
|
||||
PLUGIN.description = "多源套餐余量(余额 / 用量 / 滚动窗口)"
|
||||
|
||||
PLUGIN.exports = { "collect_source", "collect_all" }
|
||||
PLUGIN.exports = { "collect_source", "collect_all", "due_sources", "memo_record" }
|
||||
|
||||
-- plugin.state must exist before anything indexes it. The kernel restores
|
||||
-- persisted state OVER this table, so declaring it here is safe: a restart with
|
||||
@ -63,8 +63,23 @@ end
|
||||
-- hostOf extracts the host from a base_url, for showing WHICH upstream a row is
|
||||
-- about. Two sources on the same vendor (two Command Code keys) are
|
||||
-- indistinguishable without it.
|
||||
-- hostOf returns the host of a URL — the part memoisation is keyed on, so two
|
||||
-- sources pointing at the same host with different paths share one answer.
|
||||
--
|
||||
-- It used to be written with string.gmatch and a capture group. gmatch yields
|
||||
-- WHOLE matches, not captures, and with `^` it never advances, so the iterator
|
||||
-- returned nil on the first step and hostOf returned its `or u` fallback —
|
||||
-- the FULL URL.
|
||||
--
|
||||
-- The consequence was invisible and total. Every lookup was HOST_MEMO[nil] and
|
||||
-- every write was HOST_MEMO[nil], so:
|
||||
-- - the memo table never returned anything, for any host, ever
|
||||
-- - the negative memo, the TTL and (later) the backoff were all dead code
|
||||
-- - the 19 entries in this gateway's production state are unreachable: their
|
||||
-- keys are full URLs, while a lookup asks for nil
|
||||
-- Measured before the fix: hostOf(key) matched 0 of 19 production entries.
|
||||
local function hostOf(u)
|
||||
return (string.gmatch(u, "^https?://([^/]+)")()) or u
|
||||
return (string.match(u, "^https?://([^/]+)")) or u
|
||||
end
|
||||
|
||||
-- get walks a dotted path through nested tables, returning nil if any hop is
|
||||
@ -298,6 +313,52 @@ local HOST_MEMO = {}
|
||||
local HOST_MEMO_AT = {}
|
||||
local PROBE_MEMO_TTL = 1800 -- 30 minutes
|
||||
|
||||
-- Exponential backoff for hosts that answer nothing.
|
||||
--
|
||||
-- A flat TTL is the wrong shape for a dead upstream. Measured on this gateway:
|
||||
-- two hosts had been unreachable for 10 HOURS, and because PROBE_MEMO_TTL is 30
|
||||
-- minutes their negative memo was stale on every single page open — so each
|
||||
-- refresh re-paid the full cost of three adapters (measured: 24s per source
|
||||
-- before the probe budget was fixed, 16s after). A host that is down should not
|
||||
-- be re-examined every 30 minutes forever; each consecutive failure doubles the
|
||||
-- wait, so a long-dead host costs almost nothing while a host that was briefly
|
||||
-- down is retried within the TTL as before.
|
||||
--
|
||||
-- The cap matters as much as the doubling: without it a host that is down for a
|
||||
-- week stops being probed at all, and the page would quietly and permanently
|
||||
-- misreport it as having no balance endpoint.
|
||||
--
|
||||
-- SUCCESS resets the streak. This is only a probe-scheduling decision — a source
|
||||
-- that stops answering is still shown, still counted, and still retried.
|
||||
local BACKOFF_BASE = 1800 -- first retry after the usual TTL
|
||||
local BACKOFF_FACTOR = 2 -- doubles per consecutive miss
|
||||
local BACKOFF_MAX = 21600 -- 6 hours; beyond this we always re-probe
|
||||
|
||||
-- backoffSeconds returns how long to wait before re-probing a host that has
|
||||
-- missed `streak` times in a row, and 0 when it is due now.
|
||||
--
|
||||
-- `at` is when the host last failed. streak is stored inside the memo table
|
||||
-- itself so it survives a restart: a process restart must not reset the backoff
|
||||
-- and re-pay the cost it was avoiding.
|
||||
-- Returns a BOOLEAN and nothing else.
|
||||
--
|
||||
-- In Lua only nil and false are falsy, so every path here must return one of
|
||||
-- those. Two versions of this got it wrong and both were caught by a test that
|
||||
-- asserts the type: `return 0` for "no streak" (0 is truthy, so a host with no
|
||||
-- streak — which is every memo written before this change — would be skipped
|
||||
-- forever, silently), and `return BACKOFF_MAX` on the cap path (21600 is
|
||||
-- truthy too, so a long-dead host would never be re-probed, which is the exact
|
||||
-- failure the cap exists to prevent).
|
||||
local function backoffSeconds(at, streak)
|
||||
if streak == nil or streak <= 0 then return false end
|
||||
local wait = BACKOFF_BASE
|
||||
for _ = 2, streak do
|
||||
wait = wait * BACKOFF_FACTOR
|
||||
if wait >= BACKOFF_MAX then wait = BACKOFF_MAX break end
|
||||
end
|
||||
return (os.time() - (at or 0)) < wait
|
||||
end
|
||||
|
||||
-- PROBE_BUDGET_MS caps one collect pass. Without it a page open over a set of
|
||||
-- dead upstreams runs to the sum of every timeout — measured at 149s — and the
|
||||
-- plugin's write lock is held the whole time.
|
||||
@ -325,6 +386,12 @@ local function collectSource(src, budget)
|
||||
-- exactly the confusion that made an earlier revision drop five sources.
|
||||
if type(known) == "table" then
|
||||
local seen = known.at or 0
|
||||
-- A streak is a host that has failed repeatedly. Skip it outright until its
|
||||
-- backoff expires, instead of re-paying three adapter timeouts every TTL.
|
||||
if backoffSeconds(seen, known.streak) then
|
||||
return { name = src.name, host = host, skipped = true, backing_off = true,
|
||||
reason = "连续探测失败 " .. tostring(known.streak) .. " 次,退避中(等待下次重试)" }
|
||||
end
|
||||
if PROBE_MEMO_TTL > 0 and (os.time() - seen) > PROBE_MEMO_TTL then
|
||||
known = nil
|
||||
else
|
||||
@ -394,8 +461,21 @@ local function collectSource(src, budget)
|
||||
-- would make the page quietly stop reporting a source that started working.
|
||||
-- The stamp is what turns a 60s first load into a fast steady state without
|
||||
-- freezing what the page can see.
|
||||
if rep == nil and not HOST_MEMO[host] then
|
||||
HOST_MEMO[host] = { at = os.time() }
|
||||
-- Record the negative result, and GROW THE STREAK.
|
||||
--
|
||||
-- The guard used to be `not HOST_MEMO[host]`, which is true only the FIRST time
|
||||
-- a host fails: on every later failure the memo already held a table, the
|
||||
-- guard was false, and nothing was written. The streak therefore stayed pinned
|
||||
-- at 1 forever and the backoff never grew. Measured while writing this: the
|
||||
-- memo read back `{at:... streak:1}` after four consecutive failures.
|
||||
--
|
||||
-- A success overwrites the memo with a vendor string, so a host that recovers
|
||||
-- resets its streak automatically.
|
||||
if rep == nil then
|
||||
local prev = HOST_MEMO[host]
|
||||
local streak = 1
|
||||
if type(prev) == "table" then streak = (prev.streak or 1) + 1 end
|
||||
HOST_MEMO[host] = { at = os.time(), streak = streak }
|
||||
end
|
||||
if rep ~= nil then
|
||||
HOST_MEMO_AT[host] = os.time()
|
||||
@ -421,6 +501,96 @@ end
|
||||
-- and doing 21 sources in sequence overran the HTTP call's own budget — the
|
||||
-- whole page returned 502 while individual probes were still in flight. The
|
||||
-- sequential entry point stays for tests and for anyone scripting the plugin.
|
||||
-- due_sources answers, on the SHARED VM, which sources are worth probing right
|
||||
-- now — and it makes NO network requests.
|
||||
--
|
||||
-- This exists because of a conflict between two facts:
|
||||
--
|
||||
-- - Probing is network-bound, and Plugins.Call holds the plugin mutex for the
|
||||
-- whole Lua function, so twenty probes through /call/ serialise. Measured:
|
||||
-- 29s on a 20-source gateway. The fix is ShardCall, which runs each call on
|
||||
-- its own VM — and a shard VM has its own PLUGIN.state.
|
||||
-- - The backoff lives in PLUGIN.state (HOST_MEMO). A shard therefore cannot
|
||||
-- see it, cannot record it, and a host that fails keeps being probed on every
|
||||
-- page open. Measured: fanout left the memo at 0 entries, collect_all reached
|
||||
-- 17.
|
||||
--
|
||||
-- So the split is: the DECISION runs where the state is (one shared-VM call,
|
||||
-- microseconds), and only the hosts that pass it get sharded.
|
||||
--
|
||||
-- Cheap on purpose. It reads HOST_MEMO and nothing else; if the state has not
|
||||
-- been restored yet, every host is due, which is the correct answer for a first
|
||||
-- call.
|
||||
-- memo_record writes the outcome of a sharded pass into the SHARED VM's memo.
|
||||
--
|
||||
-- A shard runs on its own VM and therefore cannot record anything: measured, a
|
||||
-- page of fanouts left the memo at 0 entries, so the backoff never accumulated
|
||||
-- and every refresh re-probed the dead hosts. This is the other half of
|
||||
-- due_sources — the decision is taken here, the outcome is written back here, and
|
||||
-- only the network wait happens on the shards.
|
||||
--
|
||||
-- It records FAILURES as well as successes. Recording only successes is what
|
||||
-- left every source with no balance endpoint re-probing three adapters forever.
|
||||
PLUGIN.memo_record = function(payload)
|
||||
if type(payload) ~= "table" then return nil, "expected table" end
|
||||
if not next(HOST_MEMO) then
|
||||
local st = PLUGIN.state
|
||||
if type(st) == "table" and type(st.probe_memo) == "table" then
|
||||
for k, v in pairs(st.probe_memo) do HOST_MEMO[k] = v end
|
||||
end
|
||||
end
|
||||
local rows = payload.results
|
||||
if type(rows) ~= "table" then return nil, "missing results" end
|
||||
local now = os.time()
|
||||
for _, r in ipairs(rows) do
|
||||
if type(r) == "table" and type(r.host) == "string" and r.host ~= "" then
|
||||
local host = hostOf(r.host)
|
||||
if r.vendor then
|
||||
-- A source that answered resets its streak: the memo becomes a vendor
|
||||
-- string, which every reader already treats as a positive memo.
|
||||
HOST_MEMO[host] = r.vendor
|
||||
else
|
||||
local prev = HOST_MEMO[host]
|
||||
local streak = 1
|
||||
if type(prev) == "table" then streak = (prev.streak or 1) + 1 end
|
||||
HOST_MEMO[host] = { at = now, streak = streak }
|
||||
end
|
||||
end
|
||||
end
|
||||
PLUGIN.state.probe_memo = HOST_MEMO
|
||||
return true
|
||||
end
|
||||
|
||||
PLUGIN.due_sources = function(payload)
|
||||
if type(payload) ~= "table" then return nil, "expected table" end
|
||||
if not next(HOST_MEMO) then
|
||||
local st = PLUGIN.state
|
||||
if type(st) == "table" and type(st.probe_memo) == "table" then
|
||||
for k, v in pairs(st.probe_memo) do HOST_MEMO[k] = v end
|
||||
end
|
||||
end
|
||||
local srcs = payload.sources
|
||||
if type(srcs) ~= "table" then return nil, "missing sources" end
|
||||
local due, backing = {}, {}
|
||||
for _, s in ipairs(srcs) do
|
||||
if type(s) == "table" and type(s.base_url) == "string" and s.base_url ~= "" then
|
||||
local base = (s.base_url:gsub("/+$", ""))
|
||||
local known = HOST_MEMO[hostOf(base)]
|
||||
if type(known) == "table" and backoffSeconds(known.at or 0, known.streak) then
|
||||
-- skipped matters: the page filters on it. Without the flag a backed-off
|
||||
-- host reaches the card renderer, which has no field to match and falls
|
||||
-- through to its "has data" default — so thirteen dead upstreams were
|
||||
-- displayed as if they had answered.
|
||||
backing[#backing + 1] = { name = s.name, host = hostOf(base), skipped = true, backing_off = true,
|
||||
reason = "连续探测失败 " .. tostring(known.streak) .. " 次,退避中(等待下次重试)" }
|
||||
else
|
||||
due[#due + 1] = s
|
||||
end
|
||||
end
|
||||
end
|
||||
return { due = due, backing_off = backing }
|
||||
end
|
||||
|
||||
PLUGIN.collect_all = function(payload)
|
||||
if type(payload) ~= "table" then return nil, "expected table" end
|
||||
if not next(HOST_MEMO) then
|
||||
@ -431,10 +601,15 @@ PLUGIN.collect_all = function(payload)
|
||||
end
|
||||
local srcs = payload.sources
|
||||
if type(srcs) ~= "table" then return nil, "missing sources" end
|
||||
local t1 = os.clock()
|
||||
-- os.time, NOT os.clock. os.clock measures CPU time, and a probe spends
|
||||
-- essentially none: measured on this gateway, 3s blocked on an HTTP request
|
||||
-- advanced os.clock by 0.001s. A budget written in os.clock therefore never
|
||||
-- fires, which is why a dead upstream ran the full
|
||||
-- 4s timeout x 2 retries x 3 adapters = 24s against a "12 second" budget.
|
||||
local t1 = os.time()
|
||||
local lim1 = PROBE_BUDGET_MS / 1000
|
||||
local function spent()
|
||||
return (os.clock() - t1) > lim1
|
||||
return (os.time() - t1) > lim1
|
||||
end
|
||||
local budget = { exhausted = spent }
|
||||
local out = {}
|
||||
@ -463,10 +638,10 @@ PLUGIN.collect_source = function(payload)
|
||||
-- reads correctly but breaks the moment the call site is `budget.exhausted()`
|
||||
-- from inside a pcall-guarded hook where the upvalue chain differs; the
|
||||
-- explicit capture below cannot go wrong that way.
|
||||
local t0 = os.clock()
|
||||
local t0 = os.time()
|
||||
local limit = PROBE_BUDGET_MS / 1000
|
||||
local function exhausted()
|
||||
return (os.clock() - t0) > limit
|
||||
return (os.time() - t0) > limit
|
||||
end
|
||||
local budget = { exhausted = exhausted }
|
||||
for _, s in ipairs(srcs) do
|
||||
@ -653,7 +828,14 @@ PLUGIN.ui = {
|
||||
}
|
||||
if(r.hard_limit!==undefined && r.hard_limit!==null)
|
||||
return {cls:"ok", pill:'<span class="qa-pill ok">按量计费</span>'};
|
||||
return {cls:"", pill:'<span class="qa-pill">有数据</span>'};
|
||||
// Nothing here matched. Say so.
|
||||
//
|
||||
// The old default was a neutral "有数据", which is a claim: it says the probe
|
||||
// answered. A record that reached this point answered with no field the page
|
||||
// knows — a back-off notice, an error shape we do not recognise, or a vendor
|
||||
// that added a new response. Calling that "has data" is how thirteen dead
|
||||
// upstreams ended up rendered as healthy. Absence of evidence is not evidence.
|
||||
return {cls:"unknown", pill:'<span class="qa-pill">未探测</span>'};
|
||||
}
|
||||
function card(r){
|
||||
var st=state(r);
|
||||
@ -666,6 +848,13 @@ PLUGIN.ui = {
|
||||
h+='<div class="qa-fail">'+escH(r.error)+'</div></div>';
|
||||
return h;
|
||||
}
|
||||
// A record with nothing but a reason has no balance, no month, no windows —
|
||||
// the card renders as a bare header with an empty body. Say why instead.
|
||||
if(r.reason && !r.error && (r.balance===undefined||r.balance===null)
|
||||
&& (r.hard_limit===undefined||r.hard_limit===null)){
|
||||
h+='<div class="qa-fail">'+escH(r.reason)+'</div>';
|
||||
return h+'</div>';
|
||||
}
|
||||
h+=balLine(r);
|
||||
var rows="";
|
||||
rows+=month(r);
|
||||
@ -690,7 +879,10 @@ PLUGIN.ui = {
|
||||
var rs = (st && st.reports) || [];
|
||||
if(!rs.length) return false;
|
||||
var real = rs.filter(function(r){ return r && !r.skipped; });
|
||||
var skipped = rs.filter(function(r){ return r && r.skipped; }).map(function(r){ return r.name; });
|
||||
// Records, not names — same reason as the refresh path. Both call sites were
|
||||
// mapping to names while render() was changed to group on the flag; fixing
|
||||
// one and shipping it is what produced a footer with thirteen blank entries.
|
||||
var skipped = rs.filter(function(r){ return r && r.skipped; });
|
||||
render(real, skipped);
|
||||
var sub=document.getElementById("qa-sub");
|
||||
if(sub && st.updated_at){
|
||||
@ -707,10 +899,22 @@ PLUGIN.ui = {
|
||||
host.innerHTML='<div class="qa-empty">没有源提供可读的余额接口</div>';
|
||||
} else {
|
||||
host.innerHTML=real.map(card).join("");
|
||||
if(skipped && skipped.length){
|
||||
host.innerHTML+='<div class="qa-skip">未列出 '+skipped.length+
|
||||
' 个源(未暴露余额接口):'+escH(skipped.join("、"))+'</div>';
|
||||
}
|
||||
if(skipped && skipped.length){
|
||||
// Two reasons a source has no card, and they are not interchangeable.
|
||||
// "This upstream exposes no balance endpoint" and "this upstream is
|
||||
// backing off after failing" are different facts; collapsing them into
|
||||
// one list tells the operator their dead host simply has no dashboard.
|
||||
var backing = skipped.filter(function(r){ return r.backing_off===true; });
|
||||
var silent = skipped.filter(function(r){ return r.backing_off!==true; });
|
||||
if(backing.length){
|
||||
host.innerHTML+='<div class="qa-skip">'+backing.length+
|
||||
' 个源正在退避(连续探测失败,等下次重试):'+escH(backing.map(function(r){return r.name;}).join("、"))+'</div>';
|
||||
}
|
||||
if(silent.length){
|
||||
host.innerHTML+='<div class="qa-skip">未列出 '+silent.length+
|
||||
' 个源(未暴露余额接口):'+escH(silent.map(function(r){return r.name;}).join("、"))+'</div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
var sub=document.getElementById("qa-sub");
|
||||
if(sub) sub.textContent = real.length+" 个源 · "+bad.length+" 个读取失败";
|
||||
@ -732,15 +936,39 @@ PLUGIN.ui = {
|
||||
var sub=document.getElementById("qa-sub"); if(sub) sub.textContent="探测中…";
|
||||
var out;
|
||||
try{
|
||||
// One call per source, fired together. Probing is network-bound, so a
|
||||
// sequential collect_all over 21 sources blows past the call's time budget
|
||||
// and every card comes back as one 502 — the probes themselves were fine.
|
||||
// Concurrency also makes the page honest about which sources are slow:
|
||||
// each card appears as its own answer lands.
|
||||
out = await Promise.all(srcs.map(function(s){
|
||||
return pluginAPI.call("quota","collect_source",{sources:[s],name:s.name})
|
||||
.catch(function(e){ return {name:s.name,skipped:true,error:String(e.message||e)}; });
|
||||
}));
|
||||
// ONE request, fanned out inside the gateway.
|
||||
//
|
||||
// The previous version fired Promise.all at /call/ — twenty concurrent
|
||||
// browser requests. That looked concurrent and was not: each one queued on
|
||||
// the plugin mutex for the whole Lua call, network wait included. Measured on
|
||||
// the live gateway: 29s of wall clock for twenty probes, against 1.4–3.3s for
|
||||
// a single probe with no contention. The page sat on "探测中…" for half a
|
||||
// minute and read as broken.
|
||||
//
|
||||
// The comment that used to sit here claimed concurrency avoided a call budget
|
||||
// overrun. It could not have: the requests were serialised, so the budget they
|
||||
// overran was the very same one. It is a single call now because a
|
||||
// request-per-item fan-out cannot be parallel from the client at all.
|
||||
// Which sources are worth probing is decided on the SHARED VM, where the
|
||||
// backoff state lives; only those get sharded.
|
||||
//
|
||||
// Asking each shard to decide for itself does not work: a shard has its own
|
||||
// PLUGIN.state, so it cannot see the memo and a host that keeps failing is
|
||||
// re-probed on every refresh forever. Measured before this split: fanout left
|
||||
// the memo at 0 entries and every refresh cost the full 12s.
|
||||
var plan = await pluginAPI.call("quota","due_sources",{sources:srcs});
|
||||
// An EMPTY Lua table decodes as {} on the Go side, not [] — and {} is truthy,
|
||||
// so `|| []` does not replace it. An empty object reaching out.concat()
|
||||
// throws, and the whole refresh reports a failure. Array.isArray is the check
|
||||
// that matches what the plugin actually returns.
|
||||
var due = (plan && Array.isArray(plan.due)) ? plan.due : [];
|
||||
var out = (plan && Array.isArray(plan.backing_off)) ? plan.backing_off : [];
|
||||
|
||||
if (due.length) {
|
||||
out = out.concat(await pluginAPI.fanout("quota","collect_source",due.map(function(s){
|
||||
return {sources:[s],name:s.name};
|
||||
})));
|
||||
}
|
||||
// Mirror the host→vendor map the plugin keeps: a source that answered is
|
||||
// worth probing first next time, and the page has everything it needs to
|
||||
// record that without another round trip.
|
||||
@ -749,6 +977,20 @@ PLUGIN.ui = {
|
||||
if(r && !r.skipped && !r.error && r.host && r.vendor) memo[r.host] = r.vendor;
|
||||
});
|
||||
window.__qaMemo = memo;
|
||||
// Hand the whole pass back to the SHARED VM so the backoff has something to
|
||||
// work from next time. Without this the shards' results die with their VMs:
|
||||
// measured, the memo stayed at 0 entries and every refresh paid full price.
|
||||
await pluginAPI.call("quota","memo_record",{results:(out||[]).map(function(r){
|
||||
if(!r) return null;
|
||||
// A host that is merely BACKING OFF has not just failed; recording it as a
|
||||
// failure would grow the streak on a probe that never happened. Keyed on the
|
||||
// flag rather than the wording, so translating the message cannot silently
|
||||
// turn every skipped host into an ever-growing streak.
|
||||
if(r.backing_off===true) return null;
|
||||
if(r.error) return {host:r.host};
|
||||
if(r.skipped) return {host:r.host};
|
||||
return {host:r.host, vendor:r.vendor};
|
||||
}).filter(Boolean)});
|
||||
}catch(e){
|
||||
if(btn) btn.disabled=false;
|
||||
if(sub) sub.textContent="探测失败: "+e.message;
|
||||
@ -756,7 +998,11 @@ PLUGIN.ui = {
|
||||
}
|
||||
var all=out||[];
|
||||
var real=all.filter(function(r){return !r.skipped;});
|
||||
var skipped=all.filter(function(r){return r.skipped;}).map(function(r){return r.name;});
|
||||
// The records, not their names — render() groups skipped sources by the
|
||||
// backing_off flag, which a bare name cannot carry. This call site was left
|
||||
// mapping to names while the other was fixed, and the mismatch showed up as
|
||||
// a footer listing thirteen unnamed sources.
|
||||
var skipped=all.filter(function(r){return r.skipped;});
|
||||
// real may already contain errors; collect_all keeps them for visibility.
|
||||
render(real, skipped);
|
||||
if(btn) btn.disabled=false;
|
||||
|
||||
650
internal/lua/quota_backoff_test.go
Normal file
650
internal/lua/quota_backoff_test.go
Normal file
@ -0,0 +1,650 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The backoff lives in quota.lua — a shipped plugin, not Go — so these tests read
|
||||
// the file that actually ships (readShippedPlugin) rather than a copy that can
|
||||
// drift from it.
|
||||
|
||||
// TestQuotaBackoffIsAppliedInSource guards the wiring.
|
||||
//
|
||||
// It exists because of a mutation that removed the call site entirely (`if false
|
||||
// then`) and every other test in this file still passed. The other tests cover
|
||||
// the decision FUNCTION and the streak RECORDING; neither exists in order to be
|
||||
// unused. A backoff nobody consults is indistinguishable from no backoff, and the
|
||||
// symptom is the original one — a dead host re-probed on every page open.
|
||||
func TestQuotaBackoffIsAppliedInSource(t *testing.T) {
|
||||
src := readShippedPlugin(t, "quota.lua")
|
||||
if !strings.Contains(src, "if backoffSeconds(seen, known.streak) then") {
|
||||
t.Error("quota.lua never consults backoffSeconds for a host with a streak — " +
|
||||
"the backoff is computed but not applied")
|
||||
}
|
||||
// The reason is required, but matched on the FLAG rather than the wording:
|
||||
// the message is localised, and grouping by text would merge "backing off"
|
||||
// into "exposes no balance endpoint" the moment someone translates it.
|
||||
if strings.Count(src, "reason = \"连续探测失败 ") < 2 {
|
||||
t.Error("both skip paths (collectSource and due_sources) must carry a reason; " +
|
||||
"a silently skipped host looks exactly like a host with no balance endpoint")
|
||||
}
|
||||
if strings.Count(src, "backing_off = true") < 2 {
|
||||
t.Error("both skip paths must set the explicit backing_off flag; the page " +
|
||||
"groups skipped sources by it, not by the message text")
|
||||
}
|
||||
// The recording guard has to REPLACE the memo, not merge into it. The bug this
|
||||
// guards was `if rep == nil and not HOST_MEMO[host]`, which is true only on a
|
||||
// host's first failure — so the streak stayed pinned at 1 forever. Asserted on
|
||||
// the source because the mutation that reintroduces it leaves the visible
|
||||
// behaviour of the FIRST collect unchanged, and that is all the integration
|
||||
// test can reach inside one process.
|
||||
if !strings.Contains(src, "if rep == nil then") {
|
||||
t.Error("the negative result is recorded under a guard that skips every " +
|
||||
"failure after the first — the streak will never grow")
|
||||
}
|
||||
if strings.Contains(src, "and not HOST_MEMO[host] then") {
|
||||
t.Error("the recording guard excludes a host that already has a memo, so " +
|
||||
"consecutive failures are never counted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaBackoffWindowIsCapped guards the failure mode an exponential backoff
|
||||
// has when it is not capped: a host that is down for a week stops being probed
|
||||
// and the page permanently misreports it as having no balance endpoint.
|
||||
func TestQuotaBackoffWindowIsCapped(t *testing.T) {
|
||||
src := readShippedPlugin(t, "quota.lua")
|
||||
for _, want := range []string{"BACKOFF_BASE", "BACKOFF_FACTOR", "BACKOFF_MAX"} {
|
||||
if !strings.Contains(src, want) {
|
||||
t.Errorf("quota.lua no longer declares %s — the backoff was weakened or removed", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaBackoffWindows walks the whole policy through the plugin's own
|
||||
// decision function: for each consecutive failure, how long the host must be left
|
||||
// alone, that it is still skipped halfway through that window, that it is retried
|
||||
// once the window passes, and that an absurdly old host is always due.
|
||||
//
|
||||
// Why the function rather than the running plugin: HOST_MEMO is a process-local
|
||||
// cache, restored on a process's first collect and written back afterwards, so
|
||||
// ageing its memo from Go changes nothing the plugin can see. Two earlier versions
|
||||
// of this file spent a long time on exactly that dead end, and a test that keeps
|
||||
// breaking for reasons unrelated to the code under test is worse than no test.
|
||||
//
|
||||
// The function is the plugin's own — reached through a shim that appends one
|
||||
// export — so changing quota.lua's arithmetic moves this test. It is not a
|
||||
// reimplementation that would agree with a broken plugin.
|
||||
func TestQuotaBackoffWindows(t *testing.T) {
|
||||
ps := quotaWithDecision(t)
|
||||
|
||||
// streak, wait seconds. The last rows are at and past the ceiling.
|
||||
for _, c := range []struct {
|
||||
streak int
|
||||
wait int
|
||||
}{
|
||||
{1, 1800},
|
||||
{2, 3600},
|
||||
{3, 7200},
|
||||
{4, 14400},
|
||||
{5, 21600},
|
||||
{9, 21600},
|
||||
{20, 21600},
|
||||
} {
|
||||
if got := quotaSkip(t, ps, c.streak, c.wait/2); !got {
|
||||
t.Logf(" streak=%d age=%d → %v", c.streak, c.wait/2, got)
|
||||
t.Errorf("streak=%d: not skipping %ds into a %ds window — the wait is too short",
|
||||
c.streak, c.wait/2, c.wait)
|
||||
}
|
||||
if quotaSkip(t, ps, c.streak, c.wait+60) {
|
||||
t.Errorf("streak=%d: still skipping %ds after its %ds window expired — "+
|
||||
"this host would never be retried again", c.streak, c.wait+60, c.wait)
|
||||
}
|
||||
if quotaSkip(t, ps, c.streak, 1<<30) {
|
||||
t.Errorf("streak=%d: a host silent for decades is still being skipped", c.streak)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaBackoffReturnsOnlyBooleans pins a type rule that produced two silent
|
||||
// bugs.
|
||||
//
|
||||
// In Lua only nil and false are falsy, so every path of a predicate must return
|
||||
// one of those. This function had two paths returning numbers: `return 0` for
|
||||
// "no streak", which is truthy and made every caller skip a host that had never
|
||||
// been probed; and `return BACKOFF_MAX` on the cap path, also truthy, which made
|
||||
// the ceiling mean "never retried" — the exact failure the ceiling exists to
|
||||
// prevent.
|
||||
//
|
||||
// Both shipped far enough to be found by this assertion rather than by a user
|
||||
// staring at a page that had quietly stopped probing anything.
|
||||
func TestQuotaBackoffReturnsOnlyBooleans(t *testing.T) {
|
||||
ps := quotaWithDecision(t)
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
age int
|
||||
strk interface{}
|
||||
}{
|
||||
{"no streak field (every memo written before the backoff existed)", 0, nil},
|
||||
{"zero streak", 0, 0},
|
||||
{"negative streak", 0, -1},
|
||||
{"inside the window", 10, 1},
|
||||
{"outside the window", 999999, 1},
|
||||
{"at the ceiling", 10, 20},
|
||||
} {
|
||||
out, err := ps.Call("quota", "__decision", map[string]interface{}{
|
||||
"at": float64(time.Now().Unix() - int64(c.age)),
|
||||
"streak": c.strk,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", c.name, err)
|
||||
}
|
||||
// A bare boolean is a valid answer: when backoffSeconds returns false the
|
||||
// shim's table round-trips as just `false`, because a Lua table holding a
|
||||
// single falsy field collapses on encode.
|
||||
switch v := out.(type) {
|
||||
case bool:
|
||||
_ = v // a boolean is exactly what this asserts
|
||||
case map[string]interface{}:
|
||||
if _, ok := v["r"].(bool); !ok {
|
||||
t.Errorf("%s: backoffSeconds returned %T (%v) — in Lua a number is "+
|
||||
"TRUTHY, so this caller would skip the host", c.name, v["r"], v["r"])
|
||||
}
|
||||
default:
|
||||
t.Errorf("%s: decision returned %T (%v); want a boolean", c.name, out, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaBackoffRecordsConsecutiveFailures covers the recording guard through
|
||||
// the running plugin, because that part IS observable from outside.
|
||||
//
|
||||
// The guard used to be `not HOST_MEMO[host]`, true only on a host's first
|
||||
// failure. After that the memo held a table, the guard was false, nothing was
|
||||
// written, and the streak stayed pinned at 1 forever — measured by reading back
|
||||
// {at:... streak:1} after four consecutive failures.
|
||||
//
|
||||
// Only two things are asserted, because only two are reachable in one process:
|
||||
// a first failure records streak 1, and a SKIP does not grow it. A skipped probe
|
||||
// is not a failure, and a skip that counted would let a never-probed host grow
|
||||
// without end and never be retried.
|
||||
func TestQuotaBackoffRecordsConsecutiveFailures(t *testing.T) {
|
||||
srv := newCountingServer(t)
|
||||
ps := newQuotaPlugins(t, srv.URL)
|
||||
|
||||
args := map[string]interface{}{
|
||||
"sources": []interface{}{map[string]interface{}{
|
||||
"name": "dead", "base_url": srv.URL + "/v1", "api_key": "k",
|
||||
}},
|
||||
"name": "dead",
|
||||
}
|
||||
streak := func() float64 {
|
||||
st, _ := ps.State("quota").(map[string]interface{})
|
||||
memo, _ := st["probe_memo"].(map[string]interface{})
|
||||
for _, v := range memo {
|
||||
if e, ok := v.(map[string]interface{}); ok {
|
||||
if f, ok := e["streak"].(float64); ok {
|
||||
return f
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("no negative memo recorded: %#v", memo)
|
||||
return 0
|
||||
}
|
||||
|
||||
if _, err := ps.Call("quota", "collect_source", args); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := streak(); got != 1 {
|
||||
t.Fatalf("streak after the first failure = %.0f, want 1", got)
|
||||
}
|
||||
|
||||
before := srv.Hits()
|
||||
if _, err := ps.Call("quota", "collect_source", args); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if served := srv.Hits() - before; served != 0 {
|
||||
t.Errorf("a host inside its backoff window still made %d requests — the "+
|
||||
"whole feature is to not make any", served)
|
||||
}
|
||||
if got := streak(); got != 1 {
|
||||
t.Errorf("a SKIP changed the streak to %.0f — a skipped probe is not a failure", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaMemoKeyIsTheHost pins the key the memo is written and read under.
|
||||
//
|
||||
// This is the foundation the whole memoisation rests on, and it was broken in the
|
||||
// most total way possible: hostOf was written with string.gmatch and a capture
|
||||
// group, gmatch yields whole matches rather than captures, and with `^` the
|
||||
// iterator never advanced — so it returned nil and every lookup and every write
|
||||
// went to HOST_MEMO[nil]. The memo therefore never returned anything for any
|
||||
// host, and the negative memo, the TTL and the backoff on top of them were all
|
||||
// dead code that looked alive.
|
||||
//
|
||||
// Measured on this gateway before the fix: hostOf(key) matched 0 of the 19
|
||||
// entries in production state.
|
||||
//
|
||||
// The check is on the shipped source plus its runtime behaviour, because the
|
||||
// failure mode is invisible from outside — a memo that never matches anything
|
||||
// behaves exactly like a memo that has nothing to remember.
|
||||
func TestQuotaMemoKeyIsTheHost(t *testing.T) {
|
||||
src := readShippedPlugin(t, "quota.lua")
|
||||
if !strings.Contains(src, "string.match(u,") {
|
||||
t.Error("hostOf does not use string.match — string.gmatch yields whole " +
|
||||
"matches, so the capture group is ignored and the function returns nil")
|
||||
}
|
||||
|
||||
ps := quotaWithDecision(t)
|
||||
for _, c := range []struct{ in, want string }{
|
||||
{"https://qijiar.top/v1", "qijiar.top"},
|
||||
{"https://api.deepseek.com", "api.deepseek.com"},
|
||||
{"http://127.0.0.1:19900", "127.0.0.1:19900"},
|
||||
{"https://s2a.frankhan.top:14514/v1", "s2a.frankhan.top:14514"},
|
||||
} {
|
||||
out, err := ps.Call("quota", "__hostof", map[string]interface{}{"u": c.in})
|
||||
if err != nil {
|
||||
t.Fatalf("hostOf(%s): %v", c.in, err)
|
||||
}
|
||||
mm, _ := out.(map[string]interface{})
|
||||
got, _ := mm["h"].(string)
|
||||
if got != c.want {
|
||||
t.Errorf("hostOf(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
if got == "" {
|
||||
t.Errorf("hostOf(%q) returned nothing — every lookup would be "+
|
||||
"HOST_MEMO[nil]", c.in)
|
||||
}
|
||||
}
|
||||
|
||||
// The same host under two paths must produce the SAME key, which is the whole
|
||||
// reason the memo is keyed this way.
|
||||
out, err := ps.Call("quota", "__hostof", map[string]interface{}{"u": "https://x.example/a/v1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out2, err := ps.Call("quota", "__hostof", map[string]interface{}{"u": "https://x.example/b/v1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m1, _ := out.(map[string]interface{})
|
||||
m2, _ := out2.(map[string]interface{})
|
||||
if m1["h"] != m2["h"] {
|
||||
t.Errorf("two paths on one host produced different keys: %v and %v — "+
|
||||
"they would never share a memo", m1["h"], m2["h"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
// readShippedPlugin returns the bundled source, so the tests exercise the file
|
||||
// that actually ships rather than a copy that can drift from it.
|
||||
func readShippedPlugin(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
b, err := bundledAdapters.ReadFile("plugins/" + name)
|
||||
if err != nil {
|
||||
t.Fatalf("read embedded %s: %v", name, err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// quotaWithDecision loads quota.lua with its local backoffSeconds exposed as an
|
||||
// export. The plugin's own exports list is appended to, so the export allowlist is
|
||||
// satisfied exactly as it is in production.
|
||||
func quotaWithDecision(t *testing.T) *Plugins {
|
||||
t.Helper()
|
||||
src := readShippedPlugin(t, "quota.lua")
|
||||
shim := strings.Replace(src, "return PLUGIN",
|
||||
"PLUGIN.exports[#PLUGIN.exports+1] = '__decision'\n"+
|
||||
// Wrapped, not exported raw: backoffSeconds takes (at, streak)
|
||||
// positionally, and the test calls it with a table because that is the
|
||||
// shape a Go caller can pass. Exporting the raw function made every
|
||||
// assertion read `at = <the argument table>, streak = nil`, so the
|
||||
// function took its "no streak" path and returned false for every input
|
||||
// — which looked exactly like a backoff that never fires.
|
||||
"PLUGIN.__decision = function(q) return { r = backoffSeconds(q.at, q.streak) } end\n"+
|
||||
"PLUGIN.exports[#PLUGIN.exports+1] = '__hostof'\n"+
|
||||
"PLUGIN.__hostof = function(q) return { h = hostOf(q.u) } end\n"+
|
||||
"return PLUGIN", 1)
|
||||
if shim == src {
|
||||
t.Fatal("could not inject the decision shim — quota.lua no longer ends with 'return PLUGIN'")
|
||||
}
|
||||
return newQuotaPluginsWith(t, shim, "127.0.0.1")
|
||||
}
|
||||
|
||||
// quotaSkip asks the plugin whether a host that failed `streak` times and was
|
||||
// last heard from `age` seconds ago should be skipped.
|
||||
func quotaSkip(t *testing.T, ps *Plugins, streak, age int) bool {
|
||||
out, err := ps.Call("quota", "__decision", map[string]interface{}{
|
||||
"at": float64(time.Now().Unix() - int64(age)),
|
||||
"streak": float64(streak),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("decision: %v", err)
|
||||
}
|
||||
// A single-field table collapses to the bare value on encode, so both shapes
|
||||
// mean the same thing here.
|
||||
switch v := out.(type) {
|
||||
case bool:
|
||||
return v
|
||||
case map[string]interface{}:
|
||||
b, ok := v["r"].(bool)
|
||||
if !ok {
|
||||
t.Fatalf("decision returned %T, not a boolean: %#v", v["r"], v)
|
||||
}
|
||||
return b
|
||||
default:
|
||||
t.Fatalf("decision returned %T (%v), not a boolean", out, out)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// newQuotaPlugins boots the shipped quota.lua against a temp plugin dir.
|
||||
func newQuotaPlugins(t *testing.T, baseURL string) *Plugins {
|
||||
t.Helper()
|
||||
return newQuotaPluginsWith(t, readShippedPlugin(t, "quota.lua"), baseURL)
|
||||
}
|
||||
|
||||
func newQuotaPluginsWith(t *testing.T, src, baseURL string) *Plugins {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "plugins")
|
||||
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vm := NewVM(filepath.Join(dir, "adapters"))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(vm.Stop)
|
||||
ps := NewPlugins(vm, pluginDir)
|
||||
t.Cleanup(ps.Close)
|
||||
ps.SetOutboundHosts([]string{"127.0.0.1"})
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "quota.lua"), []byte(src), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadDir(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := ps.find("quota")
|
||||
if p == nil {
|
||||
t.Fatal("quota plugin did not load")
|
||||
}
|
||||
if p.LoadError != "" {
|
||||
t.Fatalf("quota plugin failed to compile: %s", p.LoadError)
|
||||
}
|
||||
_ = baseURL
|
||||
return ps
|
||||
}
|
||||
|
||||
// countingServer answers 404 on every path and counts what it is asked, so a test
|
||||
// can tell "the plugin skipped" (no requests) from "the plugin probed and found
|
||||
// nothing" (requests, no data).
|
||||
//
|
||||
// It used to answer 200 with a non-balance body, and the commandcode adapter
|
||||
// accepted that — so the host was memoised as a SUCCESS and the negative path was
|
||||
// never reached. The test then failed for the right reason, and would have
|
||||
// passed for the wrong one had the assertion been looser.
|
||||
type countingServer struct {
|
||||
*httptest.Server
|
||||
hits int
|
||||
}
|
||||
|
||||
func (c *countingServer) Hits() int { return c.hits }
|
||||
|
||||
func newCountingServer(t *testing.T) *countingServer {
|
||||
t.Helper()
|
||||
cs := &countingServer{}
|
||||
cs.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cs.hits++
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"error":"not found"}`))
|
||||
}))
|
||||
t.Cleanup(cs.Close)
|
||||
return cs
|
||||
}
|
||||
|
||||
// TestQuotaDueSourcesAndMemoRecordCoverTheShardGap pins the split that makes the
|
||||
// backoff work at all under sharding.
|
||||
//
|
||||
// The conflict, measured on a live 20-source gateway:
|
||||
// - probing is network-bound and Plugins.Call holds the plugin mutex for the
|
||||
// whole Lua call, so /call/ serialises (29s)
|
||||
// - so the page uses ShardCall — and a shard has its own PLUGIN.state
|
||||
// - and the backoff lives in PLUGIN.state: fanout left the memo at 0 entries,
|
||||
// so dead hosts were re-probed forever
|
||||
//
|
||||
// The resolution is to put the DECISION and the RECORDING back on the shared VM
|
||||
// (microseconds, no network) and shard only the network wait. These two exports
|
||||
// are that seam, and without them the fanout path silently defeats the backoff.
|
||||
func TestQuotaDueSourcesAndMemoRecordCoverTheShardGap(t *testing.T) {
|
||||
src := readShippedPlugin(t, "quota.lua")
|
||||
for _, want := range []string{"due_sources", "memo_record"} {
|
||||
if !strings.Contains(src, want) {
|
||||
t.Errorf("quota.lua does not export %s — a sharded pass cannot see the "+
|
||||
"backoff state, nor leave any behind", want)
|
||||
}
|
||||
if !strings.Contains(src, `"`+want+`"`) {
|
||||
t.Errorf("%s is not listed in PLUGIN.exports, so the page cannot call it", want)
|
||||
}
|
||||
}
|
||||
// due_sources must not probe. If it did, the whole point of asking it first
|
||||
// would be lost: the cost would be paid before any shard runs.
|
||||
if strings.Contains(src, "due_sources = function(payload)") &&
|
||||
strings.Contains(src, "due_sources") {
|
||||
// The body is small; check it does not call collectSource.
|
||||
i := strings.Index(src, "due_sources = function(payload)")
|
||||
j := strings.Index(src[i:], "\nend\n")
|
||||
if j > 0 {
|
||||
body := src[i : i+j]
|
||||
if strings.Contains(body, "collectSource(") {
|
||||
t.Error("due_sources calls collectSource — the filter must make no " +
|
||||
"network request, or it costs the same as probing everything")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaMemoRecordGrowsTheStreakFromShards covers the recording half: a host
|
||||
// that failed inside a shard must end up in the SHARED memo with a streak, or the
|
||||
// next refresh has nothing to back off from.
|
||||
func TestQuotaMemoRecordGrowsTheStreakFromShards(t *testing.T) {
|
||||
srv := newCountingServer(t)
|
||||
ps := newQuotaPlugins(t, srv.URL)
|
||||
host := hostOnly(srv.URL)
|
||||
|
||||
read := func() map[string]interface{} {
|
||||
st, _ := ps.State("quota").(map[string]interface{})
|
||||
memo, _ := st["probe_memo"].(map[string]interface{})
|
||||
return memo
|
||||
}
|
||||
|
||||
// A shard reports a host that answered with nothing.
|
||||
for i := 1; i <= 3; i++ {
|
||||
out, err := ps.Call("quota", "memo_record", map[string]interface{}{
|
||||
"results": []interface{}{
|
||||
map[string]interface{}{"host": srv.URL + "/v1"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("memo_record %d: %v", i, err)
|
||||
}
|
||||
if out != true {
|
||||
t.Fatalf("memo_record returned %#v", out)
|
||||
}
|
||||
}
|
||||
memo := read()
|
||||
e, ok := memo[host].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("memo has no entry for %s: %#v", host, memo)
|
||||
}
|
||||
if got, _ := e["streak"].(float64); got != 3 {
|
||||
t.Errorf("streak after three shard failures = %.0f, want 3", got)
|
||||
}
|
||||
|
||||
// A host that answers is recorded as a positive memo, which resets the streak.
|
||||
if _, err := ps.Call("quota", "memo_record", map[string]interface{}{
|
||||
"results": []interface{}{
|
||||
map[string]interface{}{"host": srv.URL + "/v1", "vendor": "deepseek"},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := read()[host]; got != "deepseek" {
|
||||
t.Errorf("a host that answered is memoised as %#v, want the vendor name — "+
|
||||
"the streak must not survive a success", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaDueSourcesSkipsBackedOffHosts is the behaviour the page depends on:
|
||||
// the filter must keep a backed-off host out of the sharded set.
|
||||
func TestQuotaDueSourcesSkipsBackedOffHosts(t *testing.T) {
|
||||
srv := newCountingServer(t)
|
||||
ps := newQuotaPlugins(t, srv.URL)
|
||||
host := hostOnly(srv.URL)
|
||||
|
||||
// One failure, which is enough to put the host inside its window.
|
||||
if _, err := ps.Call("quota", "memo_record", map[string]interface{}{
|
||||
"results": []interface{}{map[string]interface{}{"host": srv.URL + "/v1"}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A SECOND host, so the two cases are genuinely independent. The memo is keyed
|
||||
// by host, so two paths on one host are one entry — using the same server here
|
||||
// would have asserted that a per-host memo behaves per-path.
|
||||
other := newCountingServer(t)
|
||||
srcs := []interface{}{
|
||||
map[string]interface{}{"name": "dead", "base_url": srv.URL + "/v1", "api_key": "k"},
|
||||
map[string]interface{}{"name": "fresh", "base_url": other.URL + "/v1", "api_key": "k"},
|
||||
}
|
||||
out, err := ps.Call("quota", "due_sources", map[string]interface{}{"sources": srcs})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, ok := out.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("due_sources returned %#v", out)
|
||||
}
|
||||
due, _ := m["due"].([]interface{})
|
||||
backing, _ := m["backing_off"].([]interface{})
|
||||
if len(backing) != 1 {
|
||||
t.Errorf("backing_off = %#v, want exactly the host that failed", backing)
|
||||
} else if b, _ := backing[0].(map[string]interface{}); b["host"] != host {
|
||||
t.Errorf("backing off %v, want %s — the key must be the host, or it will "+
|
||||
"never match on the next refresh", b["host"], host)
|
||||
}
|
||||
if len(due) != 1 {
|
||||
t.Errorf("due = %#v, want only the unprobed host", due)
|
||||
} else if d, _ := due[0].(map[string]interface{}); d["name"] != "fresh" {
|
||||
t.Errorf("due contains %v, want fresh", d["name"])
|
||||
}
|
||||
|
||||
// And it must make no network request — the whole saving depends on it.
|
||||
if n := srv.Hits(); n != 0 {
|
||||
t.Errorf("due_sources made %d requests; it must decide without probing", n)
|
||||
}
|
||||
}
|
||||
|
||||
// hostOnly mirrors the plugin's own hostOf. Used only to CHECK what the plugin
|
||||
// wrote — seeding a memo under a different key produces a memo the plugin never
|
||||
// looks up, which cost this file a couple of rounds before it was noticed.
|
||||
func hostOnly(baseURL string) string {
|
||||
u := strings.TrimPrefix(strings.TrimPrefix(baseURL, "https://"), "http://")
|
||||
if i := strings.Index(u, "/"); i >= 0 {
|
||||
u = u[:i]
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// TestQuotaBackoffRecordIsSkippedAndFlagged pins the shape the page depends on.
|
||||
//
|
||||
// Two things went wrong in the same field, and both were silent:
|
||||
//
|
||||
// 1. The back-off record had no `skipped` flag, so the page's
|
||||
// `filter(r => !r.skipped)` let it through to the card renderer. That
|
||||
// renderer matches on known fields, and a record carrying only a reason
|
||||
// matches none — so it fell through to a default that said "有数据".
|
||||
// Thirteen dead upstreams were rendered as though they had answered.
|
||||
// 2. Once it did carry `skipped`, the page grouped skipped sources by reading
|
||||
// the REASON TEXT. Translating that string would have silently merged
|
||||
// "backing off" into "exposes no balance endpoint" — a different claim
|
||||
// about the upstream. The grouping keys on an explicit flag.
|
||||
func TestQuotaBackoffRecordIsSkippedAndFlagged(t *testing.T) {
|
||||
srv := newCountingServer(t)
|
||||
ps := newQuotaPlugins(t, srv.URL)
|
||||
|
||||
// One failure, enough to put the host inside its window.
|
||||
if _, err := ps.Call("quota", "memo_record", map[string]interface{}{
|
||||
"results": []interface{}{map[string]interface{}{"host": srv.URL + "/v1"}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out, err := ps.Call("quota", "due_sources", map[string]interface{}{
|
||||
"sources": []interface{}{map[string]interface{}{
|
||||
"name": "dead", "base_url": srv.URL + "/v1", "api_key": "k",
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, _ := out.(map[string]interface{})
|
||||
backing, _ := m["backing_off"].([]interface{})
|
||||
if len(backing) != 1 {
|
||||
t.Fatalf("backing_off = %#v, want one record", m["backing_off"])
|
||||
}
|
||||
rec, _ := backing[0].(map[string]interface{})
|
||||
if rec["skipped"] != true {
|
||||
t.Errorf("back-off record has skipped=%v — the page filters cards on this "+
|
||||
"field, and without it the record is rendered as a source with data",
|
||||
rec["skipped"])
|
||||
}
|
||||
if rec["backing_off"] != true {
|
||||
t.Errorf("back-off record has no explicit backing_off flag; the page groups " +
|
||||
"by it instead of by reason text, so translating the message cannot " +
|
||||
"merge this with 'no balance endpoint'")
|
||||
}
|
||||
if s, _ := rec["reason"].(string); s == "" {
|
||||
t.Error("back-off record carries no reason; the card renders empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaRenderCallSitesPassRecordsNotNames guards a bug that only appeared
|
||||
// because ONE of two identical call sites was fixed.
|
||||
//
|
||||
// render() groups skipped sources by the backing_off flag. A call site that maps
|
||||
// the records to their names first ("skipped.map(r => r.name)") therefore hands
|
||||
// render an array of strings: the flag is gone, every entry falls into the
|
||||
// "exposes no balance endpoint" bucket, and the footer lists N unnamed sources.
|
||||
//
|
||||
// The first fix changed only the refresh path. The snapshot path kept mapping to
|
||||
// names, and the symptom was a footer reading "未列出 13 个源:、、、、…" — which
|
||||
// reads like a rendering bug rather than a contract mismatch between two call
|
||||
// sites of one function.
|
||||
func TestQuotaRenderCallSitesPassRecordsNotNames(t *testing.T) {
|
||||
src := readShippedPlugin(t, "quota.lua")
|
||||
if strings.Contains(src, ".map(function(r){ return r.name; })") ||
|
||||
strings.Contains(src, ".map(function(r){return r.name;})") {
|
||||
// A benign occurrence would be fine, but there is no other reason for a
|
||||
// skipped list to be reduced to names, so treat any as the bug.
|
||||
for _, line := range strings.Split(src, "\n") {
|
||||
if strings.Contains(line, "skipped") && strings.Contains(line, ".map(") &&
|
||||
strings.Contains(line, "r.name") {
|
||||
t.Errorf("a skipped list is mapped to names before reaching render(): %s\n"+
|
||||
"render() groups on the backing_off flag, which a name cannot carry",
|
||||
strings.TrimSpace(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
// Both call sites must exist and both must hand over records.
|
||||
if n := strings.Count(src, ".filter(function(r){return r.skipped;})") +
|
||||
strings.Count(src, ".filter(function(r){ return r && r.skipped; })"); n < 2 {
|
||||
t.Errorf("found %d skipped-list filters, want 2 (snapshot and refresh paths)", n)
|
||||
}
|
||||
}
|
||||
107
internal/provider/model_kind_test.go
Normal file
107
internal/provider/model_kind_test.go
Normal file
@ -0,0 +1,107 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
)
|
||||
|
||||
// TestSourceStatus_CarriesModelKind is the fix for a UI bug where the chat
|
||||
// test page listed image models next to chat ones.
|
||||
//
|
||||
// The root cause was structural, not a missing filter: SourceStatus.Models was
|
||||
// a flat []string, so by the time the UI had the list there was nothing left to
|
||||
// tell "gpt-image-2" from "claude-sonnet-5.5". Picking the former sent the
|
||||
// request to /api/chat, which cannot serve it, and the failure said nothing
|
||||
// about the real reason.
|
||||
//
|
||||
// Models stays a []string for callers that only need names; ModelKinds is the
|
||||
// additive field that carries the missing dimension.
|
||||
func TestSourceStatus_CarriesModelKind(t *testing.T) {
|
||||
src := config.Source{
|
||||
Name: "vela",
|
||||
BaseURL: "https://example.invalid",
|
||||
Adapter: "openai",
|
||||
APIKey: "sk-test",
|
||||
Endpoint: "/chat/completions",
|
||||
Models: []config.Model{
|
||||
{ID: "claude-sonnet-5.5"}, // kind omitted -> chat default
|
||||
{ID: "gpt-image-2", Kind: "image"}, // image
|
||||
{ID: "seedream-5.0", Kind: "image"},
|
||||
},
|
||||
}
|
||||
p := newTestProvider(t, src)
|
||||
|
||||
kinds := p.ModelsWithKind()
|
||||
if len(kinds) != 3 {
|
||||
t.Fatalf("want 3 entries, got %d", len(kinds))
|
||||
}
|
||||
|
||||
// Order must mirror Models() so the UI can zip the two lists.
|
||||
names := p.Models()
|
||||
for i := range kinds {
|
||||
if kinds[i].ID != names[i] {
|
||||
t.Errorf("index %d: ModelKinds has %q but Models has %q — the lists must stay aligned",
|
||||
i, kinds[i].ID, names[i])
|
||||
}
|
||||
}
|
||||
|
||||
want := map[string]string{
|
||||
"claude-sonnet-5.5": "chat",
|
||||
"gpt-image-2": "image",
|
||||
"seedream-5.0": "image",
|
||||
}
|
||||
for _, k := range kinds {
|
||||
if got := want[k.ID]; k.Kind != got {
|
||||
t.Errorf("%s: kind = %q, want %q", k.ID, k.Kind, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelKinds_CountsImageModelsInARealCatalog: the number of image models is
|
||||
// what decides whether the UI needs the split at all, so assert against a
|
||||
// catalog with both kinds rather than a hand-picked pair.
|
||||
func TestModelKinds_CountsImageModelsInARealCatalog(t *testing.T) {
|
||||
src := config.Source{
|
||||
Name: "vela", BaseURL: "https://example.invalid", Adapter: "openai", APIKey: "k",
|
||||
Models: []config.Model{
|
||||
{ID: "a"}, {ID: "b"}, {ID: "c"}, {ID: "d"},
|
||||
{ID: "img1", Kind: "image"}, {ID: "img2", Kind: "image"},
|
||||
},
|
||||
}
|
||||
p := newTestProvider(t, src)
|
||||
var images int
|
||||
for _, k := range p.ModelsWithKind() {
|
||||
if k.Kind == "image" {
|
||||
images++
|
||||
}
|
||||
}
|
||||
if images != 2 {
|
||||
t.Errorf("image models = %d, want 2", images)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceStatusJSON_ModelKindsOmittedWhenEmpty pins the backward-compat
|
||||
// promise: an older UI parsing this JSON must not break on a new field, and a
|
||||
// source with no model list must not gain a "model_kinds": null.
|
||||
func TestSourceStatusJSON_ModelKindsOmittedWhenEmpty(t *testing.T) {
|
||||
b, err := json.Marshal(SourceStatus{Name: "x", Models: []string{"a"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, present := m["model_kinds"]; present {
|
||||
t.Errorf("model_kinds present when empty: %s — should be omitted for older UIs", b)
|
||||
}
|
||||
if _, present := m["models"]; !present {
|
||||
t.Errorf("models field disappeared: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
// newTestProvider (defined in provider_test.go) spins up a real Lua VM, which
|
||||
// is heavier than these tests need, but reusing it keeps one construction path
|
||||
// for the whole package.
|
||||
@ -451,6 +451,25 @@ func (p *Provider) Models() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// ModelsWithKind returns the same ids as Models but paired with each model's
|
||||
// kind, in the same order.
|
||||
//
|
||||
// Models() cannot answer "which of these are image models", and that question
|
||||
// has to be answerable: the chat test page lists models from one source list,
|
||||
// and an image model offered there fails on every call because /api/chat is the
|
||||
// chat endpoint.
|
||||
func (p *Provider) ModelsWithKind() []ModelKindInfo {
|
||||
out := make([]ModelKindInfo, 0, len(p.cfg.Models))
|
||||
for _, m := range p.cfg.Models {
|
||||
kind := m.Kind
|
||||
if kind == "" {
|
||||
kind = "chat" // config.Model documents "" as the chat default
|
||||
}
|
||||
out = append(out, ModelKindInfo{ID: m.ID, Kind: kind})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ModelByID returns the model definition if owned by this source.
|
||||
func (p *Provider) ModelByID(id string) *config.Model {
|
||||
for i := range p.cfg.Models {
|
||||
|
||||
@ -196,12 +196,27 @@ func (r *Registry) ProviderForSlot(model, source string) *Provider {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ModelKindInfo carries the per-model fields the chat test UI needs to tell a
|
||||
// chat model from an image one.
|
||||
type ModelKindInfo struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"` // "chat" (default) | "image"
|
||||
}
|
||||
|
||||
// ModelStatus is a web-UI friendly snapshot per source.
|
||||
type SourceStatus struct {
|
||||
Name string `json:"name"`
|
||||
Adapter string `json:"adapter"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Models []string `json:"models"`
|
||||
// ModelKinds parallels Models: Models[i] is ModelKinds[i].ID.
|
||||
//
|
||||
// It exists because a flat []string cannot answer "is gpt-image-2
|
||||
// reachable from /api/chat?" — it cannot, and offering it there produced
|
||||
// a guaranteed failure with no explanation. Models stays unchanged for
|
||||
// callers that only need names; this is purely additive and omitted when
|
||||
// empty, so an older UI keeps working.
|
||||
ModelKinds []ModelKindInfo `json:"model_kinds,omitempty"`
|
||||
Available bool `json:"available"`
|
||||
Healthy bool `json:"healthy"`
|
||||
MaxConcurrent int `json:"max_concurrent"`
|
||||
@ -259,6 +274,7 @@ func (r *Registry) Status() []SourceStatus {
|
||||
Adapter: p.Adapter(),
|
||||
BaseURL: p.Config().BaseURL,
|
||||
Models: p.Models(),
|
||||
ModelKinds: p.ModelsWithKind(),
|
||||
Available: p.Available(),
|
||||
Healthy: p.Available(),
|
||||
MaxConcurrent: p.MaxConcurrent(),
|
||||
|
||||
71
packaging/upload-release.sh
Executable file
71
packaging/upload-release.sh
Executable file
@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env bash
|
||||
# Upload release assets to a GitCode release.
|
||||
#
|
||||
# The endpoint shapes are asymmetric and both differ from the "obvious" guess:
|
||||
#
|
||||
# GET /releases/tags/{tag} → read a release
|
||||
# GET /releases/{tag}/upload_url → get a presigned PUT (NO "tags/")
|
||||
# PUT {presigned url} → upload, REPLAYING the headers verbatim
|
||||
#
|
||||
# Two mistakes cost real time on v1.9.5 before this script existed:
|
||||
#
|
||||
# 1. POSTing straight to /releases/{tag}/assets returns 404 — that endpoint
|
||||
# does not exist. It reads as "authentication failed" if you only check
|
||||
# the status code.
|
||||
# 2. PUTting without the headers fails with an opaque storage error, not a
|
||||
# permission one, so it looks like a size limit or a corrupt file.
|
||||
#
|
||||
# The x-obs-* headers are part of the signature: dropping any one of them turns
|
||||
# the request into an unsigned one.
|
||||
set -uo pipefail
|
||||
|
||||
TOKEN="$(tr -d '\n\r' < /tmp/gc_token)"
|
||||
REPO="JianFeeeee/ModelRouter"
|
||||
TAG="${1:?usage: upload-release.sh <tag> <file>...}"
|
||||
shift
|
||||
|
||||
API="https://gitcode.com/api/v5/repos/${REPO}"
|
||||
|
||||
for f in "$@"; do
|
||||
name="$(basename "$f")"
|
||||
size="$(stat -c%s "$f")"
|
||||
printf ' %-46s %10s bytes ' "$name" "$size"
|
||||
|
||||
# 1) presigned upload location. The file name must be URL-encoded; the v1.9.4
|
||||
# assets show "~" surviving in a stored name, so encoding is per-component.
|
||||
enc="$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))' "$name")"
|
||||
resp="$(curl -sS -m 60 -H "Authorization: Bearer ${TOKEN}" \
|
||||
"${API}/releases/${TAG}/upload_url?file_name=${enc}" 2>&1)"
|
||||
url="$(printf '%s' "$resp" | python3 -c 'import json,sys
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
print(d.get("url") or d.get("uploadUrl") or "")
|
||||
except Exception:
|
||||
print("")' 2>/dev/null)"
|
||||
if [ -z "$url" ]; then
|
||||
echo "✗ 取得上传地址失败: ${resp:0:140}"
|
||||
continue
|
||||
fi
|
||||
|
||||
# 2) PUT with the headers replayed exactly as returned.
|
||||
code="$(printf '%s' "$resp" | python3 -c 'import json,sys
|
||||
try:
|
||||
print(json.dumps(json.load(sys.stdin).get("headers") or {}))
|
||||
except Exception:
|
||||
print("{}")' 2>/dev/null | python3 -c '
|
||||
import json, subprocess, sys
|
||||
headers = json.load(sys.stdin)
|
||||
args = ["curl", "-sS", "-o", "/dev/null", "-w", "%{http_code}", "-X", "PUT"]
|
||||
for k, v in headers.items():
|
||||
args += ["-H", f"{k}: {v}"]
|
||||
args += ["--data-binary", "@" + sys.argv[1], sys.argv[2]]
|
||||
print(subprocess.run(args, capture_output=True, text=True).stdout)
|
||||
' "$f" "$url" 2>/dev/null)"
|
||||
|
||||
if [ "$code" = "200" ] || [ "$code" = "201" ] || [ "$code" = "204" ]; then
|
||||
echo "✓ HTTP=$code"
|
||||
else
|
||||
echo "✗ HTTP=$code"
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
Reference in New Issue
Block a user