mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-12 12:17:23 +00:00
Compare commits
21 Commits
v1.9.6
...
release/v1
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e83ce0fc9 | |||
| ae2b121959 | |||
| af977efbf7 | |||
| 0d9eb916df | |||
| d01712a697 | |||
| 3444d2d903 | |||
| 83f1ec0807 | |||
| 65075b6d44 | |||
| 4a63b9dc0b | |||
| 75a0d247a4 | |||
| 2fdb82b119 | |||
| c96e6051ab | |||
| 82424564b6 | |||
| d1e7fd3e77 | |||
| 8909096d80 | |||
| f6561a3809 | |||
| 2e6cc60da6 | |||
| 92aa3d6a9b | |||
| 8aa06574fc | |||
| cdd1fcb529 | |||
| 0d9fb26244 |
41
README.md
41
README.md
@ -150,6 +150,47 @@ sources:
|
|||||||
timeout: 120s # 请求超时,默认 120s
|
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/` 均不提交版本库(见
|
`config.yaml`、`runtime.json`、`master.key`、`adapters/` 均不提交版本库(见
|
||||||
`.gitignore`),请随备份带走——`master.key` 丢失后运行时密文无法解密。
|
`.gitignore`),请随备份带走——`master.key` 丢失后运行时密文无法解密。
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "modelrouter-gui",
|
"name": "modelrouter-gui",
|
||||||
"version": "1.9.6",
|
"version": "1.9.9",
|
||||||
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
|
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
|
||||||
"author": "ModelRouter",
|
"author": "ModelRouter",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
|
|||||||
@ -650,11 +650,29 @@ func (c *Core) rebuildRegistry() error {
|
|||||||
// allowlist for plugin outbound HTTP. Unparseable URLs are skipped rather than
|
// allowlist for plugin outbound HTTP. Unparseable URLs are skipped rather than
|
||||||
// failing startup: a malformed base_url is already rejected when the provider is
|
// failing startup: a malformed base_url is already rejected when the provider is
|
||||||
// built, and it must not take plugins down with it.
|
// built, and it must not take plugins down with it.
|
||||||
|
//
|
||||||
|
// A source may also imply a SECOND host. OpenDesign splits inference
|
||||||
|
// (amr-link.open-design.ai) from billing (amr-api.open-design.ai), and the
|
||||||
|
// quota plugin derives the billing host from the inference one — so the host it
|
||||||
|
// asks for is not the host the operator configured, and the request would be
|
||||||
|
// refused with a message that reads like a plugin defect. Adding the derived
|
||||||
|
// host here is what makes the probe reachable.
|
||||||
|
//
|
||||||
|
// The grant stays scoped: only a source that IS on amr-link pulls in amr-api, so
|
||||||
|
// a deployment with no OpenDesign source does not silently gain the entry.
|
||||||
func sourceHosts(cfg *config.Config) []string {
|
func sourceHosts(cfg *config.Config) []string {
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
out := make([]string, 0, len(cfg.Sources))
|
out := make([]string, 0, len(cfg.Sources))
|
||||||
|
seen := make(map[string]bool, len(cfg.Sources))
|
||||||
|
add := func(h string) {
|
||||||
|
if h == "" || seen[h] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[h] = true
|
||||||
|
out = append(out, h)
|
||||||
|
}
|
||||||
for _, s := range cfg.Sources {
|
for _, s := range cfg.Sources {
|
||||||
if s.BaseURL == "" {
|
if s.BaseURL == "" {
|
||||||
continue
|
continue
|
||||||
@ -663,11 +681,27 @@ func sourceHosts(cfg *config.Config) []string {
|
|||||||
if err != nil || u.Hostname() == "" {
|
if err != nil || u.Hostname() == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, u.Hostname())
|
add(u.Hostname())
|
||||||
|
add(deriveBillingHost(u.Hostname()))
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deriveBillingHost returns the billing host a configured source implies, or
|
||||||
|
// the host unchanged when it implies none.
|
||||||
|
//
|
||||||
|
// Kept in Go as well as in the Lua adapter because the two must agree: Go builds
|
||||||
|
// the allowlist and Lua builds the request URL, and a disagreement shows up as a
|
||||||
|
// refused request rather than as an error naming the mismatch. The rule is the
|
||||||
|
// full hostname, not a prefix — `amr-link.` as a prefix would also rewrite
|
||||||
|
// amr-link.example.com into an OpenDesign address.
|
||||||
|
func deriveBillingHost(host string) string {
|
||||||
|
if host == "amr-link.open-design.ai" {
|
||||||
|
return "amr-api.open-design.ai"
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
// buildAutoChain rebuilds the AUTO chain snapshot from config.yaml rules
|
// buildAutoChain rebuilds the AUTO chain snapshot from config.yaml rules
|
||||||
// against the current providers. Slot model ids are normalized to the exact
|
// against the current providers. Slot model ids are normalized to the exact
|
||||||
// configured spelling (case-insensitive match), otherwise ModelFor would fall
|
// configured spelling (case-insensitive match), otherwise ModelFor would fall
|
||||||
|
|||||||
108
internal/core/source_hosts_test.go
Normal file
108
internal/core/source_hosts_test.go
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"llmsproxy/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The plugin outbound allowlist is built from the hosts of configured sources.
|
||||||
|
// That rule is correct for every adapter whose credential and endpoint live on
|
||||||
|
// the same host — which was all of them until OpenDesign.
|
||||||
|
//
|
||||||
|
// OpenDesign splits them: inference is amr-link.open-design.ai, billing is
|
||||||
|
// amr-api.open-design.ai. The quota plugin derives the billing host from the
|
||||||
|
// inference one, so the host it asks for is NOT the host the operator
|
||||||
|
// configured. The request is then refused by the allowlist with a message that
|
||||||
|
// reads like a plugin defect ("host ... is not an allowed outbound destination")
|
||||||
|
// rather than a missing entry.
|
||||||
|
//
|
||||||
|
// This file pins the bridge: when a source looks like OpenDesign, the derived
|
||||||
|
// billing host joins the allowlist.
|
||||||
|
|
||||||
|
func TestSourceHostsIncludesConfiguredHosts(t *testing.T) {
|
||||||
|
cfg := &config.Config{Sources: []config.Source{
|
||||||
|
{Name: "a", BaseURL: "https://api.deepseek.com"},
|
||||||
|
{Name: "b", BaseURL: "https://amr-link.open-design.ai/v1"},
|
||||||
|
{Name: "empty", BaseURL: ""},
|
||||||
|
{Name: "bad", BaseURL: "://nonsense"},
|
||||||
|
}}
|
||||||
|
got := map[string]bool{}
|
||||||
|
for _, h := range sourceHosts(cfg) {
|
||||||
|
got[h] = true
|
||||||
|
}
|
||||||
|
for _, want := range []string{"api.deepseek.com", "amr-link.open-design.ai"} {
|
||||||
|
if !got[want] {
|
||||||
|
t.Errorf("sourceHosts missing configured host %q (allowlist would refuse "+
|
||||||
|
"every plugin request to it): %v", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An empty/unparseable base_url must not abort the whole list.
|
||||||
|
if !got["api.deepseek.com"] {
|
||||||
|
t.Fatal("an unparseable sibling source dropped a valid host")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSourceHostsAddsDerivedBillingHost covers the OpenDesign case at the level
|
||||||
|
// that actually matters: the host the PLUGIN asks for must be in the allowlist.
|
||||||
|
func TestSourceHostsAddsDerivedBillingHost(t *testing.T) {
|
||||||
|
cfg := &config.Config{Sources: []config.Source{
|
||||||
|
{Name: "vela", BaseURL: "https://amr-link.open-design.ai/v1"},
|
||||||
|
}}
|
||||||
|
got := map[string]bool{}
|
||||||
|
for _, h := range sourceHosts(cfg) {
|
||||||
|
got[h] = true
|
||||||
|
}
|
||||||
|
if !got["amr-link.open-design.ai"] {
|
||||||
|
t.Fatalf("inference host missing: %v", got)
|
||||||
|
}
|
||||||
|
if !got["amr-api.open-design.ai"] {
|
||||||
|
t.Fatalf("the billing host the quota plugin derives is not in the allowlist, "+
|
||||||
|
"so every OpenDesign balance probe is refused before it leaves the "+
|
||||||
|
"process: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A deployment with no OpenDesign source must not silently gain its billing
|
||||||
|
// host: an allowlist entry is a grant, and widening it for everyone would let an
|
||||||
|
// unrelated plugin reach a host the operator never configured.
|
||||||
|
func TestSourceHostsDoesNotGrantUnconfiguredBillingHost(t *testing.T) {
|
||||||
|
cfg := &config.Config{Sources: []config.Source{
|
||||||
|
{Name: "ds", BaseURL: "https://api.deepseek.com"},
|
||||||
|
}}
|
||||||
|
for _, h := range sourceHosts(cfg) {
|
||||||
|
if h == "amr-api.open-design.ai" {
|
||||||
|
t.Fatal("the OpenDesign billing host was granted to a deployment with " +
|
||||||
|
"no OpenDesign source")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The derivation has to agree with the Lua side. Keep the two spellings of the
|
||||||
|
// rule in one place so a change to one is visible when reading the other.
|
||||||
|
func TestSourceHostsDerivationMatchesPluginRule(t *testing.T) {
|
||||||
|
// what the Lua adapter does: origin:gsub("^amr%-link%.", "amr-api.")
|
||||||
|
const in = "amr-link.open-design.ai"
|
||||||
|
want := "amr-api.open-design.ai"
|
||||||
|
got := deriveBillingHost(in)
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("deriveBillingHost(%q) = %q, want %q — the Go allowlist and the "+
|
||||||
|
"Lua probe must agree on the host or the request is refused", in, got, want)
|
||||||
|
}
|
||||||
|
// A host that is not amr-link stays untouched, so this cannot accidentally
|
||||||
|
// rewrite an unrelated provider into an OpenDesign address.
|
||||||
|
for _, other := range []string{"api.deepseek.com", "amr-link.example.com", "xamr-link.a"} {
|
||||||
|
if deriveBillingHost(other) != other {
|
||||||
|
t.Errorf("deriveBillingHost(%q) = %q, want it unchanged", other, deriveBillingHost(other))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// url.Parse round-trip keeps the helper honest about what it receives.
|
||||||
|
u, err := url.Parse("https://amr-link.open-design.ai/v1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if deriveBillingHost(u.Hostname()) != "amr-api.open-design.ai" {
|
||||||
|
t.Fatal("derivation failed on a real base_url hostname")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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", Auth: "any", Summary: "usage aggregates"},
|
||||||
{Method: "GET", Path: "/api/stats/records", Auth: "any", Summary: "paged request records"},
|
{Method: "GET", Path: "/api/stats/records", Auth: "any", Summary: "paged request records"},
|
||||||
{Method: "GET", Path: "/api/adapters", Auth: "admin", Summary: "installed Lua adapters"},
|
{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: "GET", Path: "/api/source_templates", Auth: "admin", Summary: "source templates"},
|
||||||
{Method: "POST", Path: "/v1/chat/completions", Auth: "any", Summary: "OpenAI-compatible inference"},
|
{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
|
return nil
|
||||||
}
|
}
|
||||||
// A scope that simply does not list the model is a different problem from
|
// 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
|
// 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
|
// 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
|
// them to the wrong setting. AUTO is checked here before the chain is even
|
||||||
// consulted, so this is the message that surfaces for AUTO.
|
// consulted, so this is the message that surfaces for AUTO.
|
||||||
if isAuto(model) {
|
if isAuto(model) {
|
||||||
return "aRejection{msg: fmt.Sprintf(
|
return "aRejection{msg: fmt.Sprintf(
|
||||||
"model %q is not in this key's model scope, so it cannot use AUTO; "+
|
"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)}
|
"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)}
|
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
|
// 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
|
// A SUBSET of completion_tokens, not an extra. Billing plugins that
|
||||||
// add it to completion would double-charge every thinking token.
|
// add it to completion would double-charge every thinking token.
|
||||||
"reasoning_tokens": rec.Reasoning,
|
"reasoning_tokens": rec.Reasoning,
|
||||||
"image_count": rec.ImageCount,
|
"image_count": rec.ImageCount,
|
||||||
"error": rec.Err,
|
"error": rec.Err,
|
||||||
"time": rec.Time,
|
"time": rec.Time,
|
||||||
// chain_walk: the AUTO tier-by-tier trace, when the request went
|
// 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
|
// 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
|
// 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
|
var tut *types.TokenUsage
|
||||||
if lastUsage != nil {
|
if lastUsage != nil {
|
||||||
tut = lastUsage
|
tut = lastUsage
|
||||||
// Write the upstream's cache accounting back onto the request record
|
applyStreamUsageDetails(rec, lastUsage)
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
} else if rec.Prompt+rec.Compl > 0 {
|
} else if rec.Prompt+rec.Compl > 0 {
|
||||||
u := types.TokenUsage{
|
u := types.TokenUsage{
|
||||||
Prompt: int(rec.Prompt),
|
Prompt: int(rec.Prompt),
|
||||||
@ -1459,3 +1446,39 @@ func (g *Gateway) handleImage(w http.ResponseWriter, r *http.Request) {
|
|||||||
Data: resp.ImageData,
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
342
internal/gateway/discover_mutate_test.go
Normal file
342
internal/gateway/discover_mutate_test.go
Normal file
@ -0,0 +1,342 @@
|
|||||||
|
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.
|
||||||
|
//
|
||||||
|
// It also must not be run against a DIRTY tree: the mutant is written over
|
||||||
|
// discover.go, and a leftover mutant from a killed run would make the baseline
|
||||||
|
// green against broken code while every verdict came from the wrong source.
|
||||||
|
// See the guard at the top of TestRealMutants.
|
||||||
|
//
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refuse to run against a dirty tree.
|
||||||
|
//
|
||||||
|
// The mutant is written OVER discover.go, so on a dirty file the "original"
|
||||||
|
// captured above is not the committed source — the baseline then runs against
|
||||||
|
// whatever was there, and a killed run (a background-task timeout, a Ctrl-C)
|
||||||
|
// leaves a mutant behind that the NEXT run treats as the baseline. Both make
|
||||||
|
// every verdict meaningless while the harness still reports a clean score.
|
||||||
|
//
|
||||||
|
// Refusing rather than restoring: this cannot tell "leftover mutant" apart from
|
||||||
|
// "the developer is mid-edit", and silently reverting real work is worse than
|
||||||
|
// stopping.
|
||||||
|
if dirty, derr := mutationTargetIsDirty(wd, "discover.go"); derr == nil && dirty {
|
||||||
|
t.Fatalf("%s has uncommitted changes — commit or stash them first. A mutant "+
|
||||||
|
"left behind by a killed run would otherwise become the baseline, and "+
|
||||||
|
"every verdict below would be about code nobody wrote.", target)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mutationTargetIsDirty reports whether a file under internal/gateway has
|
||||||
|
// uncommitted changes, so the harness can refuse to run instead of treating a
|
||||||
|
// leftover mutant as the baseline.
|
||||||
|
//
|
||||||
|
// git runs from the package directory with a PATH RELATIVE to it. An earlier
|
||||||
|
// version of the sibling script passed a repo-relative pathspec while running in
|
||||||
|
// the package directory; git matched nothing and the guard never fired.
|
||||||
|
func mutationTargetIsDirty(wd, name string) (bool, error) {
|
||||||
|
cmd := exec.Command("git", "diff", "--name-only", "--", "./"+name)
|
||||||
|
cmd.Dir = wd
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out)) != "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -224,6 +224,11 @@ func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) {
|
|||||||
g.handleAdaptersAPI(w, r)
|
g.handleAdaptersAPI(w, r)
|
||||||
case r.URL.Path == "/api/v1" || strings.HasPrefix(r.URL.Path, "/api/v1/"):
|
case r.URL.Path == "/api/v1" || strings.HasPrefix(r.URL.Path, "/api/v1/"):
|
||||||
g.apiV1Routes(w, r)
|
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/"):
|
case r.URL.Path == "/api/sources" || strings.HasPrefix(r.URL.Path, "/api/sources/"):
|
||||||
g.handleSourcesAPI(w, r)
|
g.handleSourcesAPI(w, r)
|
||||||
case r.URL.Path == "/api/source_templates" || strings.HasPrefix(r.URL.Path, "/api/source_templates/"):
|
case r.URL.Path == "/api/source_templates" || strings.HasPrefix(r.URL.Path, "/api/source_templates/"):
|
||||||
|
|||||||
@ -765,9 +765,20 @@ func (s *Stats) WindowTokens(model, source string, sec int64) int64 {
|
|||||||
return total
|
return total
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rows flattens a map[string]*Stat into a reportable, descending-by-requests
|
||||||
|
// list.
|
||||||
|
//
|
||||||
|
// A nil value is skipped rather than dereferenced. The maps this is called with
|
||||||
|
// are built by several callers (Snapshot, PeriodSnapshot, the audit replay) and a
|
||||||
|
// nil entry is a legitimate representation of "this dimension has no data" —
|
||||||
|
// `map[string]*Stat{key: s.byKey[key]}` produced exactly that whenever the
|
||||||
|
// queried key had no recorded traffic, and `*v` then panicked the handler.
|
||||||
func rows(m map[string]*Stat) []StatsRow {
|
func rows(m map[string]*Stat) []StatsRow {
|
||||||
out := make([]StatsRow, 0, len(m))
|
out := make([]StatsRow, 0, len(m))
|
||||||
for k, v := range m {
|
for k, v := range m {
|
||||||
|
if v == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
out = append(out, StatsRow{Name: k, Stat: *v})
|
out = append(out, StatsRow{Name: k, Stat: *v})
|
||||||
}
|
}
|
||||||
for i := 1; i < len(out); i++ {
|
for i := 1; i < len(out); i++ {
|
||||||
@ -1185,7 +1196,26 @@ func (s *Stats) Snapshot(limit int, key string) map[string]interface{} {
|
|||||||
if key == "" {
|
if key == "" {
|
||||||
byKey, byModel, bySrc = s.byKey, s.byModel, s.bySrc
|
byKey, byModel, bySrc = s.byKey, s.byModel, s.bySrc
|
||||||
} else {
|
} else {
|
||||||
byKey = map[string]*Stat{key: s.byKey[key]}
|
// The map is keyed by the MASKED key id (keyID(req.Key)), which is what
|
||||||
|
// by_key reports and what the dashboard sends as ?key=. An admin may
|
||||||
|
// pass anything in that query parameter, including the full key, and
|
||||||
|
// `s.byKey[key]` then returns the zero value — a nil *Stat.
|
||||||
|
//
|
||||||
|
// rows() below does `*v` on every value, so a nil entry panicked the
|
||||||
|
// handler: any ?key=<full key> request killed the response with an empty
|
||||||
|
// reply, and logged a full panic stack. Reproduced against the live
|
||||||
|
// gateway: /api/stats?period=day&limit=1&key=sk-gw-... → curl exit 52.
|
||||||
|
//
|
||||||
|
// A key with no recorded traffic is a legitimate question ("what did this
|
||||||
|
// key use?"), and its answer is zero — so an absent entry is an empty
|
||||||
|
// result, not an error, and not a crash.
|
||||||
|
st := s.byKey[key]
|
||||||
|
byKey = make(map[string]*Stat, 1)
|
||||||
|
if st != nil {
|
||||||
|
byKey[key] = st
|
||||||
|
} else {
|
||||||
|
byKey[key] = &Stat{}
|
||||||
|
}
|
||||||
byModel = s.byKeyModel[key]
|
byModel = s.byKeyModel[key]
|
||||||
bySrc = s.byKeySrc[key]
|
bySrc = s.byKeySrc[key]
|
||||||
}
|
}
|
||||||
@ -1202,8 +1232,11 @@ func (s *Stats) Snapshot(limit int, key string) map[string]interface{} {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
bs := make([]agrRow, 0, len(s.byStatus))
|
bs := make([]agrRow, 0, len(s.byStatus))
|
||||||
for code := range s.byStatus {
|
for code, st := range s.byStatus {
|
||||||
bs = append(bs, agrRow{Name: strconv.Itoa(code), Stat: *s.byStatus[code]})
|
if st == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bs = append(bs, agrRow{Name: strconv.Itoa(code), Stat: *st})
|
||||||
}
|
}
|
||||||
sort.Slice(bs, func(i, j int) bool {
|
sort.Slice(bs, func(i, j int) bool {
|
||||||
ci, _ := strconv.Atoi(bs[i].Name)
|
ci, _ := strconv.Atoi(bs[i].Name)
|
||||||
|
|||||||
@ -174,6 +174,9 @@ func (s *Stats) PeriodSnapshot(p Period, key string, now time.Time) PeriodSnapsh
|
|||||||
out.Srcs = rows(bySrc)
|
out.Srcs = rows(bySrc)
|
||||||
bs := make([]agrRow, 0, len(byStatus))
|
bs := make([]agrRow, 0, len(byStatus))
|
||||||
for code, st := range byStatus {
|
for code, st := range byStatus {
|
||||||
|
if st == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
bs = append(bs, agrRow{Name: code, Stat: *st})
|
bs = append(bs, agrRow{Name: code, Stat: *st})
|
||||||
}
|
}
|
||||||
sort.Slice(bs, func(i, j int) bool {
|
sort.Slice(bs, func(i, j int) bool {
|
||||||
@ -192,7 +195,11 @@ func (s *Stats) PeriodSnapshot(p Period, key string, now time.Time) PeriodSnapsh
|
|||||||
sort.Strings(ks)
|
sort.Strings(ks)
|
||||||
out.Bucket = make([]PeriodBucket, 0, len(ks))
|
out.Bucket = make([]PeriodBucket, 0, len(ks))
|
||||||
for _, k := range ks {
|
for _, k := range ks {
|
||||||
out.Bucket = append(out.Bucket, PeriodBucket{Bucket: k, Stat: *buckets[k]})
|
// Defensive for the same reason rows() is: ks comes from the bucket map's
|
||||||
|
// keys, and a nil value would panic on the deref below.
|
||||||
|
if st := buckets[k]; st != nil {
|
||||||
|
out.Bucket = append(out.Bucket, PeriodBucket{Bucket: k, Stat: *st})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The window is only "complete" if the audit walk actually reached back
|
// The window is only "complete" if the audit walk actually reached back
|
||||||
|
|||||||
140
internal/gateway/stats_unknown_key_test.go
Normal file
140
internal/gateway/stats_unknown_key_test.go
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSnapshotUnknownKeyDoesNotPanic covers a nil-dereference that killed the
|
||||||
|
// response for any /api/stats request carrying a ?key= the statistics had no
|
||||||
|
// entry for.
|
||||||
|
//
|
||||||
|
// The maps in Snapshot are keyed by the MASKED key id (keyID(req.Key)) — that
|
||||||
|
// is what by_key reports and what the dashboard sends. An admin may pass anything
|
||||||
|
// in the query parameter, including the full key, and `s.byKey[key]` then yields
|
||||||
|
// the zero value: a nil *Stat. rows() dereferenced every value, so the handler
|
||||||
|
// panicked and the client got an empty reply plus a panic stack in the log.
|
||||||
|
//
|
||||||
|
// Reproduced against the live gateway before the fix:
|
||||||
|
//
|
||||||
|
// /api/stats?period=day&limit=1&key=sk-gw-7ffe12c5cbacec40bd39a7291df9a9ed
|
||||||
|
// → curl: (52) Empty reply from server
|
||||||
|
// → log: http: panic serving …: invalid memory address or nil pointer dereference
|
||||||
|
// llmsproxy/internal/gateway.rows stats.go:771
|
||||||
|
//
|
||||||
|
// A key with no recorded traffic is a legitimate question, and zero is its
|
||||||
|
// answer — not a crash.
|
||||||
|
func TestSnapshotUnknownKeyDoesNotPanic(t *testing.T) {
|
||||||
|
s := NewStats(16)
|
||||||
|
// One recorded key, masked the way Record masks it.
|
||||||
|
s.Record(Req{Key: "***aaaaaa", Type: "chat", Model: "m", Prompt: 5, Compl: 2})
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
key string
|
||||||
|
}{
|
||||||
|
{"masked key that exists", "***aaaaaa"},
|
||||||
|
{"masked key that does not exist", "***zzzzzz"},
|
||||||
|
{"full key rather than the mask", "sk-gw-full-key-value"},
|
||||||
|
{"arbitrary text", "not-a-key-at-all"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
// A panic here fails the subtest by unwinding through the test
|
||||||
|
// goroutine; rows() used to take the whole process with it.
|
||||||
|
out := s.Snapshot(10, c.key)
|
||||||
|
byKey, ok := out["by_key"].([]StatsRow)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("by_key is %T, want []StatsRow", out["by_key"])
|
||||||
|
}
|
||||||
|
total, _ := out["total"].(Stat)
|
||||||
|
t.Logf("key=%q → by_key %d 条, total.reqs=%d", c.key, len(byKey), total.Reqs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSnapshotUnknownKeyReportsZeroNotAbsence pins the semantics: a key with no
|
||||||
|
// traffic answers zero, and the by_key row still exists so a dashboard that keys
|
||||||
|
// off its presence renders a row of zeros rather than dropping the key.
|
||||||
|
func TestSnapshotUnknownKeyReportsZeroNotAbsence(t *testing.T) {
|
||||||
|
s := NewStats(16)
|
||||||
|
out := s.Snapshot(10, "***zzzzzz")
|
||||||
|
byKey, _ := out["by_key"].([]StatsRow)
|
||||||
|
if len(byKey) != 1 {
|
||||||
|
t.Fatalf("by_key has %d rows, want 1: the queried key must appear with zeros", len(byKey))
|
||||||
|
}
|
||||||
|
if byKey[0].Name != "***zzzzzz" {
|
||||||
|
t.Errorf("row name = %q, want ***zzzzzz", byKey[0].Name)
|
||||||
|
}
|
||||||
|
if byKey[0].Reqs != 0 {
|
||||||
|
t.Errorf("reqs = %d, want 0", byKey[0].Reqs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPeriodSnapshotUnknownKeyDoesNotPanic is the same guard on the period view,
|
||||||
|
// which builds its own buckets and calls the same flattening helpers.
|
||||||
|
func TestPeriodSnapshotUnknownKeyDoesNotPanic(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
audit := dir + "/audit.jsonl"
|
||||||
|
writeAuditFile(t, audit, 3, "***aaaaaa", time.Now().Add(-time.Hour).UnixMilli())
|
||||||
|
s := periodStats(t, audit)
|
||||||
|
|
||||||
|
for _, key := range []string{"***aaaaaa", "***zzzzzz", "sk-gw-full-key-value"} {
|
||||||
|
out := s.PeriodSnapshot(PeriodAll, key, time.Now())
|
||||||
|
t.Logf("key=%q → total.reqs=%d byKey=%d buckets=%d",
|
||||||
|
key, out.Total.Reqs, len(out.ByKey), len(out.Bucket))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRowsSkipsNilValues pins the defensive behaviour directly, so a future
|
||||||
|
// caller that reintroduces a nil entry fails here rather than in production.
|
||||||
|
func TestRowsSkipsNilValues(t *testing.T) {
|
||||||
|
in := map[string]*Stat{
|
||||||
|
"has-traffic": {Reqs: 5},
|
||||||
|
"no-entry": nil,
|
||||||
|
}
|
||||||
|
got := rows(in)
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("rows() returned %d rows, want 1 (the nil entry must be skipped): %+v", len(got), got)
|
||||||
|
}
|
||||||
|
if got[0].Name != "has-traffic" {
|
||||||
|
t.Errorf("kept %q, want has-traffic", got[0].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRowsEmptyIsEmptyNotNil keeps the JSON shape honest: an all-nil map yields
|
||||||
|
// an empty list, which encodes as [] rather than null — the frontend does
|
||||||
|
// Array.isArray checks on these fields.
|
||||||
|
func TestRowsEmptyIsEmptyNotNil(t *testing.T) {
|
||||||
|
got := rows(map[string]*Stat{"a": nil})
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("rows() returned nil; the report field must stay [] instead of null")
|
||||||
|
}
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Fatalf("got %d rows, want 0", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStatsAPIUnknownKeyReturnsJSON exercises the HTTP path so the assertion
|
||||||
|
// covers the reported symptom (an empty reply), not just the helper.
|
||||||
|
func TestStatsAPIUnknownKeyReturnsJSON(t *testing.T) {
|
||||||
|
g := newTestGateway(t)
|
||||||
|
srv := httptest.NewServer(g.Handler())
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", srv.URL+"/api/stats?period=day&limit=1&key=sk-gw-full-key", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer sk-test")
|
||||||
|
resp, err := srv.Client().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed (an empty reply is what the live gateway produced): %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -366,6 +366,22 @@
|
|||||||
|
|
||||||
/* ---- chat page ---- */
|
/* ---- chat page ---- */
|
||||||
.tab-chat{max-width:840px;margin:0 auto;display:flex;flex-direction:column;height:calc(100vh - 190px)}
|
.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);
|
.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)}
|
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}
|
.chat-log{flex:1;overflow-y:auto;padding:20px 20px 8px;display:flex;flex-direction:column;gap:18px}
|
||||||
@ -573,6 +589,21 @@
|
|||||||
/></svg
|
/></svg
|
||||||
><span data-i="navChat">对话</span>
|
><span data-i="navChat">对话</span>
|
||||||
</button>
|
</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">
|
<button class="sb-i" data-tab="keys">
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@ -791,6 +822,7 @@
|
|||||||
<main id="main">
|
<main id="main">
|
||||||
<div id="tab-status" class="tab-pane"></div>
|
<div id="tab-status" class="tab-pane"></div>
|
||||||
<div id="tab-chat" class="tab-pane hidden"></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-keys" class="tab-pane hidden"></div>
|
||||||
<div id="tab-sort" class="tab-pane hidden"></div>
|
<div id="tab-sort" class="tab-pane hidden"></div>
|
||||||
<div id="tab-sources" class="tab-pane hidden"></div>
|
<div id="tab-sources" class="tab-pane hidden"></div>
|
||||||
@ -941,6 +973,20 @@
|
|||||||
adPoolPeak: "峰值",
|
adPoolPeak: "峰值",
|
||||||
adPoolSteps: "步长 (扩/缩)",
|
adPoolSteps: "步长 (扩/缩)",
|
||||||
cModel: "模型",
|
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 换行",
|
cMsg: "Enter 发送,Shift+Enter 换行",
|
||||||
send: "发送",
|
send: "发送",
|
||||||
cClear: "清空",
|
cClear: "清空",
|
||||||
@ -989,6 +1035,16 @@
|
|||||||
mModels: "模型列表",
|
mModels: "模型列表",
|
||||||
mAddModel: "+ 模型",
|
mAddModel: "+ 模型",
|
||||||
mMeta: "Meta",
|
mMeta: "Meta",
|
||||||
|
discScan: "自动探测模型",
|
||||||
|
discScanning: "探测中…",
|
||||||
|
discHint: "填好 URL 和密钥后点「自动探测模型」,网关会拉取上游的模型列表并填入下方;仍需点保存才会生效。",
|
||||||
|
discNeedUrl: "请先填写 URL",
|
||||||
|
discEmpty: "上游返回的模型列表为空",
|
||||||
|
discFound: "已找到 %d 个模型",
|
||||||
|
discChatN: "对话 %d 个",
|
||||||
|
discImageN: "图像 %d 个",
|
||||||
|
discUnsaved: "尚未保存,点保存后生效",
|
||||||
|
discFailed: "探测失败",
|
||||||
mSave: "保存",
|
mSave: "保存",
|
||||||
mCancel: "取消",
|
mCancel: "取消",
|
||||||
toastCopied: "已复制",
|
toastCopied: "已复制",
|
||||||
@ -1232,6 +1288,20 @@
|
|||||||
adPoolPeak: "Peak",
|
adPoolPeak: "Peak",
|
||||||
adPoolSteps: "Steps (grow/shrink)",
|
adPoolSteps: "Steps (grow/shrink)",
|
||||||
cModel: "Model",
|
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",
|
cMsg: "Enter to send, Shift+Enter for newline",
|
||||||
send: "Send",
|
send: "Send",
|
||||||
cClear: "Clear",
|
cClear: "Clear",
|
||||||
@ -1281,6 +1351,16 @@
|
|||||||
mModels: "Models",
|
mModels: "Models",
|
||||||
mAddModel: "+ model",
|
mAddModel: "+ model",
|
||||||
mMeta: "Meta",
|
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",
|
mSave: "Save",
|
||||||
mCancel: "Cancel",
|
mCancel: "Cancel",
|
||||||
toastCopied: "Copied",
|
toastCopied: "Copied",
|
||||||
@ -1471,7 +1551,7 @@
|
|||||||
// keep in sync, and adding a tab meant finding all three. A plugin page
|
// 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
|
// that is routed but never shown is exactly the kind of silent gap that
|
||||||
// survives review.
|
// 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
|
// 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
|
// `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
|
// goTab's read inside the temporal dead zone: the first click on a plugin
|
||||||
@ -2719,28 +2799,65 @@ recsState.keyNames = keyNames || {};
|
|||||||
/* ---------- chat tab ---------- */
|
/* ---------- chat tab ---------- */
|
||||||
let chatHistory = [];
|
let chatHistory = [];
|
||||||
let chatImages = [];
|
let chatImages = [];
|
||||||
async function renderChat() {
|
async function renderChat() {
|
||||||
const s = await api("/api/status");
|
const s = await api("/api/status");
|
||||||
// Prefer per-source options ("source:model" routing) when the caller sees
|
// Prefer per-source options ("source:model" routing) when the caller sees
|
||||||
// sources (admin); otherwise fall back to the flat model list (user scope).
|
// sources (admin); otherwise fall back to the flat model list (user scope).
|
||||||
let optList;
|
//
|
||||||
if (Array.isArray(s.sources) && s.sources.length) {
|
// Chat and image models are listed in SEPARATE groups. The backend used to
|
||||||
optList = ["AUTO"];
|
// hand this page one flat list with the kind stripped, so gpt-image-2 sat
|
||||||
s.sources.forEach((src) =>
|
// next to claude-sonnet-5.5 and picking it sent the request to /api/chat,
|
||||||
(src.models || []).forEach((m) => {
|
// which cannot serve it — a failure whose message never mentioned images.
|
||||||
optList.push(
|
//
|
||||||
`<option value="${escAttr(`${src.name}:${m}`)}">${esc(`${src.name} · ${m}`)}</option>`,
|
// 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 = [];
|
||||||
} else {
|
const addModel = (value, label, kind) => {
|
||||||
optList = ["AUTO"].concat(
|
const html = `<option value="${escAttr(value)}">${esc(label)}</option>`;
|
||||||
(s.models || []).map(
|
if (kind === "image") imgOpts.push(html);
|
||||||
(m) => `<option value="${escAttr(m)}">${esc(m)}</option>`,
|
else optList.push(html);
|
||||||
),
|
};
|
||||||
);
|
|
||||||
}
|
if (Array.isArray(s.sources) && s.sources.length) {
|
||||||
const finalOpts = optList.join("");
|
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 = `
|
$("#tab-chat").innerHTML = `
|
||||||
<div class="tab-chat">
|
<div class="tab-chat">
|
||||||
<div class="chat-wrap">
|
<div class="chat-wrap">
|
||||||
@ -2784,6 +2901,149 @@ recsState.keyNames = keyNames || {};
|
|||||||
$("#ct-imgs").style.display = "none";
|
$("#ct-imgs").style.display = "none";
|
||||||
$("#ct-msg").focus();
|
$("#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 {
|
||||||
|
// api() is a thin wrapper over fetch(p, o) — o is the NATIVE
|
||||||
|
// fetch options object, so body must be a STRING.
|
||||||
|
//
|
||||||
|
// Passing the object directly made fetch coerce it to the literal
|
||||||
|
// text "[object Object]", the gateway's json.Unmarshal rejected it,
|
||||||
|
// and the page showed a bare "400 Bad Request" with no image and no
|
||||||
|
// usable message. It cost a round of debugging because the DOM, the
|
||||||
|
// model list and the button state were all correct — only the wire
|
||||||
|
// format was wrong, which is invisible until you capture the request.
|
||||||
|
//
|
||||||
|
// JSON.stringify + an explicit Content-Type is what every other
|
||||||
|
// POST in this file does; the missing header plus the object body
|
||||||
|
// meant the server saw Content-Type: text/plain and a string body.
|
||||||
|
const payload = { model: model, prompt: prompt, n: n };
|
||||||
|
if (size) payload.size = size;
|
||||||
|
const r = await api("/v1/images/generations", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
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() {
|
function updateGrow() {
|
||||||
const ta = $("#ct-msg");
|
const ta = $("#ct-msg");
|
||||||
if (!ta) return;
|
if (!ta) return;
|
||||||
@ -3134,13 +3394,15 @@ recsState.keyNames = keyNames || {};
|
|||||||
.join("") +
|
.join("") +
|
||||||
"</select>";
|
"</select>";
|
||||||
wrap.innerHTML = `<div class="card"><h2><span>${esc(name ? t("modalEdit") + ": " + name : t("modalNew"))}</span><span class="grow"></span>
|
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>` : ""}
|
${!name ? `<button class="ghost small" onclick="srcFromTemplate()">${t("tplFrom")}</button>` : ""}
|
||||||
<button class="ghost small" onclick="srcSaveAsTemplate()">${t("tplSave")}</button></h2>
|
<button class="ghost small" onclick="srcSaveAsTemplate()">${t("tplSave")}</button></h2>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div><label>${t("mName")}</label><input id="s-name" value="${escAttr(s.name)}" ${name ? "disabled" : ""}></div>
|
<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("mAlias")}</label>${adSel}</div>
|
||||||
</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 || "")}">
|
<label>${t("mKey")}</label><input id="s-key" type="password" value="${escAttr(s.api_key || "")}">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div><label>${t("mEp")}</label><input id="s-ep" value="${escAttr(s.endpoint || "")}"></div>
|
<div><label>${t("mEp")}</label><input id="s-ep" value="${escAttr(s.endpoint || "")}"></div>
|
||||||
@ -3160,6 +3422,7 @@ recsState.keyNames = keyNames || {};
|
|||||||
</div>
|
</div>
|
||||||
<label>${t("mModels")}</label>
|
<label>${t("mModels")}</label>
|
||||||
<div id="s-models"></div>
|
<div id="s-models"></div>
|
||||||
|
<div class="hint" id="s-disc-hint">${t("discHint")}</div>
|
||||||
<button class="ghost small" onclick="addModelRow()">${t("mAddModel")}</button>
|
<button class="ghost small" onclick="addModelRow()">${t("mAddModel")}</button>
|
||||||
<label>${t("mMeta")}</label>
|
<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>
|
<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 +3471,96 @@ recsState.keyNames = keyNames || {};
|
|||||||
modelRow({ id: "", priority: 0, kind: "chat" }, div.children.length),
|
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",
|
||||||
|
// api() hands its second argument straight to fetch, so `body`
|
||||||
|
// must be a STRING. Passing the object made fetch send the literal
|
||||||
|
// text "[object Object]" and the gateway answered
|
||||||
|
// 400 invalid character 'o' — the Auto-discover button looked
|
||||||
|
// dead while curl against the same endpoint worked, because the
|
||||||
|
// curl test exercised the ENDPOINT and not this BUTTON.
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ 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) {
|
async function saveSource(btn) {
|
||||||
const models = [...document.querySelectorAll("#s-models .model-row")]
|
const models = [...document.querySelectorAll("#s-models .model-row")]
|
||||||
.map((row) => ({
|
.map((row) => ({
|
||||||
@ -3466,7 +3819,8 @@ recsState.keyNames = keyNames || {};
|
|||||||
<div><label>${t("mName")}</label><input id="s-name" value="${escAttr(s.name)}" ${name ? "disabled" : ""}></div>
|
<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("mAlias")}</label>${adSel}</div>
|
||||||
</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 class="row">
|
||||||
<div><label>${t("mEp")}</label><input id="s-ep" value="${escAttr(s.endpoint || "")}"></div>
|
<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>
|
<div><label>${t("mImgEp")}</label><input id="s-img" value="${escAttr(s.image_endpoint || "")}"></div>
|
||||||
@ -3481,6 +3835,7 @@ recsState.keyNames = keyNames || {};
|
|||||||
</div>
|
</div>
|
||||||
<label>${t("mModels")}</label>
|
<label>${t("mModels")}</label>
|
||||||
<div id="s-models"></div>
|
<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>
|
<button class="ghost small" onclick="addModelRow()">${t("mAddModel")}</button>
|
||||||
<label>${t("mMeta")}</label>
|
<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>
|
<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 +6063,7 @@ toast(t("kAutoChainGlobal"));
|
|||||||
const done = () => remountPluginElements();
|
const done = () => remountPluginElements();
|
||||||
if (tab === "status") return renderStatus().then(done, done);
|
if (tab === "status") return renderStatus().then(done, done);
|
||||||
if (tab === "chat") return renderChat().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 === "keys") return renderKeys().then(done, done);
|
||||||
if (tab === "sort") return renderSort().then(done, done);
|
if (tab === "sort") return renderSort().then(done, done);
|
||||||
if (tab === "sources") return renderSources().then(done, done);
|
if (tab === "sources") return renderSources().then(done, done);
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
109
internal/gateway/ui_image_wire_test.go
Normal file
109
internal/gateway/ui_image_wire_test.go
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestUI_ImageRequestSendsJSONBody pins the bug that made the image page
|
||||||
|
// unusable in production while every DOM assertion still passed.
|
||||||
|
//
|
||||||
|
// The page called api(path, {method, body: {...}}). api() is
|
||||||
|
// `(p, o) => fetch(p, o)` — o is the NATIVE fetch options object, so an object
|
||||||
|
// in `body` is coerced by fetch into the literal text "[object Object]". The
|
||||||
|
// gateway's json.Unmarshal rejected it, the response was a bare 400 with
|
||||||
|
// lat_ms 0, and the page showed an error toast with no image and no usable
|
||||||
|
// message.
|
||||||
|
//
|
||||||
|
// What made this expensive: the pane rendered, the model list had all six
|
||||||
|
// models, the button enabled itself, the console was clean. Only the WIRE
|
||||||
|
// format was wrong, and nothing in the DOM shows a wire format.
|
||||||
|
func TestUI_ImageRequestSendsJSONBody(t *testing.T) {
|
||||||
|
body := sendImageSource(t)
|
||||||
|
|
||||||
|
if !strings.Contains(body, "JSON.stringify") {
|
||||||
|
t.Error("sendImage() does not JSON.stringify its body — fetch coerces " +
|
||||||
|
"the object to the text \"[object Object]\" and the gateway answers " +
|
||||||
|
"400 with lat_ms 0")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `"Content-Type": "application/json"`) {
|
||||||
|
t.Error("sendImage() does not set Content-Type: application/json; the " +
|
||||||
|
"server sees text/plain and may refuse to parse the body")
|
||||||
|
}
|
||||||
|
// The literal defect, asserted so a later refactor reintroducing it fails
|
||||||
|
// here rather than in a browser.
|
||||||
|
if strings.Contains(body, "body: size ? {") || strings.Contains(body, "body: { model") {
|
||||||
|
t.Error("sendImage() still passes an object literal as the fetch body")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUI_PostCallsSendJSONString guards every POST in the file, not just the
|
||||||
|
// image one. The image page happened to be the only place that used api() with
|
||||||
|
// an object body, so a check scoped to it would pass while another page had the
|
||||||
|
// same defect.
|
||||||
|
func TestUI_PostCallsSendJSONString(t *testing.T) {
|
||||||
|
ui := uiSource(t)
|
||||||
|
lines := strings.Split(ui, "\n")
|
||||||
|
|
||||||
|
var bad []string
|
||||||
|
for i, l := range lines {
|
||||||
|
if !strings.Contains(l, `method: "POST"`) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
end := i + 8
|
||||||
|
if end > len(lines) {
|
||||||
|
end = len(lines)
|
||||||
|
}
|
||||||
|
window := strings.Join(lines[i:end], "\n")
|
||||||
|
if !strings.Contains(window, "body:") {
|
||||||
|
continue // no body at all: legitimate (a bare trigger)
|
||||||
|
}
|
||||||
|
if strings.Contains(window, "JSON.stringify") || strings.Contains(window, "FormData") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bad = append(bad, strings.TrimSpace(l))
|
||||||
|
}
|
||||||
|
if len(bad) > 0 {
|
||||||
|
t.Errorf("%d POST call(s) send a non-string body:", len(bad))
|
||||||
|
for _, b := range bad {
|
||||||
|
if len(b) > 100 {
|
||||||
|
b = b[:100]
|
||||||
|
}
|
||||||
|
t.Errorf(" %s", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUI_ImagePageReportsServerErrors pins that a failed generation surfaces
|
||||||
|
// something actionable. The production bug produced a bare "400 Bad Request"
|
||||||
|
// toast; the requirement is that the gateway's own message reaches the page.
|
||||||
|
func TestUI_ImagePageReportsServerErrors(t *testing.T) {
|
||||||
|
body := sendImageSource(t)
|
||||||
|
|
||||||
|
// api() throws Error(j.error.message) on !r.ok, so appending e.message is
|
||||||
|
// what puts the explanation on screen.
|
||||||
|
if !strings.Contains(body, "e && e.message") {
|
||||||
|
t.Error("sendImage() does not surface the server's error message; a " +
|
||||||
|
"failure would show a bare status with no cause")
|
||||||
|
}
|
||||||
|
// And a success carrying no image must not look like a success.
|
||||||
|
if !strings.Contains(body, "imgNoData") {
|
||||||
|
t.Error("sendImage() does not warn when the upstream returns no image")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendImageSource returns the text of sendImage() only, so an assertion cannot
|
||||||
|
// be satisfied by a string that happens to appear elsewhere in the file.
|
||||||
|
func sendImageSource(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
ui := uiSource(t)
|
||||||
|
i := strings.Index(ui, "async function sendImage(")
|
||||||
|
if i < 0 {
|
||||||
|
t.Fatal("sendImage() not found")
|
||||||
|
}
|
||||||
|
body := ui[i:]
|
||||||
|
if end := strings.Index(body[1:], "\n function "); end > 0 {
|
||||||
|
body = body[:end+1]
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
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
|
||||||
@ -281,3 +281,60 @@ func TestCloseFlushesTail(t *testing.T) {
|
|||||||
t.Errorf("flushed n = %v, want 1", saved.State["n"])
|
t.Errorf("flushed n = %v, want 1", saved.State["n"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestOnDemandCallIsPersisted guards the persistence gap that the quota memo fell
|
||||||
|
// into, and it needs a plugin with NO hooks to see it.
|
||||||
|
//
|
||||||
|
// markDirty fired from two places: hooks and SetState. `Call` — the on-demand path
|
||||||
|
// a plugin page uses — fired from neither, so a plugin that does all its work in
|
||||||
|
// exported functions (quota: it declares no hooks at all) mutated state that
|
||||||
|
// nothing ever wrote. The visible symptom was a health probe that looked inert
|
||||||
|
// from outside: the memo changed in memory and the file kept the previous
|
||||||
|
// generation until some unrelated configuration write happened to flush it.
|
||||||
|
//
|
||||||
|
// A hookless plugin is the whole point of the fixture: the counter above
|
||||||
|
// accumulates through a hook, which is the path that already persisted, so it
|
||||||
|
// cannot see this defect.
|
||||||
|
func TestOnDemandCallIsPersisted(t *testing.T) {
|
||||||
|
ps, _ := newPersistVM(t)
|
||||||
|
if err := ps.LoadSource("ondemand", `
|
||||||
|
local plugin = { name = "ondemand", version = "1.0" }
|
||||||
|
plugin.state = { n = 0 }
|
||||||
|
plugin.exports = { "bump" }
|
||||||
|
function plugin.bump(payload)
|
||||||
|
plugin.state.n = plugin.state.n + 1
|
||||||
|
return { n = plugin.state.n }
|
||||||
|
end
|
||||||
|
return plugin
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 1; i <= 3; i++ {
|
||||||
|
out, err := ps.Call("ondemand", "bump", map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("call %d: %v", i, err)
|
||||||
|
}
|
||||||
|
if m, _ := out.(map[string]interface{}); m["n"] != float64(i) {
|
||||||
|
t.Fatalf("call %d returned %v, want n=%d", i, out, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Close() waits for the final flush, so no timing is involved: anything the
|
||||||
|
// calls did NOT mark dirty cannot appear here.
|
||||||
|
ps.Close()
|
||||||
|
|
||||||
|
b, err := os.ReadFile(ps.stateFile("ondemand"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("★ an exported call did not mark the plugin dirty: %v — a plugin "+
|
||||||
|
"with no hooks can never persist its work", err)
|
||||||
|
}
|
||||||
|
var saved struct {
|
||||||
|
State map[string]interface{} `json:"state"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(b, &saved); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if saved.State["n"] != float64(3) {
|
||||||
|
t.Errorf("persisted n = %v after three exported calls, want 3", saved.State["n"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -152,6 +152,9 @@ func (sp *shardPool) acquire(code, name string) (*worker, func(), error) {
|
|||||||
w := sp.idle[n-1]
|
w := sp.idle[n-1]
|
||||||
sp.idle = sp.idle[:n-1]
|
sp.idle = sp.idle[:n-1]
|
||||||
sp.mu.Unlock()
|
sp.mu.Unlock()
|
||||||
|
// Re-seed on every checkout, not only on boot: the operator may have
|
||||||
|
// changed the configuration since this VM was pooled.
|
||||||
|
sp.ps.seedShardConfig(name, w)
|
||||||
return w, func() { sp.release(w) }, nil
|
return w, func() { sp.release(w) }, nil
|
||||||
}
|
}
|
||||||
if sp.limit > 0 && sp.live >= sp.limit {
|
if sp.limit > 0 && sp.live >= sp.limit {
|
||||||
@ -168,6 +171,9 @@ func (sp *shardPool) acquire(code, name string) (*worker, func(), error) {
|
|||||||
sp.mu.Unlock()
|
sp.mu.Unlock()
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
// A fresh shard has no state file and no plugin identity, so the shared
|
||||||
|
// plugin's configuration has to be pushed onto it (see seedShardConfig).
|
||||||
|
sp.ps.seedShardConfig(name, w)
|
||||||
return w, func() { sp.release(w) }, nil
|
return w, func() { sp.release(w) }, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
176
internal/lua/plugin_shard_config_test.go
Normal file
176
internal/lua/plugin_shard_config_test.go
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
package lua
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A shard VM is booted from the same plugin SOURCE but has no state file and no
|
||||||
|
// plugin identity, so anything a plugin reads from its `prices` table
|
||||||
|
// (configuration, as opposed to accumulated state) is empty unless the pool puts
|
||||||
|
// it there.
|
||||||
|
//
|
||||||
|
// That asymmetry is invisible until a plugin's behaviour depends on its
|
||||||
|
// configuration. The OpenDesign adapter is the first: it reads
|
||||||
|
// prices.control_key, and without seeding it concluded "not configured" for every
|
||||||
|
// host and reported a skip — while the identical call through the shared VM
|
||||||
|
// worked.
|
||||||
|
//
|
||||||
|
// It mattered because the two paths do different jobs: the page probes through
|
||||||
|
// the FANOUT (shard) path and records results through the shared one. A feature
|
||||||
|
// broken on the shard path is dead in production and green in every test that
|
||||||
|
// calls the shared VM.
|
||||||
|
|
||||||
|
const shardConfigProbe = `
|
||||||
|
local P = {}
|
||||||
|
P.name = "cfgprobe"
|
||||||
|
P.version = "1.0.0"
|
||||||
|
P.exports = { "read_config" }
|
||||||
|
P.read_config = function(payload)
|
||||||
|
local c = P.prices
|
||||||
|
return { key = (type(c) == "table") and c.control_key or nil }, nil
|
||||||
|
end
|
||||||
|
return P
|
||||||
|
`
|
||||||
|
|
||||||
|
func shardConfigPlugins(t *testing.T) *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)
|
||||||
|
if err := os.WriteFile(filepath.Join(pluginDir, "cfgprobe.lua"),
|
||||||
|
[]byte(shardConfigProbe), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ps := NewPlugins(vm, pluginDir)
|
||||||
|
t.Cleanup(ps.Close)
|
||||||
|
if err := ps.LoadDir(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return ps
|
||||||
|
}
|
||||||
|
|
||||||
|
func configKeyOf(t *testing.T, out interface{}) string {
|
||||||
|
t.Helper()
|
||||||
|
m, ok := out.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("result is %T (%v), want a table", out, out)
|
||||||
|
}
|
||||||
|
s, _ := m["key"].(string)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShardSeesPluginConfiguration(t *testing.T) {
|
||||||
|
ps := shardConfigPlugins(t)
|
||||||
|
if err := ps.SetState("cfgprobe", map[string]interface{}{
|
||||||
|
"prices": map[string]interface{}{"control_key": "secret-value"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The shared path is the control: every pre-existing test exercises it, and
|
||||||
|
// it passes whether or not the pool seeds anything.
|
||||||
|
out, err := ps.Call("cfgprobe", "read_config", map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("shared call: %v", err)
|
||||||
|
}
|
||||||
|
if got := configKeyOf(t, out); got != "secret-value" {
|
||||||
|
t.Fatalf("shared VM cannot read prices.control_key (%q) — the test itself "+
|
||||||
|
"is wrong, not the shard path", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The shard path is the one the page uses.
|
||||||
|
outs, err := ps.ShardCall("cfgprobe", "read_config",
|
||||||
|
[]map[string]interface{}{{}, {}, {}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ShardCall: %v", err)
|
||||||
|
}
|
||||||
|
for i, o := range outs {
|
||||||
|
if got := configKeyOf(t, o); got != "secret-value" {
|
||||||
|
t.Fatalf("shard %d read prices.control_key as %q, want the configured "+
|
||||||
|
"value — a plugin that branches on its configuration behaves "+
|
||||||
|
"differently on the path the page actually uses", i, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pooled VM is reused, so the seeding has to happen on every checkout rather
|
||||||
|
// than only on boot, or a configuration change would be invisible to the pool
|
||||||
|
// until the process restarted.
|
||||||
|
func TestShardConfigFollowsAConfigurationChange(t *testing.T) {
|
||||||
|
ps := shardConfigPlugins(t)
|
||||||
|
if err := ps.SetState("cfgprobe", map[string]interface{}{
|
||||||
|
"prices": map[string]interface{}{"control_key": "first"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := ps.ShardCall("cfgprobe", "read_config", []map[string]interface{}{{}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := ps.SetState("cfgprobe", map[string]interface{}{
|
||||||
|
"prices": map[string]interface{}{"control_key": "second"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
outs, err := ps.ShardCall("cfgprobe", "read_config",
|
||||||
|
[]map[string]interface{}{{}, {}, {}, {}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i, o := range outs {
|
||||||
|
if got := configKeyOf(t, o); got != "second" {
|
||||||
|
t.Fatalf("shard %d still reads %q after the configuration changed to "+
|
||||||
|
"%q — pooled VMs keep a stale configuration", i, got, "second")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reading must not disturb the shared value. That is what makes it acceptable to
|
||||||
|
// seed configuration into a VM whose state is otherwise throwaway, and it is why
|
||||||
|
// the seeding is a one-way copy rather than a reference to the shared table.
|
||||||
|
func TestShardConfigSeedingDoesNotAliasSharedState(t *testing.T) {
|
||||||
|
ps := shardConfigPlugins(t)
|
||||||
|
if err := ps.SetState("cfgprobe", map[string]interface{}{
|
||||||
|
"prices": map[string]interface{}{"control_key": "keep-me"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
outs, err := ps.ShardCall("cfgprobe", "read_config", []map[string]interface{}{{}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := configKeyOf(t, outs[0]); got != "keep-me" {
|
||||||
|
t.Fatalf("shard read %q", got)
|
||||||
|
}
|
||||||
|
out, err := ps.Call("cfgprobe", "read_config", map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := configKeyOf(t, out); got != "keep-me" {
|
||||||
|
t.Fatalf("the shared configuration changed to %q after a shard read", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQuotaProbesThroughTheFanoutPath is the reason the above matters. If the
|
||||||
|
// quota page stopped probing through fanout, seeding shards would be dead code
|
||||||
|
// and the asymmetry could return unnoticed.
|
||||||
|
func TestQuotaProbesThroughTheFanoutPath(t *testing.T) {
|
||||||
|
src := readShippedPlugin(t, "quota.lua")
|
||||||
|
if !strings.Contains(src, "pluginAPI.fanout(") {
|
||||||
|
t.Fatal("quota.lua no longer probes through the fanout (shard) path — the " +
|
||||||
|
"page and the tests would then exercise different code, which is how " +
|
||||||
|
"the missing shard configuration stayed hidden")
|
||||||
|
}
|
||||||
|
if !strings.Contains(src, "memo_record") {
|
||||||
|
t.Fatal("quota.lua no longer records through memo_record")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1172,6 +1172,54 @@ func audienceAllows(want, role string) bool {
|
|||||||
// upgrade would overwrite anyway.
|
// upgrade would overwrite anyway.
|
||||||
type visibilityOverrides map[string]string
|
type visibilityOverrides map[string]string
|
||||||
|
|
||||||
|
// seedShardConfig copies the shared plugin's `prices` table onto a shard VM.
|
||||||
|
//
|
||||||
|
// `prices` is CONFIGURATION, as opposed to `state` (accumulated history). A shard
|
||||||
|
// must not write state — its copy is thrown away — but it must be able to READ
|
||||||
|
// the operator's configuration, or a plugin that branches on its configuration
|
||||||
|
// behaves one way through Plugins.Call and another through ShardCall.
|
||||||
|
//
|
||||||
|
// Found by the OpenDesign adapter, which reads prices.control_key: without this, a
|
||||||
|
// fanout probe saw an empty table, concluded "not configured", and reported a
|
||||||
|
// skip for every host — while the identical call through the shared VM worked.
|
||||||
|
// The page uses the fanout path, so the feature would have been dead in
|
||||||
|
// production and alive in every test that called the shared VM.
|
||||||
|
//
|
||||||
|
// Called on every checkout (boot and reuse), so a configuration change reaches
|
||||||
|
// pooled VMs too. The push is a copy: shards get the same values, never a live
|
||||||
|
// reference to the shared table.
|
||||||
|
func (ps *Plugins) seedShardConfig(name string, w *worker) {
|
||||||
|
if ps == nil || w == nil || w.L == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ps.mu.RLock()
|
||||||
|
p := ps.find(name)
|
||||||
|
ps.mu.RUnlock()
|
||||||
|
if p == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
if p.state == nil || p.state.L == nil || p.state.L == w.L {
|
||||||
|
p.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prices := snapshotField(p.state.L, "prices")
|
||||||
|
p.mu.Unlock()
|
||||||
|
if prices == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
L := w.L
|
||||||
|
L.SetTop(0)
|
||||||
|
defer L.SetTop(0)
|
||||||
|
L.GetGlobal(pluginGlobal)
|
||||||
|
if L.IsNil(-1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
plug := L.GetTop()
|
||||||
|
pushGoValue(L, prices)
|
||||||
|
L.SetField(plug, "prices")
|
||||||
|
}
|
||||||
|
|
||||||
// SetPluginVisibility records an operator override for a plugin's audience. An
|
// SetPluginVisibility records an operator override for a plugin's audience. An
|
||||||
// empty value clears the override and restores the plugin's own default.
|
// empty value clears the override and restores the plugin's own default.
|
||||||
func (ps *Plugins) SetPluginVisibility(plugin, audience string) {
|
func (ps *Plugins) SetPluginVisibility(plugin, audience string) {
|
||||||
@ -1993,7 +2041,19 @@ func (ps *Plugins) Call(name, fn string, args map[string]interface{}) (interface
|
|||||||
if w == nil {
|
if w == nil {
|
||||||
return nil, fmt.Errorf("no state for plugin %s", name)
|
return nil, fmt.Errorf("no state for plugin %s", name)
|
||||||
}
|
}
|
||||||
return callOn(w, fn, args)
|
out, err := callOn(w, fn, args)
|
||||||
|
// An on-demand call mutates state just as a hook does — quota writes its
|
||||||
|
// host→adapter memo here, and that is the whole point of the function — but
|
||||||
|
// markDirty used to fire only from hooks and SetState. quota declares no
|
||||||
|
// hooks, so every probe it ran through this path lived in memory and the memo
|
||||||
|
// file was only rewritten when something ELSE dirtied the plugin. The
|
||||||
|
// staleness is what made the memo look inert from the outside: a negative
|
||||||
|
// entry appeared only after the next configuration change happened to flush.
|
||||||
|
//
|
||||||
|
// Marking here is the same call the hook path already makes, so the cost is the
|
||||||
|
// same (the saver snapshots on its own schedule, not per call).
|
||||||
|
ps.markDirtyLocked(p)
|
||||||
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// callOn runs one exported function on a specific VM.
|
// callOn runs one exported function on a specific VM.
|
||||||
|
|||||||
@ -43,7 +43,8 @@ PLUGIN.name = "quota"
|
|||||||
PLUGIN.version = "1.0.0"
|
PLUGIN.version = "1.0.0"
|
||||||
PLUGIN.description = "多源套餐余量(余额 / 用量 / 滚动窗口)"
|
PLUGIN.description = "多源套餐余量(余额 / 用量 / 滚动窗口)"
|
||||||
|
|
||||||
PLUGIN.exports = { "collect_source", "collect_all", "due_sources", "memo_record" }
|
PLUGIN.exports = { "collect_source", "collect_all", "due_sources", "memo_record",
|
||||||
|
"resolve_api_base" }
|
||||||
|
|
||||||
-- plugin.state must exist before anything indexes it. The kernel restores
|
-- 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
|
-- persisted state OVER this table, so declaring it here is safe: a restart with
|
||||||
@ -105,6 +106,17 @@ local function getnum(root, path)
|
|||||||
return n
|
return n
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- strang reads a string field, returning nil rather than "". An empty string
|
||||||
|
-- and an absent field must stay distinguishable: the UI prints the first as
|
||||||
|
-- blank and the second as "未探测", and collapsing them loses that.
|
||||||
|
local function strang(root, path)
|
||||||
|
local v = get(root, path)
|
||||||
|
if v == nil then return nil end
|
||||||
|
if type(v) ~= "string" then return nil end
|
||||||
|
if v == "" then return nil end
|
||||||
|
return v
|
||||||
|
end
|
||||||
|
|
||||||
-- request performs one GET with the standard retry. Retrying is not
|
-- request performs one GET with the standard retry. Retrying is not
|
||||||
-- politeness here: several of these hosts sit behind DNS that intermittently
|
-- politeness here: several of these hosts sit behind DNS that intermittently
|
||||||
-- refuses, and a single 000 turned into "provider unknown" on every poll.
|
-- refuses, and a single 000 turned into "provider unknown" on every poll.
|
||||||
@ -220,6 +232,247 @@ local function openaiDashboard(base, key)
|
|||||||
return rep, nil
|
return rep, nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- copySet returns a shallow copy of a set-table, or nil when it is empty.
|
||||||
|
--
|
||||||
|
-- The copy matters: a memo entry outlives the call that wrote it, and storing
|
||||||
|
-- the same table would let a later write mutate a record already handed out.
|
||||||
|
-- nil for an empty set keeps "nothing was unready" out of the persisted state,
|
||||||
|
-- so the read side can use plain truthiness on the field.
|
||||||
|
local function copySet(t)
|
||||||
|
local out = nil
|
||||||
|
for k, v in pairs(t) do
|
||||||
|
out = out or {}
|
||||||
|
out[k] = v
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- provisionalEntry reports whether a negative memo was written while some adapter
|
||||||
|
-- could not judge the host. Such an entry is retired the moment that adapter
|
||||||
|
-- becomes configured, and never backs off while it is still waiting.
|
||||||
|
--
|
||||||
|
-- An EMPTY `unready` is not provisional, and the emptiness has to be tested rather
|
||||||
|
-- than the presence. Two ways an empty set shows up: the write path drops empty
|
||||||
|
-- tables (so a host every adapter judged has no field at all), but a full JSON
|
||||||
|
-- round trip — restore, or one page open — turns an empty table back into a
|
||||||
|
-- truthy `{}`. Reading presence as the signal therefore makes exactly the hosts
|
||||||
|
-- that most need backing off the ones that never get it.
|
||||||
|
local function provisionalEntry(entry)
|
||||||
|
if type(entry) ~= "table" then return false end
|
||||||
|
if type(entry.unready) ~= "table" then return false end
|
||||||
|
return next(entry.unready) ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- vendorReady reports whether an adapter has the configuration it needs to answer
|
||||||
|
-- at all.
|
||||||
|
--
|
||||||
|
-- OpenDesign is the first adapter that needs a credential the SOURCE does not
|
||||||
|
-- carry (prices.control_key). When it is missing the adapter should not run: with
|
||||||
|
-- no credential the only possible outcomes are a 401 (filed as an ERROR, "key
|
||||||
|
-- rejected") or a fabricated skip, and neither is a fact about the host. What it
|
||||||
|
-- would actually be reporting is that the deployment is not configured yet.
|
||||||
|
--
|
||||||
|
-- Per-VENDOR rather than one global flag, so a future adapter that needs its own
|
||||||
|
-- configuration gets the same treatment by adding a branch here.
|
||||||
|
local function vendorReady(vendor)
|
||||||
|
if vendor == "opendesign" then
|
||||||
|
local c = PLUGIN.prices
|
||||||
|
if type(c) ~= "table" or type(c.control_key) ~= "string" or c.control_key == "" then
|
||||||
|
-- The reason travels with the refusal: "adapter not configured" alone says
|
||||||
|
-- what is wrong but not what to type.
|
||||||
|
return false, "control_key not configured (set prices.control_key)"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- Every other adapter carries its credential on the source itself.
|
||||||
|
return true, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- resolveApiBase turns a configured source base_url into the OpenDesign billing
|
||||||
|
-- origin.
|
||||||
|
--
|
||||||
|
-- Split out as its own function because it is the one piece of this adapter that
|
||||||
|
-- is pure logic, and it is load-bearing while being silent when it breaks: the
|
||||||
|
-- probe simply goes to the inference host, which serves no billing route, and the
|
||||||
|
-- source quietly stays unlisted.
|
||||||
|
--
|
||||||
|
-- The host is taken as a HOST, not as the tail of a URL. base_url is normally
|
||||||
|
-- `https://amr-link.open-design.ai/v1`, and matching a `^...$` pattern against
|
||||||
|
-- that whole string never succeeds — the rewrite then silently does not happen.
|
||||||
|
-- That was a real defect here, found by asserting behaviour rather than source
|
||||||
|
-- text.
|
||||||
|
local function resolveApiBase(base, override)
|
||||||
|
if type(override) == "string" and override ~= "" then
|
||||||
|
return (override:gsub("/+$", ""))
|
||||||
|
end
|
||||||
|
local origin = base:gsub("^https?://", ""):gsub("/.*$", "")
|
||||||
|
origin = origin:gsub("^amr%-link%.open%-design%.ai$", "amr-api.open-design.ai")
|
||||||
|
return "https://" .. origin .. "/api/v1"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- resolve_api_base exposes resolveApiBase for tests, so the derivation can be
|
||||||
|
-- asserted directly instead of inferred from which error a failed probe happened
|
||||||
|
-- to report. Declared AFTER the local function it calls — a Lua local is nil until
|
||||||
|
-- its assignment runs, and this closure would otherwise capture nil. Pure: no
|
||||||
|
-- network, no state.
|
||||||
|
PLUGIN.resolve_api_base = function(payload)
|
||||||
|
if type(payload) ~= "table" then return nil, "expected table" end
|
||||||
|
local base = tostring(payload.base_url or "")
|
||||||
|
if base == "" then return nil, "missing base_url" end
|
||||||
|
local override = payload.api_base
|
||||||
|
if type(override) ~= "string" then override = nil end
|
||||||
|
return { api_base = resolveApiBase(base, override) }, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- openDesign probes OpenDesign / Vela (amr-link.open-design.ai).
|
||||||
|
--
|
||||||
|
-- This vendor needs a credential the source does not carry. Inference runs on
|
||||||
|
-- `runtimeKey`, but the billing endpoints on amr-api.open-design.ai only accept
|
||||||
|
-- `controlKey` — a second, separate secret handed out when the desktop app signs
|
||||||
|
-- in and stored in ~/.amr/config.json under profiles.prod.
|
||||||
|
--
|
||||||
|
-- Verified against the live service (200 on both, ~1.3s each):
|
||||||
|
-- GET https://amr-api.open-design.ai/api/v1/wallet/balance (controlKey)
|
||||||
|
-- {balanceUsd, expiresAt, workspaceId, ...}
|
||||||
|
-- GET https://amr-api.open-design.ai/api/v1/billing/summary (controlKey)
|
||||||
|
-- {creditsPerUsd, membershipTier, subscriptionStatus,
|
||||||
|
-- subscriptionCreditsUsd, rechargeCreditsUsd,
|
||||||
|
-- todayConsumedUsd, totalConsumedUsd, subscriptionCurrentPeriodEnd, ...}
|
||||||
|
--
|
||||||
|
-- The two keys are NOT interchangeable: sending runtimeKey to either endpoint
|
||||||
|
-- answers 401 {"error":"unauthenticated"}. That is why this adapter reads
|
||||||
|
-- PLUGIN.prices instead of reusing the source's api_key.
|
||||||
|
--
|
||||||
|
-- config location: `prices.control_key` in the plugin's persisted config (the
|
||||||
|
-- same file billing uses for its price table — the plugin system persists one
|
||||||
|
-- `prices` table per plugin, separate from `state`). Set it through the admin
|
||||||
|
-- API:
|
||||||
|
-- PUT /api/plugins/quota/state {"prices": {"control_key": "vela_ctrl_..."}}
|
||||||
|
-- `prices.api_base` is optional and overrides the amr-link -> amr-api host
|
||||||
|
-- derivation.
|
||||||
|
--
|
||||||
|
-- Three things this adapter deliberately does NOT do:
|
||||||
|
--
|
||||||
|
-- * It does not invent an exchange rate. creditsPerUsd is the only thing that
|
||||||
|
-- turns credits into dollars, and a missing or non-positive value is an
|
||||||
|
-- error, not a default. The vendor serves pre-computed *Usd fields too, so
|
||||||
|
-- the rate is only needed for the credit breakdown.
|
||||||
|
-- * It does not report a period window when the account has no Coding Plan.
|
||||||
|
-- `vela billing workspace-snapshot` returns billingState:"free" and NO
|
||||||
|
-- codingPlan.windows for such workspaces, and rendering "100% remaining"
|
||||||
|
-- from an absent field is the exact lie this plugin was written to avoid.
|
||||||
|
-- * It does not treat a zero balance as an exhausted plan. balanceUsd and the
|
||||||
|
-- period windows are different things; conflating them was a previous bug.
|
||||||
|
local function openDesign(base, key)
|
||||||
|
-- The source's own api_key is the RUNTIME key, which these endpoints reject.
|
||||||
|
-- The control key comes from the plugin's persisted config.
|
||||||
|
local cfg = PLUGIN.prices
|
||||||
|
local ck = nil
|
||||||
|
if type(cfg) == "table" then ck = cfg.control_key end
|
||||||
|
if type(ck) ~= "string" or ck == "" then
|
||||||
|
return nil, "skip", "control_key not configured (set prices.control_key)"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- amr-link serves inference; amr-api serves billing. Rewriting the host here
|
||||||
|
-- rather than asking the operator to configure a second source keeps one
|
||||||
|
-- logical provider in the UI.
|
||||||
|
--
|
||||||
|
-- `api_base` overrides the derivation: the vendor has already moved these
|
||||||
|
-- hosts once, and an operator who can see the new one in a browser should not
|
||||||
|
-- be blocked on a code change to use it.
|
||||||
|
local api = resolveApiBase(base, type(cfg) == "table" and cfg.api_base or nil)
|
||||||
|
|
||||||
|
-- The gateway's outbound allowlist is the hosts of configured sources. It
|
||||||
|
-- therefore contains amr-link (the inference host) but NOT amr-api (the
|
||||||
|
-- billing host), and every request below would be refused with "host ... is
|
||||||
|
-- not an allowed outbound destination" — a message that reads like a plugin
|
||||||
|
-- bug rather than a missing allowlist entry. Add the derived host alongside
|
||||||
|
-- the source hosts in core.sourceHosts when this adapter is enabled.
|
||||||
|
|
||||||
|
local wallet, wErr = request(api .. "/wallet/balance", ck, nil, 1)
|
||||||
|
local summary, sErr = request(api .. "/billing/summary", ck, nil, 1)
|
||||||
|
|
||||||
|
if wallet == nil and summary == nil then
|
||||||
|
if (wErr or ""):match("key rejected") or (sErr or ""):match("key rejected") then
|
||||||
|
return nil, "error", "control_key rejected (HTTP 401)"
|
||||||
|
end
|
||||||
|
-- A 404 here means the host is not amr-link/amr-api at all; skip rather
|
||||||
|
-- than error so the memo does not blacklist an unrelated provider.
|
||||||
|
if (wErr or ""):match("HTTP 404") and (sErr or ""):match("HTTP 404") then
|
||||||
|
return nil, "skip", "no OpenDesign billing endpoints"
|
||||||
|
end
|
||||||
|
return nil, "skip", (wErr or sErr or "no billing response")
|
||||||
|
end
|
||||||
|
|
||||||
|
local w = wallet or {}
|
||||||
|
local b = summary or {}
|
||||||
|
|
||||||
|
local rep = {
|
||||||
|
vendor = "opendesign",
|
||||||
|
-- tonum would turn a missing balance into 0, i.e. "you are broke", which is
|
||||||
|
-- the failure mode this whole plugin exists to avoid. A balance that is
|
||||||
|
-- absent stays absent and the guard below turns it into a skip.
|
||||||
|
balance = getnum(w, "balanceUsd") or getnum(b, "balanceUsd"),
|
||||||
|
balance_usd = getnum(w, "balanceUsd") or getnum(b, "balanceUsd"),
|
||||||
|
tier = strang(b, "membershipTier"),
|
||||||
|
status = strang(b, "subscriptionStatus"),
|
||||||
|
interval = strang(b, "billingInterval"),
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Credits breakdown. Converted with the vendor's OWN rate rather than
|
||||||
|
-- assuming 10000 — that is this vendor's current value and hardcoding it
|
||||||
|
-- would silently produce wrong numbers the day it changes.
|
||||||
|
local rate = getnum(b, "creditsPerUsd")
|
||||||
|
if rate and rate > 0 then
|
||||||
|
rep.credits_per_usd = rate
|
||||||
|
local sub = getnum(b, "subscriptionCredits")
|
||||||
|
local rec = getnum(b, "rechargeCredits")
|
||||||
|
if sub or rec then
|
||||||
|
rep.credits = (sub or 0) + (rec or 0)
|
||||||
|
rep.subscription_usd = sub and (sub / rate) or nil
|
||||||
|
rep.recharge_usd = rec and (rec / rate) or nil
|
||||||
|
end
|
||||||
|
rep.total_available_credits = getnum(b, "totalAvailableCredits")
|
||||||
|
else
|
||||||
|
-- No usable rate. Flag it instead of guessing; the *Usd fields below still
|
||||||
|
-- give a correct balance, so the card renders with the split missing.
|
||||||
|
rep.credits_rate_missing = true
|
||||||
|
end
|
||||||
|
-- The vendor's own pre-computed dollar amounts win over our conversion.
|
||||||
|
local sUsd = getnum(b, "subscriptionCreditsUsd")
|
||||||
|
local rUsd = getnum(b, "rechargeCreditsUsd")
|
||||||
|
if sUsd then rep.subscription_usd = sUsd end
|
||||||
|
if rUsd then rep.recharge_usd = rUsd end
|
||||||
|
|
||||||
|
-- Spend, shown next to the balance.
|
||||||
|
rep.today_used = getnum(b, "todayConsumedUsd")
|
||||||
|
rep.period_used = getnum(b, "totalConsumedUsd")
|
||||||
|
rep.total_recharged = getnum(b, "totalRechargedUsd")
|
||||||
|
rep.usage_count = getnum(b, "usageCount")
|
||||||
|
|
||||||
|
-- Wallet expiry: this balance does not last forever. Surfaced because a
|
||||||
|
-- balance that silently stops being spendable is worse than a warning.
|
||||||
|
rep.balance_expires_at = strang(w, "expiresAt")
|
||||||
|
rep.period_start_at = strang(b, "subscriptionCurrentPeriodStart")
|
||||||
|
rep.period_end_at = strang(b, "subscriptionCurrentPeriodEnd")
|
||||||
|
rep.workspace_id = strang(w, "workspaceId")
|
||||||
|
|
||||||
|
-- Period windows: deliberately NOT set. codingPlan.windows only exists for a
|
||||||
|
-- workspace on a Coding Plan, and this one reports billingState:"free".
|
||||||
|
-- Leaving rep.windows nil makes the page say "no period data" rather than
|
||||||
|
-- fabricating a 100% that an operator would read as "plenty left".
|
||||||
|
--
|
||||||
|
-- When a workspace does have a plan the windows arrive as
|
||||||
|
-- {durationSeconds, usedCredits, limitCredits, remainingCredits, resetsAt}
|
||||||
|
-- and need the same credits->USD conversion; that branch belongs here, gated
|
||||||
|
-- on the response actually carrying codingPlan.
|
||||||
|
|
||||||
|
if rep.balance == nil then
|
||||||
|
return nil, "skip", "no balanceUsd in response"
|
||||||
|
end
|
||||||
|
return rep, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
-- commandCode keeps its own shape: rolling windows plus a billing period.
|
-- commandCode keeps its own shape: rolling windows plus a billing period.
|
||||||
local function commandCode(base, key)
|
local function commandCode(base, key)
|
||||||
local H = {
|
local H = {
|
||||||
@ -293,6 +546,7 @@ local ADAPTERS = {
|
|||||||
{ vendor = "commandcode", probe = commandCode },
|
{ vendor = "commandcode", probe = commandCode },
|
||||||
{ vendor = "deepseek", probe = deepseekBalance },
|
{ vendor = "deepseek", probe = deepseekBalance },
|
||||||
{ vendor = "openai-dash", probe = openaiDashboard },
|
{ vendor = "openai-dash", probe = openaiDashboard },
|
||||||
|
{ vendor = "opendesign", probe = openDesign },
|
||||||
}
|
}
|
||||||
|
|
||||||
-- The probe memo remembers, per host, which adapter answered. Persisted in the
|
-- The probe memo remembers, per host, which adapter answered. Persisted in the
|
||||||
@ -375,6 +629,24 @@ local function collectSource(src, budget)
|
|||||||
base = base:gsub("/+$", "")
|
base = base:gsub("/+$", "")
|
||||||
local key = tostring(src.api_key or "")
|
local key = tostring(src.api_key or "")
|
||||||
local rep, kind, why
|
local rep, kind, why
|
||||||
|
-- Vendors that could not judge the host because their OWN configuration is
|
||||||
|
-- missing. Stamped onto the negative memo so it can be retired the moment that
|
||||||
|
-- configuration arrives.
|
||||||
|
local unready = {}
|
||||||
|
-- A REJECTED credential outranks every other error, and the reason is the
|
||||||
|
-- only thing this variable is for.
|
||||||
|
--
|
||||||
|
-- Why the rule is not simply "last wins": the four adapters answer different
|
||||||
|
-- questions, and a general-purpose one (openDesign, which walks the vendor's
|
||||||
|
-- whole host list) can report "key rejected" while an adapter that merely could
|
||||||
|
-- not reach ITS host reports a transport error. The two need opposite
|
||||||
|
-- responses — re-issue the credential vs. investigate the network — and with a
|
||||||
|
-- plain "last wins" the credential problem is reported as a network one.
|
||||||
|
--
|
||||||
|
-- Conversely, "first error wins" is just as arbitrary in the other direction:
|
||||||
|
-- an adapter whose host is unreachable from this deployment fails before the
|
||||||
|
-- one that actually knows what is wrong.
|
||||||
|
local errWhy, errRejected = nil, false
|
||||||
-- Try the adapter this host answered with last time first. Same host with a
|
-- Try the adapter this host answered with last time first. Same host with a
|
||||||
-- different key (two Command Code seats) gives the same answers, so this turns
|
-- different key (two Command Code seats) gives the same answers, so this turns
|
||||||
-- 21 sources into a handful of distinct round trips after the first pass.
|
-- 21 sources into a handful of distinct round trips after the first pass.
|
||||||
@ -386,17 +658,59 @@ local function collectSource(src, budget)
|
|||||||
-- exactly the confusion that made an earlier revision drop five sources.
|
-- exactly the confusion that made an earlier revision drop five sources.
|
||||||
if type(known) == "table" then
|
if type(known) == "table" then
|
||||||
local seen = known.at or 0
|
local seen = known.at or 0
|
||||||
-- A streak is a host that has failed repeatedly. Skip it outright until its
|
-- A memo written while an adapter could not judge the host is PROVISIONAL.
|
||||||
-- backoff expires, instead of re-paying three adapter timeouts every TTL.
|
-- It is discarded as soon as that adapter becomes configured, and it never
|
||||||
if backoffSeconds(seen, known.streak) then
|
-- earns a backoff while it is still waiting.
|
||||||
return { name = src.name, host = host, skipped = true, backing_off = true,
|
--
|
||||||
reason = "连续探测失败 " .. tostring(known.streak) .. " 次,退避中(等待下次重试)" }
|
-- Why it must not back off: the memo is applied unconditionally, before any
|
||||||
|
-- adapter runs. Honouring a provisional entry refuses the very probe that is
|
||||||
|
-- now able to succeed, so the operator supplies the missing credential and
|
||||||
|
-- sees nothing change. Measured end to end before this: unset key → memo →
|
||||||
|
-- key set → probe skipped without a single request.
|
||||||
|
--
|
||||||
|
-- The comparison is against the vendors RECORDED in the entry, not against
|
||||||
|
-- all adapters. "Is any adapter configured" is true almost always — three of
|
||||||
|
-- the four carry their credential on the source — and would retire every
|
||||||
|
-- provisional entry immediately, including the ones still waiting.
|
||||||
|
local provisional = provisionalEntry(known)
|
||||||
|
local stale = false
|
||||||
|
if provisional then
|
||||||
|
for vendor in pairs(known.unready) do
|
||||||
|
if vendorReady(vendor) then stale = true end
|
||||||
end
|
end
|
||||||
if PROBE_MEMO_TTL > 0 and (os.time() - seen) > PROBE_MEMO_TTL then
|
|
||||||
known = nil
|
|
||||||
else
|
|
||||||
known = false -- still "nothing answered, try cheapest first"
|
|
||||||
end
|
end
|
||||||
|
if provisional then
|
||||||
|
if stale then
|
||||||
|
-- The missing configuration is here: derive the verdict again.
|
||||||
|
known = nil
|
||||||
|
else
|
||||||
|
-- Still waiting for it. Do NOT back off: the memo is applied before any
|
||||||
|
-- adapter runs, so honouring it would refuse the very probe that will
|
||||||
|
-- succeed once the credential arrives.
|
||||||
|
known = false
|
||||||
|
end
|
||||||
|
else
|
||||||
|
-- A streak is a host that has failed repeatedly, and the backoff is keyed on
|
||||||
|
-- the RECORDED STREAK, not on this entry existing.
|
||||||
|
--
|
||||||
|
-- Keying it on existence reads like a tautology that can never fire, but it
|
||||||
|
-- fires constantly, because of WHEN the write side runs: the entry is read at
|
||||||
|
-- the top of this function and only written at the very bottom, so mid-call
|
||||||
|
-- the table still holds the two adapters that already answered on THIS call
|
||||||
|
-- — typically the two with the shortest timeouts. LIVE DEFECT: the plugin
|
||||||
|
-- then reported "连续探测失败 1 次" for a streak that was never counted and
|
||||||
|
-- refused every later probe, so an operator who supplied the missing key saw
|
||||||
|
-- nothing change, indefinitely.
|
||||||
|
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
|
||||||
|
known = false -- still "nothing answered, try cheapest first"
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
-- known == false means every adapter already came back "no endpoint here" for
|
-- known == false means every adapter already came back "no endpoint here" for
|
||||||
-- this host during this process. Skip the probing entirely; one cheap request
|
-- this host during this process. Skip the probing entirely; one cheap request
|
||||||
@ -440,13 +754,32 @@ local function collectSource(src, budget)
|
|||||||
return { name = src.name, host = host, skipped = true,
|
return { name = src.name, host = host, skipped = true,
|
||||||
reason = "probe budget exhausted" }
|
reason = "probe budget exhausted" }
|
||||||
end
|
end
|
||||||
local r, k, e = a.probe(base, key)
|
-- An adapter that cannot answer for want of its OWN configuration is not
|
||||||
if r ~= nil then
|
-- probed at all. Running it would produce a 401 (reported as ERROR "key
|
||||||
rep = r
|
-- rejected") or a fabricated skip, and would send the operator to re-issue a
|
||||||
HOST_MEMO[host] = a.vendor
|
-- credential they have not configured yet.
|
||||||
break
|
if not vendorReady(a.vendor) then
|
||||||
|
local _, because = vendorReady(a.vendor)
|
||||||
|
unready[a.vendor] = true
|
||||||
|
kind = "skip"
|
||||||
|
why = because or ("adapter not configured: " .. a.vendor)
|
||||||
|
else
|
||||||
|
local r, k, e = a.probe(base, key)
|
||||||
|
if r ~= nil then
|
||||||
|
rep = r
|
||||||
|
HOST_MEMO[host] = a.vendor
|
||||||
|
break
|
||||||
|
end
|
||||||
|
kind, why = k, e
|
||||||
|
if k == "error" then
|
||||||
|
local rejected = tostring(e or ""):match("rejected") ~= nil
|
||||||
|
-- Last error wins, except that a rejected credential displaces anything
|
||||||
|
-- already recorded and is never displaced in turn.
|
||||||
|
if not errRejected and (errWhy == nil or rejected) then
|
||||||
|
errWhy, errRejected = e, rejected
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
kind, why = k, e
|
|
||||||
end
|
end
|
||||||
-- Remember the NEGATIVE result too. Only recording successes left every
|
-- Remember the NEGATIVE result too. Only recording successes left every
|
||||||
-- source that has no balance endpoint re-probing all three adapters (2
|
-- source that has no balance endpoint re-probing all three adapters (2
|
||||||
@ -471,21 +804,70 @@ local function collectSource(src, budget)
|
|||||||
--
|
--
|
||||||
-- A success overwrites the memo with a vendor string, so a host that recovers
|
-- A success overwrites the memo with a vendor string, so a host that recovers
|
||||||
-- resets its streak automatically.
|
-- resets its streak automatically.
|
||||||
|
--
|
||||||
|
-- The entry records WHICH vendors could not judge the host. Such a verdict is
|
||||||
|
-- provisional (see the read side): the memo is applied before every later
|
||||||
|
-- probe, so keeping it unqualified suppresses the source even after the
|
||||||
|
-- credential is supplied.
|
||||||
|
--
|
||||||
|
-- It is still WRITTEN rather than omitted, because dropping it would put a
|
||||||
|
-- dashboard-less source back to 60s of adapter timeouts on every page open —
|
||||||
|
-- the cost the memo exists to remove. A memo cannot say "three of four
|
||||||
|
-- answered, the fourth is waiting for a key", so it says the next best thing.
|
||||||
|
--
|
||||||
|
-- Its streak also does not carry over from a previous provisional entry: that
|
||||||
|
-- count would be spent on a judgement nobody made.
|
||||||
|
--
|
||||||
|
-- `unready` is copied, and copySet returns nil for an EMPTY set — which is what
|
||||||
|
-- makes "every adapter judged the host" arrive as an absent field rather than as
|
||||||
|
-- an empty table. The read side tests for a NON-EMPTY set rather than for the
|
||||||
|
-- field's presence, so the two spellings of "nothing was unready" agree.
|
||||||
if rep == nil then
|
if rep == nil then
|
||||||
local prev = HOST_MEMO[host]
|
local prev = HOST_MEMO[host]
|
||||||
local streak = 1
|
local streak = 1
|
||||||
if type(prev) == "table" then streak = (prev.streak or 1) + 1 end
|
local prevUnready = provisionalEntry(prev)
|
||||||
HOST_MEMO[host] = { at = os.time(), streak = streak }
|
if type(prev) == "table" and not prevUnready then
|
||||||
|
streak = (prev.streak or 1) + 1
|
||||||
|
end
|
||||||
|
HOST_MEMO[host] = { at = os.time(), streak = streak, unready = copySet(unready) }
|
||||||
end
|
end
|
||||||
if rep ~= nil then
|
if rep ~= nil then
|
||||||
HOST_MEMO_AT[host] = os.time()
|
HOST_MEMO_AT[host] = os.time()
|
||||||
end
|
end
|
||||||
if rep == nil then
|
if rep == nil then
|
||||||
if kind == "error" then
|
-- An ERROR is reported when either:
|
||||||
return { name = src.name, vendor = "", error = tostring(why),
|
-- * the LAST adapter to judge the host reported one, or
|
||||||
|
-- * ANY adapter reported a REJECTED credential.
|
||||||
|
--
|
||||||
|
-- The second clause is not a refinement, it is the whole point. openDesign
|
||||||
|
-- walks the vendor's host list and is therefore the adapter most likely to
|
||||||
|
-- see "key rejected", but it also runs FIRST once it has been memoised as the
|
||||||
|
-- answering vendor — so a last-wins rule classifies its 401 by whatever the
|
||||||
|
-- next adapter says, and the next adapter (commandcode, pointed at the
|
||||||
|
-- source's own host) answers 404 "no dashboard here". Observed end to end:
|
||||||
|
-- a deliberately wrong control key was reported as "this host has no balance
|
||||||
|
-- endpoint", sending the operator to investigate the vendor instead of
|
||||||
|
-- re-issuing the credential. A rejection has one unambiguous remedy, so it
|
||||||
|
-- outranks position.
|
||||||
|
if kind == "error" or errRejected then
|
||||||
|
return { name = src.name, vendor = "", error = tostring(errWhy or why),
|
||||||
skipped = false, host = hostOf(base) }
|
skipped = false, host = hostOf(base) }
|
||||||
end
|
end
|
||||||
return { name = src.name, skipped = true, host = hostOf(base) }
|
-- The skip REASON travels. Discarding it here is why
|
||||||
|
-- TestQuotaOpenDesignSkipsWithoutControlKey could not tell a configured
|
||||||
|
-- adapter that refused from a source that never reached it: both arrived as
|
||||||
|
-- {skipped=true}, so a mutation that ignored the missing credential was
|
||||||
|
-- invisible at this boundary. "Not configured" is actionable (set the key);
|
||||||
|
-- "no balance endpoint" is not.
|
||||||
|
local out = { name = src.name, skipped = true, host = hostOf(base) }
|
||||||
|
if why and why ~= "" then out.reason = tostring(why) end
|
||||||
|
-- `unready` travels with the record because the page fans out on shard VMs
|
||||||
|
-- and reports back through memo_record, where a row with no vendor and no
|
||||||
|
-- error is otherwise indistinguishable from a real failure. Without it the
|
||||||
|
-- sharded path — the one the page actually uses — would still earn the source
|
||||||
|
-- a backoff it should never have had.
|
||||||
|
if next(unready) ~= nil then out.unready = true end
|
||||||
|
return out
|
||||||
end
|
end
|
||||||
rep.name = src.name
|
rep.name = src.name
|
||||||
rep.host = hostOf(base)
|
rep.host = hostOf(base)
|
||||||
@ -552,8 +934,24 @@ PLUGIN.memo_record = function(payload)
|
|||||||
else
|
else
|
||||||
local prev = HOST_MEMO[host]
|
local prev = HOST_MEMO[host]
|
||||||
local streak = 1
|
local streak = 1
|
||||||
if type(prev) == "table" then streak = (prev.streak or 1) + 1 end
|
-- A provisional entry is replaced, not aged: its count describes a
|
||||||
HOST_MEMO[host] = { at = now, streak = streak }
|
-- judgement nobody made. See the write side in collectSource.
|
||||||
|
if type(prev) == "table" and not prev.unready then
|
||||||
|
streak = (prev.streak or 1) + 1
|
||||||
|
end
|
||||||
|
-- The page can only send a boolean (it does not know which adapter was
|
||||||
|
-- unready), so the set is re-derived here on the shared VM. Copying it
|
||||||
|
-- matters: this table is stored, and a later write must not mutate it.
|
||||||
|
local u = nil
|
||||||
|
if r.unready then
|
||||||
|
for _, a in ipairs(ADAPTERS) do
|
||||||
|
if not vendorReady(a.vendor) then
|
||||||
|
u = u or {}
|
||||||
|
u[a.vendor] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
HOST_MEMO[host] = { at = now, streak = streak, unready = u }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -911,8 +1309,16 @@ PLUGIN.ui = {
|
|||||||
' 个源正在退避(连续探测失败,等下次重试):'+escH(backing.map(function(r){return r.name;}).join("、"))+'</div>';
|
' 个源正在退避(连续探测失败,等下次重试):'+escH(backing.map(function(r){return r.name;}).join("、"))+'</div>';
|
||||||
}
|
}
|
||||||
if(silent.length){
|
if(silent.length){
|
||||||
|
// Name the reason per source instead of assuming every silent skip
|
||||||
|
// means "no balance endpoint". A source that skipped because its
|
||||||
|
// credential is missing is listed the same way but needs a
|
||||||
|
// completely different fix, and an operator reading a flat list of
|
||||||
|
// names has no way to tell them apart.
|
||||||
|
var why = silent.map(function(r){
|
||||||
|
return r.reason ? (r.name+"("+r.reason+")") : r.name;
|
||||||
|
}).join("、");
|
||||||
host.innerHTML+='<div class="qa-skip">未列出 '+silent.length+
|
host.innerHTML+='<div class="qa-skip">未列出 '+silent.length+
|
||||||
' 个源(未暴露余额接口):'+escH(silent.map(function(r){return r.name;}).join("、"))+'</div>';
|
' 个源:'+escH(why)+'</div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -987,6 +1393,10 @@ PLUGIN.ui = {
|
|||||||
// flag rather than the wording, so translating the message cannot silently
|
// flag rather than the wording, so translating the message cannot silently
|
||||||
// turn every skipped host into an ever-growing streak.
|
// turn every skipped host into an ever-growing streak.
|
||||||
if(r.backing_off===true) return null;
|
if(r.backing_off===true) return null;
|
||||||
|
// A host probed while an adapter had no configuration was not judged.
|
||||||
|
// Recording it would earn a backoff that outlives the configuration
|
||||||
|
// mistake and suppresses the probe that would now succeed.
|
||||||
|
if(r.unready===true) return null;
|
||||||
if(r.error) return {host:r.host};
|
if(r.error) return {host:r.host};
|
||||||
if(r.skipped) return {host:r.host};
|
if(r.skipped) return {host:r.host};
|
||||||
return {host:r.host, vendor:r.vendor};
|
return {host:r.host, vendor:r.vendor};
|
||||||
|
|||||||
151
internal/lua/plugins/usage.lua
Normal file
151
internal/lua/plugins/usage.lua
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
-- usage:按调用者 key 汇报用量,供 OpenAI 兼容前端展示。
|
||||||
|
--
|
||||||
|
-- 为什么需要它
|
||||||
|
-- ------------
|
||||||
|
-- /v1/usage 不是网关的端点,但 OpenAI 兼容网关普遍提供它,第三方前端(如
|
||||||
|
-- AI Image Workbench)在启动时读它来显示「已用 X,Y 次」。缺了它,界面显示
|
||||||
|
-- 「后台未开放消费接口」——看起来像坏了,而不是像没配。
|
||||||
|
--
|
||||||
|
-- 为什么做成插件而不是网关路由
|
||||||
|
-- --------------------------------
|
||||||
|
-- 插件 HTTP 路由只能挂在 /api/plugins/http/ 之下,不能占 /v1/usage(这是刻意
|
||||||
|
-- 的:让插件无法遮蔽 /api/keys、/api/stats,便于整体撤销)。而「这是可插拔的
|
||||||
|
-- 便利功能、不是网关核心」正是它的性质。所以:
|
||||||
|
-- 插件注册 GET /usage
|
||||||
|
-- 部署侧的 nginx 把 /v1/usage 转发过来
|
||||||
|
-- 网关内核零改动,删掉插件就恢复原状。
|
||||||
|
--
|
||||||
|
-- 数据来源与归属
|
||||||
|
-- --------------
|
||||||
|
-- 取自己的 /api/stats —— 它已按调用 key 隔离(实测:user key 231 次 = 自己的,
|
||||||
|
-- admin 33847 次 = 全量)。
|
||||||
|
--
|
||||||
|
-- ★ 必须传**掩码**,不能传完整 key。
|
||||||
|
-- /api/stats 的统计 map 以 keyID(req.Key)(即 "***f9a9ed")为键 —— by_key
|
||||||
|
-- 返回的就是它,面板传的也是它。而 admin 身份下 ?key= 接受任意值:传完整 key
|
||||||
|
-- 会查不到,拿到零值。实测:
|
||||||
|
-- ?key=***f9a9ed → total.reqs=33847
|
||||||
|
-- ?key=sk-gw-7ffe…9ed → total.reqs=0
|
||||||
|
-- (这一版还暴露了网关一个 nil 解引用崩溃,已单独修掉,见提交 af977ef。)
|
||||||
|
--
|
||||||
|
-- 掩码的算法:末 6 位,前面替换为 ***;不足 6 位的 key 原样返回。与网关
|
||||||
|
-- internal/gateway/server.go 的 keyID() 一致。
|
||||||
|
--
|
||||||
|
-- total 为什么是 token 而不是金额
|
||||||
|
-- ------------------------------
|
||||||
|
-- 网关的 /api/stats 只有 token 与请求数,没有金额。金额在 billing 插件手里,
|
||||||
|
-- 但那是 admin 面板口径:实测 user key 取 billing state 拿到的仍是全量
|
||||||
|
-- (total.cost 与 admin 相同),按 key 取金额需要改 billing 插件本身。
|
||||||
|
-- 宁可少给一个数,也不给一个归属错误的数 —— 后者会让用户以为花了多少钱,
|
||||||
|
-- 而那个数是全站总量。
|
||||||
|
--
|
||||||
|
-- 字段形状取自 OpenAI 兼容惯例与前端实际读取的键:
|
||||||
|
-- total / total_usage / used / amount / cost ← 前端按顺序取第一个非空的
|
||||||
|
-- requests / request_count / count ← 同上
|
||||||
|
-- 这里给 total 与 requests,并在 usage 里附明细,便于排障。
|
||||||
|
|
||||||
|
local PLUGIN = {
|
||||||
|
name = "usage",
|
||||||
|
version = "1.0.0",
|
||||||
|
description = "按 key 汇报 token 与请求数(供 /v1/usage 兼容前端)",
|
||||||
|
}
|
||||||
|
|
||||||
|
-- roles 必填:插件 HTTP 路由的默认是 admin-only(声明里不写 roles 时,
|
||||||
|
-- 任何非 admin 拿到 403 “this endpoint declares no roles”)。
|
||||||
|
--
|
||||||
|
-- usage 的内容天然按**调用者自己的 key** 隔离(下面 selfStats 传的就是本次请求
|
||||||
|
-- 的 key),所以放行 user 是安全的:一个 user 只能看到自己的用量,与直接调
|
||||||
|
-- /api/stats?key= 得到的一致。声明角色而不是不声明,是为了让这个安全性
|
||||||
|
-- 写在声明里而不是藏在实现里。
|
||||||
|
PLUGIN.http = {
|
||||||
|
{ path = "/usage", method = "GET", handler = "usage",
|
||||||
|
description = "当前调用 key 的 token 与请求数",
|
||||||
|
roles = { "admin", "user" } },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- PERIOD 可选:day / week / month / all。默认 all —— 「已用多少」应当是
|
||||||
|
-- 累计值,日维度反而误导(看起来像每天都重置)。
|
||||||
|
local PERIOD = "all"
|
||||||
|
|
||||||
|
-- maskedKey 把完整 key 变成 /api/stats 认识的掩码形式。
|
||||||
|
local function maskedKey(key)
|
||||||
|
if type(key) ~= "string" then return "" end
|
||||||
|
if #key <= 6 then return key end
|
||||||
|
return "***" .. string.sub(key, -6)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- selfStats 取网关自己的统计。key 为空时返回 nil 而不是报错:
|
||||||
|
-- 无 key 的调用者没有可归属的用量,给零比给全量诚实。
|
||||||
|
local function selfStats(key)
|
||||||
|
if type(key) ~= "string" or key == "" then
|
||||||
|
return nil, "no key in request"
|
||||||
|
end
|
||||||
|
local url = "http://127.0.0.1:8081/api/stats?period=" .. PERIOD ..
|
||||||
|
"&limit=1&key=" .. maskedKey(key)
|
||||||
|
local r = __llmsproxy_http({
|
||||||
|
url = url,
|
||||||
|
method = "GET",
|
||||||
|
timeout_ms = 4000,
|
||||||
|
headers = { ["Authorization"] = "Bearer " .. key },
|
||||||
|
})
|
||||||
|
if r == nil then
|
||||||
|
return nil, "stats request refused or timed out"
|
||||||
|
end
|
||||||
|
if r.status ~= 200 then
|
||||||
|
return nil, "stats returned HTTP " .. tostring(r.status)
|
||||||
|
end
|
||||||
|
return r.body
|
||||||
|
end
|
||||||
|
|
||||||
|
function PLUGIN.usage(payload)
|
||||||
|
local key = payload and payload.key or ""
|
||||||
|
local body, err = selfStats(key)
|
||||||
|
if not body then
|
||||||
|
-- 一个拿不到统计的端点应当说清楚,而不是返回 0:0 会被前端渲染成
|
||||||
|
-- 「已用 0,0 次」,把故障说成了没用量。
|
||||||
|
return {
|
||||||
|
status = 503,
|
||||||
|
json = { error = { message = "usage unavailable: " .. tostring(err) } },
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local ok, doc = pcall(json.decode, body)
|
||||||
|
if not ok or type(doc) ~= "table" then
|
||||||
|
return {
|
||||||
|
status = 502,
|
||||||
|
json = { error = { message = "stats response is not JSON" } },
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local t = doc.total or {}
|
||||||
|
local n = function(v) return tonumber(v) or 0 end
|
||||||
|
|
||||||
|
-- 前端按 total → total_usage → used → amount → cost 的顺序取第一个「非空」。
|
||||||
|
-- Lua 里 nil 与 0 的差别很重要:0 会被读到,nil 会跳到下一个键,所以这几个
|
||||||
|
-- 别名全部给数值。
|
||||||
|
local total = n(t.tokens)
|
||||||
|
local requests = n(t.reqs)
|
||||||
|
|
||||||
|
return {
|
||||||
|
json = {
|
||||||
|
total = total,
|
||||||
|
total_usage = total,
|
||||||
|
used = total,
|
||||||
|
amount = total,
|
||||||
|
requests = requests,
|
||||||
|
request_count = requests,
|
||||||
|
count = requests,
|
||||||
|
-- 明细:前端不读,排障时不用再去翻 /api/stats
|
||||||
|
_detail = {
|
||||||
|
period = PERIOD,
|
||||||
|
key_id = maskedKey(key),
|
||||||
|
ok = n(t.ok),
|
||||||
|
err = n(t.err),
|
||||||
|
prompt_tokens = n(t.prompt_tokens),
|
||||||
|
completion_tokens = n(t.completion_tokens),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return PLUGIN
|
||||||
@ -177,6 +177,18 @@ func TestQuotaBackoffRecordsConsecutiveFailures(t *testing.T) {
|
|||||||
srv := newCountingServer(t)
|
srv := newCountingServer(t)
|
||||||
ps := newQuotaPlugins(t, srv.URL)
|
ps := newQuotaPlugins(t, srv.URL)
|
||||||
|
|
||||||
|
// Configure every vendor that needs its own credentials, so no adapter is
|
||||||
|
// UNABLE to judge the host. That is a precondition, not decoration: a host
|
||||||
|
// judged while an adapter is unconfigured gets a PROVISIONAL memo, and a
|
||||||
|
// provisional memo deliberately never backs off (it would refuse the very
|
||||||
|
// probe that succeeds once the credential arrives). Without this the test
|
||||||
|
// would be measuring that rule instead of the ordinary backoff.
|
||||||
|
if err := ps.SetState("quota", map[string]interface{}{
|
||||||
|
"prices": map[string]interface{}{"control_key": "ctrl"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
args := map[string]interface{}{
|
args := map[string]interface{}{
|
||||||
"sources": []interface{}{map[string]interface{}{
|
"sources": []interface{}{map[string]interface{}{
|
||||||
"name": "dead", "base_url": srv.URL + "/v1", "api_key": "k",
|
"name": "dead", "base_url": srv.URL + "/v1", "api_key": "k",
|
||||||
|
|||||||
649
internal/lua/quota_opendesign_test.go
Normal file
649
internal/lua/quota_opendesign_test.go
Normal file
@ -0,0 +1,649 @@
|
|||||||
|
package lua
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The OpenDesign / Vela adapter is the first quota probe that needs a credential
|
||||||
|
// the source itself does not carry. Inference runs on the source's `api_key`
|
||||||
|
// (the runtime key); the two billing endpoints on amr-api.open-design.ai only
|
||||||
|
// accept the `controlKey` the desktop app receives at sign-in.
|
||||||
|
//
|
||||||
|
// That asymmetry is why these tests exist: the obvious wrong implementation —
|
||||||
|
// "reuse the source's api_key like every other adapter" — returns 401 against
|
||||||
|
// the live service, and a test that only stubs a happy response would never
|
||||||
|
// notice.
|
||||||
|
//
|
||||||
|
// Live responses this fixture copies (both 200, ~1.3s):
|
||||||
|
// GET https://amr-api.open-design.ai/api/v1/wallet/balance
|
||||||
|
// GET https://amr-api.open-design.ai/api/v1/billing/summary
|
||||||
|
const (
|
||||||
|
odWallet = `{"balanceUsd":"15.7062","expiresAt":"2026-10-08T23:01:06.662Z",` +
|
||||||
|
`"updatedAt":"2026-10-06T08:02:20.167Z","billingScopeVersion":2,` +
|
||||||
|
`"workspaceId":"h7cvgf86l79yy3cpmsbmjfxu",` +
|
||||||
|
`"workspaceMemberId":"uxqzu1rrtwsmzfpwugkcuany"}`
|
||||||
|
|
||||||
|
odSummary = `{"balanceUsd":"15.7062","creditsPerUsd":10000,` +
|
||||||
|
`"balances":{"subscriptionCredits":"152062","rechargeCredits":"5000",` +
|
||||||
|
`"totalAvailableCredits":"157062"},"subscriptionCreditsUsd":"15.2062",` +
|
||||||
|
`"rechargeCreditsUsd":"0.5000","totalAvailableCreditsUsd":"15.7062",` +
|
||||||
|
`"membershipTier":"plus","billingInterval":"monthly",` +
|
||||||
|
`"subscriptionStatus":"active","subscriptionEntitlementStatus":"active",` +
|
||||||
|
`"subscriptionCancelAtPeriodEnd":false,` +
|
||||||
|
`"subscriptionCurrentPeriodStart":"2026-10-05T23:01:54.000Z",` +
|
||||||
|
`"subscriptionCurrentPeriodEnd":"2026-11-05T23:01:54.000Z",` +
|
||||||
|
`"pendingSubscriptionChange":null,"firstMonthIntroEligible":false,` +
|
||||||
|
`"availableActions":["subscription_checkout","billing_portal"],` +
|
||||||
|
`"totalRechargedUsd":"0.5000","totalConsumedUsd":"4.7938",` +
|
||||||
|
`"todayConsumedUsd":"0.0036","rechargeCount":1,"usageCount":2982,` +
|
||||||
|
`"updatedAt":"2026-10-06T14:55:00.407Z"}`
|
||||||
|
)
|
||||||
|
|
||||||
|
// odServer stands in for amr-api. It records the Authorization header it saw on
|
||||||
|
// the two BILLING routes so a test can assert WHICH credential the plugin sent
|
||||||
|
// — the whole point of this adapter.
|
||||||
|
//
|
||||||
|
// Only the billing paths are recorded. The other three adapters derive their
|
||||||
|
// paths from the source's base_url, not from api_base, so in these tests they
|
||||||
|
// are pointed at amr-link and refused by the outbound allowlist before they can
|
||||||
|
// reach this server. Recording every request would make the "did it probe at
|
||||||
|
// all?" assertion depend on that coincidence; recording the billing routes makes
|
||||||
|
// it mean exactly what it says.
|
||||||
|
func odServer(t *testing.T, seenAuth *[]string, wallet, summary string, wStatus, sStatus int) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/wallet/balance"):
|
||||||
|
if seenAuth != nil {
|
||||||
|
*seenAuth = append(*seenAuth, r.Header.Get("Authorization"))
|
||||||
|
}
|
||||||
|
w.WriteHeader(wStatus)
|
||||||
|
_, _ = w.Write([]byte(wallet))
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/billing/summary"):
|
||||||
|
if seenAuth != nil {
|
||||||
|
*seenAuth = append(*seenAuth, r.Header.Get("Authorization"))
|
||||||
|
}
|
||||||
|
w.WriteHeader(sStatus)
|
||||||
|
_, _ = w.Write([]byte(summary))
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"not found"}`))
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// odPlugins boots a real VM with the shipped quota.lua (not a copy) and returns
|
||||||
|
// the loaded Plugins.
|
||||||
|
// odPlugins boots the shipped quota.lua against a temp plugin dir.
|
||||||
|
//
|
||||||
|
// NOTE on what these tests can and cannot reach: the backoff window is read from
|
||||||
|
// the WALL CLOCK, so nothing here observes recovery of an aged negative memo —
|
||||||
|
// reaching that branch would mean sleeping 1800s. The aged-entry path is covered
|
||||||
|
// out of band, by seeding a negative memo with an old `at` (`.probe/trace_memo.py`,
|
||||||
|
// step 10) and watching the same code recover. What IS asserted here is the entry's
|
||||||
|
// SHAPE, which is what the read side keys its decisions on.
|
||||||
|
func odPlugins(t *testing.T) (*Plugins, string) {
|
||||||
|
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)
|
||||||
|
|
||||||
|
src := readShippedPlugin(t, "quota.lua")
|
||||||
|
if err := os.WriteFile(filepath.Join(pluginDir, "quota.lua"), []byte(src), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ps := NewPlugins(vm, pluginDir)
|
||||||
|
t.Cleanup(ps.Close)
|
||||||
|
// Set before LoadDir: the allowlist is consulted when the plugin makes a
|
||||||
|
// request, and the empty default denies everything.
|
||||||
|
ps.SetOutboundHosts([]string{"127.0.0.1"})
|
||||||
|
if err := ps.LoadDir(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
p := ps.find("quota")
|
||||||
|
if p == nil {
|
||||||
|
t.Fatal("quota plugin not found")
|
||||||
|
}
|
||||||
|
if p.LoadError != "" {
|
||||||
|
t.Fatalf("quota.lua failed to compile: %s", p.LoadError)
|
||||||
|
}
|
||||||
|
return ps, src
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectOpenDesign drives the plugin's own collect_source for a source that
|
||||||
|
// looks like vela. The plugin iterates ADAPTERS in order and picks the host's
|
||||||
|
// remembered vendor, so the first adapter that answers is the one under test.
|
||||||
|
func collectOpenDesign(t *testing.T, ps *Plugins, base string) map[string]interface{} {
|
||||||
|
t.Helper()
|
||||||
|
out, err := ps.Call("quota", "collect_source", map[string]interface{}{
|
||||||
|
"name": "vela",
|
||||||
|
"sources": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"name": "vela",
|
||||||
|
"base_url": base,
|
||||||
|
"api_key": "sk-amr-runtime-key-must-not-be-sent-to-billing",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("collect_source failed: %v", err)
|
||||||
|
}
|
||||||
|
rep, _ := out.(map[string]interface{})
|
||||||
|
return rep
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQuotaOpenDesignUsesControlKeyNotRuntimeKey is the reason this adapter
|
||||||
|
// cannot be written like the others.
|
||||||
|
//
|
||||||
|
// Mutation: replace `request(api .. "/wallet/balance", ck, ...)` with the
|
||||||
|
// function's `key` parameter (`request(api .. "/wallet/balance", key, ...)`)
|
||||||
|
// and this fails — the server records the runtime key.
|
||||||
|
func TestQuotaOpenDesignUsesControlKeyNotRuntimeKey(t *testing.T) {
|
||||||
|
var seenAuth []string
|
||||||
|
srv := odServer(t, &seenAuth, odWallet, odSummary, 200, 200)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
ps, _ := odPlugins(t)
|
||||||
|
// The plugin's config table is `prices`; SetState routes a `prices` key
|
||||||
|
// there rather than into accumulated state.
|
||||||
|
if err := ps.SetState("quota", map[string]interface{}{
|
||||||
|
"prices": map[string]interface{}{
|
||||||
|
"control_key": "vela_ctrl_TESTKEY",
|
||||||
|
"api_base": srv.URL + "/api/v1",
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SetState: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rep := collectOpenDesign(t, ps, "https://amr-link.open-design.ai/v1")
|
||||||
|
if rep == nil {
|
||||||
|
t.Fatal("no report produced")
|
||||||
|
}
|
||||||
|
if rep["vendor"] != "opendesign" {
|
||||||
|
t.Fatalf("wrong adapter answered: vendor=%v (report=%v)", rep["vendor"], rep)
|
||||||
|
}
|
||||||
|
for _, got := range seenAuth {
|
||||||
|
if strings.Contains(got, "runtime-key") {
|
||||||
|
t.Fatalf("the source's api_key was sent to the billing endpoint (%q); "+
|
||||||
|
"amr-api rejects it with 401 — the control key is a separate credential", got)
|
||||||
|
}
|
||||||
|
if got != "Bearer vela_ctrl_TESTKEY" {
|
||||||
|
t.Fatalf("billing request carried %q, want the configured control key", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(seenAuth) == 0 {
|
||||||
|
t.Fatal("no request reached the server: the adapter never probed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQuotaOpenDesignSkipsWithoutControlKey pins the failure mode that made the
|
||||||
|
// previous incarnation of this probe useless: with no credential the adapter
|
||||||
|
// must SKIP (not error, not report a zero balance). An error would put the
|
||||||
|
// source in the negative memo and file it under "probe failed"; a zero balance
|
||||||
|
// would tell the operator the account is empty.
|
||||||
|
//
|
||||||
|
// This test originally left api_base unset, which sent the probe to the REAL
|
||||||
|
// amr-api host. Every path then failed on the network and returned nil, so the
|
||||||
|
// test passed for the wrong reason — and a mutation that deleted the credential
|
||||||
|
// guard survived. Pointing api_base at a local stub makes the server reachable,
|
||||||
|
// so the only thing that can stop the request is the guard itself.
|
||||||
|
func TestQuotaOpenDesignSkipsWithoutControlKey(t *testing.T) {
|
||||||
|
var seenAuth []string
|
||||||
|
srv := odServer(t, &seenAuth, odWallet, odSummary, 200, 200)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
ps, _ := odPlugins(t)
|
||||||
|
// api_base set, control_key deliberately NOT set.
|
||||||
|
if err := ps.SetState("quota", map[string]interface{}{
|
||||||
|
"prices": map[string]interface{}{"api_base": srv.URL + "/api/v1"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rep := collectOpenDesign(t, ps, "https://amr-link.open-design.ai/v1")
|
||||||
|
if rep == nil {
|
||||||
|
t.Fatal("no report produced")
|
||||||
|
}
|
||||||
|
// The guard must come BEFORE the network. A reachable server that is never
|
||||||
|
// asked anything is the only evidence that the credential check ran at all.
|
||||||
|
if len(seenAuth) != 0 {
|
||||||
|
t.Fatalf("the adapter probed the billing host with no control key configured "+
|
||||||
|
"(Authorization: %q); an empty bearer is not a credential and the "+
|
||||||
|
"response cannot be trusted", seenAuth)
|
||||||
|
}
|
||||||
|
if rep["skipped"] != true {
|
||||||
|
t.Fatalf("skipped = %#v, want true", rep["skipped"])
|
||||||
|
}
|
||||||
|
reason, _ := rep["reason"].(string)
|
||||||
|
if !strings.Contains(reason, "control_key") {
|
||||||
|
t.Fatalf("reason = %q, want it to name the missing control_key so the "+
|
||||||
|
"operator knows what to go and configure", reason)
|
||||||
|
}
|
||||||
|
// Whatever adapter answers, it must not be a fabricated opendesign balance.
|
||||||
|
if rep["vendor"] == "opendesign" {
|
||||||
|
t.Fatalf("opendesign reported a balance with no control key configured: %v", rep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQuotaOpenDesignReportsBalanceAndSpend checks the fields the operator
|
||||||
|
// actually reads. The vendor sends these as JSON STRINGS ("15.7062"), so a
|
||||||
|
// naive type assertion to number would drop every one of them.
|
||||||
|
func TestQuotaOpenDesignReportsBalanceAndSpend(t *testing.T) {
|
||||||
|
srv := odServer(t, nil, odWallet, odSummary, 200, 200)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
ps, _ := odPlugins(t)
|
||||||
|
if err := ps.SetState("quota", map[string]interface{}{
|
||||||
|
"prices": map[string]interface{}{
|
||||||
|
"control_key": "vela_ctrl_TESTKEY",
|
||||||
|
"api_base": srv.URL + "/api/v1",
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rep := collectOpenDesign(t, ps, "https://amr-link.open-design.ai/v1")
|
||||||
|
if rep == nil || rep["vendor"] != "opendesign" {
|
||||||
|
t.Fatalf("expected an opendesign report, got %v", rep)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := map[string]float64{
|
||||||
|
"balance": 15.7062,
|
||||||
|
"subscription_usd": 15.2062,
|
||||||
|
"recharge_usd": 0.5000,
|
||||||
|
"today_used": 0.0036,
|
||||||
|
"period_used": 4.7938,
|
||||||
|
}
|
||||||
|
for k, exp := range want {
|
||||||
|
got, ok := rep[k].(float64)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("%s missing or not a number: %#v (full report %v)", k, rep[k], rep)
|
||||||
|
}
|
||||||
|
if got != exp {
|
||||||
|
t.Fatalf("%s = %v, want %v", k, got, exp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rep["credits_per_usd"] != float64(10000) {
|
||||||
|
t.Fatalf("credits_per_usd = %v, want 10000 (used for the credit split)", rep["credits_per_usd"])
|
||||||
|
}
|
||||||
|
if rep["tier"] != "plus" || rep["status"] != "active" {
|
||||||
|
t.Fatalf("tier/status = %v/%v, want plus/active", rep["tier"], rep["status"])
|
||||||
|
}
|
||||||
|
if rep["balance_expires_at"] != "2026-10-08T23:01:06.662Z" {
|
||||||
|
t.Fatalf("balance_expires_at = %v — a balance that expires silently is "+
|
||||||
|
"worse than one that warns", rep["balance_expires_at"])
|
||||||
|
}
|
||||||
|
// usageCount arrives as a JSON number, not a string, and must survive too.
|
||||||
|
if rep["usage_count"] != float64(2982) {
|
||||||
|
t.Fatalf("usage_count = %#v, want 2982", rep["usage_count"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQuotaOpenDesignHasNoFabricatedPeriodWindow pins the deliberately-absent
|
||||||
|
// field. `vela billing workspace-snapshot` answers billingState:"free" with NO
|
||||||
|
// codingPlan.windows for this account, so there is no period allowance to show.
|
||||||
|
//
|
||||||
|
// Mutation: add `rep.windows = {{...}}` (or any synthesized window) and this
|
||||||
|
// fails. The page renders windows as "X% remaining", so a fabricated one is a
|
||||||
|
// confident lie about the account.
|
||||||
|
func TestQuotaOpenDesignHasNoFabricatedPeriodWindow(t *testing.T) {
|
||||||
|
srv := odServer(t, nil, odWallet, odSummary, 200, 200)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
ps, _ := odPlugins(t)
|
||||||
|
if err := ps.SetState("quota", map[string]interface{}{
|
||||||
|
"prices": map[string]interface{}{
|
||||||
|
"control_key": "vela_ctrl_TESTKEY",
|
||||||
|
"api_base": srv.URL + "/api/v1",
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rep := collectOpenDesign(t, ps, "https://amr-link.open-design.ai/v1")
|
||||||
|
if rep == nil || rep["vendor"] != "opendesign" {
|
||||||
|
t.Fatalf("expected an opendesign report, got %v", rep)
|
||||||
|
}
|
||||||
|
if w, ok := rep["windows"]; ok && w != nil {
|
||||||
|
t.Fatalf("windows = %#v, but this account has no Coding Plan "+
|
||||||
|
"(billingState=free) — a synthesized window renders as a "+
|
||||||
|
"percentage remaining that does not exist", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQuotaOpenDesignRejectedControlKeyIsAnError guards the diagnosis. A 401
|
||||||
|
// means the credential is wrong — a fact the operator must fix — so it must not
|
||||||
|
// be filed under "skip" alongside "this vendor has no balance endpoint". The two
|
||||||
|
// produce the same blank card but wildly different next actions.
|
||||||
|
//
|
||||||
|
// Two details make the test actually exercise the rule instead of passing by
|
||||||
|
// accident, and both were found by a mutation surviving:
|
||||||
|
//
|
||||||
|
// 1. The memo is pre-seeded so openDesign runs FIRST. The memo remembers which
|
||||||
|
// vendor answered last and that vendor is tried first; once a host has ever
|
||||||
|
// answered through openDesign it runs first forever after. With the default
|
||||||
|
// order (openDesign last) its 401 IS the last verdict, so even a plain
|
||||||
|
// "last error wins" rule passes.
|
||||||
|
// 2. The other three adapters must ANSWER (404), not fail. They read `base`, not
|
||||||
|
// `api_base`, so the source base_url points at the same stub — otherwise they
|
||||||
|
// all error out on an unreachable host and the final verdict is an error
|
||||||
|
// regardless of the rule being tested.
|
||||||
|
func TestQuotaOpenDesignRejectedControlKeyIsAnError(t *testing.T) {
|
||||||
|
srv := odServer(t, nil, `{"error":"unauthenticated"}`, `{"error":"unauthenticated"}`,
|
||||||
|
http.StatusUnauthorized, http.StatusUnauthorized)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
ps, _ := odPlugins(t)
|
||||||
|
// Host key must match what hostOf(base) yields for the source below.
|
||||||
|
src := map[string]interface{}{
|
||||||
|
"name": "vela", "base_url": srv.URL, "api_key": "k",
|
||||||
|
}
|
||||||
|
if err := ps.SetState("quota", map[string]interface{}{
|
||||||
|
"prices": map[string]interface{}{
|
||||||
|
"control_key": "vela_ctrl_WRONG",
|
||||||
|
"api_base": srv.URL + "/api/v1",
|
||||||
|
},
|
||||||
|
// Flat, NOT nested under a "state" key: SetState pulls `prices` out and
|
||||||
|
// stores the REST of the payload as the plugin's state. Nesting it would
|
||||||
|
// store {state: {probe_memo: ...}} and the memo would never be read.
|
||||||
|
"probe_memo": map[string]interface{}{
|
||||||
|
hostOnly(srv.URL): "opendesign",
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := ps.Call("quota", "collect_source", map[string]interface{}{
|
||||||
|
"name": "vela", "sources": []interface{}{src},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rep, _ := out.(map[string]interface{})
|
||||||
|
if rep == nil {
|
||||||
|
t.Fatal("no report produced")
|
||||||
|
}
|
||||||
|
if rep["vendor"] == "opendesign" {
|
||||||
|
t.Fatalf("a rejected control key must not produce a balance: %v", rep)
|
||||||
|
}
|
||||||
|
// The plugin reports a credential failure as an error, which the page renders
|
||||||
|
// as a red "读取失败" row. Filing it as a skip instead shows the same blank
|
||||||
|
// card as "this vendor has no balance endpoint" — and sends the operator to
|
||||||
|
// investigate the vendor rather than re-run the login.
|
||||||
|
if rep["skipped"] == true {
|
||||||
|
t.Fatalf("a rejected control key was reported as a SKIP: %v — the other three "+
|
||||||
|
"adapters answered 404 for this host, so the final verdict is a skip unless "+
|
||||||
|
"a rejected credential outranks it. The operator would go looking at the "+
|
||||||
|
"vendor instead of re-running the login.", rep)
|
||||||
|
}
|
||||||
|
msg, _ := rep["error"].(string)
|
||||||
|
if !strings.Contains(msg, "rejected") {
|
||||||
|
t.Fatalf("error = %q, want it to name the rejected credential so the "+
|
||||||
|
"operator knows to re-run the login rather than to investigate the vendor", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQuotaOpenDesignDoesNotDefaultMissingRate pins the "do not invent an
|
||||||
|
// exchange rate" rule. creditsPerUsd is the only thing that converts the credit
|
||||||
|
// breakdown into dollars; when it is absent the vendor's own *Usd fields still
|
||||||
|
// give a correct balance, so the card must render with the split missing and
|
||||||
|
// FLAG it, not divide by a remembered 10000.
|
||||||
|
func TestQuotaOpenDesignDoesNotDefaultMissingRate(t *testing.T) {
|
||||||
|
// Same summary with creditsPerUsd removed.
|
||||||
|
noRate := strings.Replace(odSummary, `"creditsPerUsd":10000,`, "", 1)
|
||||||
|
if strings.Contains(noRate, "creditsPerUsd") {
|
||||||
|
t.Fatal("fixture still carries creditsPerUsd")
|
||||||
|
}
|
||||||
|
srv := odServer(t, nil, odWallet, noRate, 200, 200)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
ps, _ := odPlugins(t)
|
||||||
|
if err := ps.SetState("quota", map[string]interface{}{
|
||||||
|
"prices": map[string]interface{}{
|
||||||
|
"control_key": "vela_ctrl_TESTKEY",
|
||||||
|
"api_base": srv.URL + "/api/v1",
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rep := collectOpenDesign(t, ps, "https://amr-link.open-design.ai/v1")
|
||||||
|
if rep == nil || rep["vendor"] != "opendesign" {
|
||||||
|
t.Fatalf("expected an opendesign report, got %v", rep)
|
||||||
|
}
|
||||||
|
// The balance is authoritative and comes straight from the vendor.
|
||||||
|
if rep["balance"] != 15.7062 {
|
||||||
|
t.Fatalf("balance = %#v, want 15.7062 (it must not depend on the rate)", rep["balance"])
|
||||||
|
}
|
||||||
|
if rep["credits_rate_missing"] != true {
|
||||||
|
t.Fatalf("credits_rate_missing = %#v, want true: the page must show that "+
|
||||||
|
"the credit split is unavailable rather than a silent zero", rep["credits_rate_missing"])
|
||||||
|
}
|
||||||
|
if v, ok := rep["credits_per_usd"]; ok && v != nil {
|
||||||
|
t.Fatalf("credits_per_usd = %#v with no rate in the response — a defaulted "+
|
||||||
|
"rate produces confidently wrong dollar amounts", v)
|
||||||
|
}
|
||||||
|
// subscription_usd/recharge_usd come from the vendor's own USD fields, so
|
||||||
|
// they are still correct and must still be present.
|
||||||
|
if rep["subscription_usd"] != 15.2062 {
|
||||||
|
t.Fatalf("subscription_usd = %#v, want 15.2062 from the vendor's own field",
|
||||||
|
rep["subscription_usd"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQuotaOpenDesignHostRewritingIsUsed keeps the amr-link -> amr-api default
|
||||||
|
// working. Without it the adapter probes the inference host, which serves no
|
||||||
|
// billing route at all, and every OpenDesign source silently skips.
|
||||||
|
//
|
||||||
|
// Asserted on the exported derivation rather than on the source text or on a
|
||||||
|
// failed probe's error message. The source-text version pinned the SPELLING of
|
||||||
|
// the rule: it went red the moment the rule was tightened from an `amr-link.`
|
||||||
|
// prefix to a full hostname, while the behaviour was unchanged. The
|
||||||
|
// error-message version inferred "which host did we ask?" from an allowlist
|
||||||
|
// refusal, which stops working the moment any adapter earlier in the chain also
|
||||||
|
// fails — and one of them always does.
|
||||||
|
func TestQuotaOpenDesignHostRewritingIsUsed(t *testing.T) {
|
||||||
|
ps, _ := odPlugins(t)
|
||||||
|
cases := []struct{ in, want string }{
|
||||||
|
// The real production shape: base_url carries a path.
|
||||||
|
{"https://amr-link.open-design.ai/v1", "https://amr-api.open-design.ai/api/v1"},
|
||||||
|
// And without one, so the derivation cannot depend on the path being there.
|
||||||
|
{"https://amr-link.open-design.ai", "https://amr-api.open-design.ai/api/v1"},
|
||||||
|
// A trailing slash must not produce a double slash.
|
||||||
|
{"https://amr-link.open-design.ai/v1/", "https://amr-api.open-design.ai/api/v1"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
out, err := ps.Call("quota", "resolve_api_base", map[string]interface{}{
|
||||||
|
"base_url": c.in,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve_api_base(%q): %v", c.in, err)
|
||||||
|
}
|
||||||
|
rep, _ := out.(map[string]interface{})
|
||||||
|
if got, _ := rep["api_base"].(string); got != c.want {
|
||||||
|
t.Errorf("resolve_api_base(%q) = %q, want %q — the probe would go to the "+
|
||||||
|
"inference host, which serves no billing route, and the source would "+
|
||||||
|
"silently stay unlisted", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A lookalike host must NOT be rewritten into an OpenDesign address: an
|
||||||
|
// `amr-link.` prefix rule would turn amr-link.example.com into amr-api...,
|
||||||
|
// sending an unrelated provider's probe to a host that is not its own.
|
||||||
|
out, err := ps.Call("quota", "resolve_api_base", map[string]interface{}{
|
||||||
|
"base_url": "https://amr-link.example.com/v1",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rep, _ := out.(map[string]interface{})
|
||||||
|
if got, _ := rep["api_base"].(string); got != "https://amr-link.example.com/api/v1" {
|
||||||
|
t.Errorf("a lookalike host was rewritten to %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An explicit override wins (and is trimmed), so an operator can point at a
|
||||||
|
// moved host without waiting for a code change.
|
||||||
|
out, err = ps.Call("quota", "resolve_api_base", map[string]interface{}{
|
||||||
|
"base_url": "https://amr-link.open-design.ai/v1",
|
||||||
|
"api_base": "https://amr-api.example.net/api/v1/",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rep, _ = out.(map[string]interface{})
|
||||||
|
if got, _ := rep["api_base"].(string); got != "https://amr-api.example.net/api/v1" {
|
||||||
|
t.Errorf("api_base override not honoured (or not trimmed): %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQuotaUnreadyAdapterDoesNotSuppressAConfiguredHost is the end-to-end half of
|
||||||
|
// the unready rule, and it reproduces two bugs seen on the live plugin.
|
||||||
|
//
|
||||||
|
// (1) STALE READ. The memo is applied at the top of collectSource and the table
|
||||||
|
// it comes from is only updated by the WRITE side, which runs at the very end.
|
||||||
|
// Keying the backoff on `memo_entry_exists` therefore asks a question that is
|
||||||
|
// true on everything except the first probe of the oldest entry, and the plugin
|
||||||
|
// describes it to the operator as "连续探测失败 1 次" — an invented streak. Live
|
||||||
|
// trace: unset key → memo → key set → probes refused, memo untouched, forever.
|
||||||
|
// Backing off must key on the recorded STREAK, not on the entry existing.
|
||||||
|
//
|
||||||
|
// (2) EMPTY `unready` SET. Lua drops empty tables, so an answer from every
|
||||||
|
// adapter writes a negative with no `unready` field — indistinguishable, by
|
||||||
|
// truthiness, from a provisional one whose set happens to be empty. Reading it as
|
||||||
|
// provisional made an ordinary streak into a non-backing-off entry, which is the
|
||||||
|
// other half of the same live symptom.
|
||||||
|
//
|
||||||
|
// It is NOT fixable by "do not memoise a skip": on a real OpenDesign host the
|
||||||
|
// other three adapters answer 404 ("no dashboard here"), which is durable and
|
||||||
|
// worth remembering, so the verdict would be recorded either way. The memo has to
|
||||||
|
// carry WHICH vendors were unable to judge the host, and the read side has to
|
||||||
|
// retire that record once they can.
|
||||||
|
func TestQuotaUnreadyAdapterDoesNotSuppressAConfiguredHost(t *testing.T) {
|
||||||
|
var seenAuth []string
|
||||||
|
srv := odServer(t, &seenAuth, odWallet, odSummary, 200, 200)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
ps, _ := odPlugins(t)
|
||||||
|
args := map[string]interface{}{
|
||||||
|
"name": "vela",
|
||||||
|
"sources": []interface{}{map[string]interface{}{
|
||||||
|
"name": "vela", "base_url": srv.URL + "/v1", "api_key": "k",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: no control_key. openDesign cannot judge the host, and no billing
|
||||||
|
// request goes anywhere.
|
||||||
|
out, err := ps.Call("quota", "collect_source", args)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rep, _ := out.(map[string]interface{})
|
||||||
|
if rep["skipped"] != true {
|
||||||
|
t.Fatalf("expected a skip with no control key configured: %v", rep)
|
||||||
|
}
|
||||||
|
if len(seenAuth) != 0 {
|
||||||
|
t.Fatalf("an unconfigured adapter made a request: %q", seenAuth)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: configure the key. The very next probe must reach the upstream. It
|
||||||
|
// is inside the backoff window (minutes of wall clock) while this runs in
|
||||||
|
// microseconds — so a host refused now is refused for the whole window in
|
||||||
|
// production, which is the bug, not a timing accident.
|
||||||
|
if err := ps.SetState("quota", map[string]interface{}{
|
||||||
|
"prices": map[string]interface{}{
|
||||||
|
"control_key": "vela_ctrl_TESTKEY",
|
||||||
|
"api_base": srv.URL + "/api/v1",
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out, err = ps.Call("quota", "collect_source", args)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rep, _ = out.(map[string]interface{})
|
||||||
|
if rep["vendor"] != "opendesign" || rep["skipped"] == true {
|
||||||
|
t.Fatalf("expected an opendesign balance once configured, got %v — the host "+
|
||||||
|
"is still suppressed by the provisional memo it earned while the adapter "+
|
||||||
|
"had no credential", rep)
|
||||||
|
}
|
||||||
|
if len(seenAuth) == 0 {
|
||||||
|
t.Fatal("no request reached the billing endpoint after the control key was " +
|
||||||
|
"supplied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQuotaUnreadyStreakDoesNotBlockAConfiguredHost covers the second half of the
|
||||||
|
// same live symptom, and it needs its own server because the first half ends with a
|
||||||
|
// SUCCESS (which overwrites the memo).
|
||||||
|
//
|
||||||
|
// The read side backs a host off by looking up `HOST_MEMO[host]`, a table the
|
||||||
|
// WRITE side only fills in at the very end of the call. Two ways that goes wrong:
|
||||||
|
//
|
||||||
|
// - backing off on "an entry exists" instead of on its streak — the entry always
|
||||||
|
// exists, so the host is refused forever after its first negative
|
||||||
|
// - reading an `unready` set that is absent (Lua drops empty tables when every
|
||||||
|
// adapter answered) as if it were a provisional one, which DISABLES the backoff
|
||||||
|
// for exactly the hosts that most need it
|
||||||
|
//
|
||||||
|
// Both are about the shape of the entry rather than about the vendor, so they are
|
||||||
|
// asserted on the entry the probe actually left behind.
|
||||||
|
func TestQuotaUnreadyStreakDoesNotBlockAConfiguredHost(t *testing.T) {
|
||||||
|
// Every billing route 404s: no vendor has a dashboard for this host, which is
|
||||||
|
// the durable negative the memo exists to remember.
|
||||||
|
srv := odServer(t, nil, `{}`, `{}`, 404, 404)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
ps, _ := odPlugins(t)
|
||||||
|
if err := ps.SetState("quota", map[string]interface{}{
|
||||||
|
"prices": map[string]interface{}{
|
||||||
|
"control_key": "vela_ctrl_TESTKEY",
|
||||||
|
"api_base": srv.URL + "/api/v1",
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
args := map[string]interface{}{
|
||||||
|
"name": "vela",
|
||||||
|
"sources": []interface{}{map[string]interface{}{
|
||||||
|
"name": "vela", "base_url": srv.URL + "/v1", "api_key": "k",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
if _, err := ps.Call("quota", "collect_source", args); err != nil {
|
||||||
|
t.Fatalf("probe %d: %v", i+1, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
st, _ := ps.State("quota").(map[string]interface{})
|
||||||
|
memo, _ := st["probe_memo"].(map[string]interface{})
|
||||||
|
// The host key is derived from base_url, so find the single entry.
|
||||||
|
if len(memo) != 1 {
|
||||||
|
t.Fatalf("expected exactly one memo entry, got %v", memo)
|
||||||
|
}
|
||||||
|
var entry map[string]interface{}
|
||||||
|
for _, v := range memo {
|
||||||
|
entry, _ = v.(map[string]interface{})
|
||||||
|
}
|
||||||
|
if entry == nil {
|
||||||
|
t.Fatalf("the negative result was not recorded at all: %v", memo)
|
||||||
|
}
|
||||||
|
// The durable/provisional distinction is the entry's SHAPE. A first probe of a
|
||||||
|
// host no vendor can report on must produce a durable entry: with the empty
|
||||||
|
// `unready` set stamped on unconditionally, Lua's dropping of empty tables
|
||||||
|
// means the reader and the writer disagree about what an absent field means,
|
||||||
|
// and a host that most needs backing off is the one that never gets it.
|
||||||
|
if _, provisional := entry["unready"]; provisional {
|
||||||
|
t.Fatalf("every adapter judged this host, so the entry must not be "+
|
||||||
|
"provisional (an absent `unready` is what makes it durable): %v", entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
112
internal/lua/usage_plugin_test.go
Normal file
112
internal/lua/usage_plugin_test.go
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
package lua
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestUsagePluginShipsAndGuardsItsOwnKey covers the two things that make the
|
||||||
|
// usage plugin safe to ship, and both are easy to break by accident because they
|
||||||
|
// live in a Lua file nobody compiles.
|
||||||
|
//
|
||||||
|
// 1) SHIPPED. `usage` is the reason /v1/usage exists at all, and it only exists
|
||||||
|
// for deployments that receive it. A gateway build that does not embed it
|
||||||
|
// answers 404 and the frontend shows "后台未开放消费接口" — which reads as a
|
||||||
|
// bug rather than as a missing optional feature. Same reasoning billing.lua and
|
||||||
|
// quota.lua are embedded.
|
||||||
|
//
|
||||||
|
// 2) SELF-SCOPED. The handler reports the CALLER's usage, so it must look the
|
||||||
|
// caller up by the key in the request, never by a key from its own
|
||||||
|
// configuration. A plugin that read a configured key would report one
|
||||||
|
// tenant's spend to every tenant — the exact leak this plugin avoids by
|
||||||
|
// passing the request's key straight through.
|
||||||
|
func TestUsagePluginShipsAndGuardsItsOwnKey(t *testing.T) {
|
||||||
|
src := readShippedPlugin(t, "usage.lua")
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
`path = "/usage"`,
|
||||||
|
`handler = "usage"`,
|
||||||
|
`roles = { "admin", "user" }`,
|
||||||
|
`payload.key`,
|
||||||
|
`maskedKey`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(src, want) {
|
||||||
|
t.Errorf("usage.lua no longer contains %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The adapter-less path: it must not reach for a stored credential.
|
||||||
|
for _, forbidden := range []string{
|
||||||
|
"PLUGIN.state",
|
||||||
|
"PLUGIN.prices",
|
||||||
|
"api_key",
|
||||||
|
"control_key",
|
||||||
|
} {
|
||||||
|
if strings.Contains(src, forbidden) {
|
||||||
|
t.Errorf("usage.lua reads %q — it must report only the calling key "+
|
||||||
|
"from the request, never a stored credential", forbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUsageMasksKeyLikeTheGateway pins the mask algorithm against the Go one.
|
||||||
|
//
|
||||||
|
// This is the subtle one. /api/stats keys its maps by keyID(req.Key) — the
|
||||||
|
// MASKED form ("***f9a9ed") — while the handler receives the FULL key. Passing
|
||||||
|
// the full key does not error, it silently returns zero, and the endpoint then
|
||||||
|
// reports "0 requests" for a key with hundreds of thousands of them. Measured
|
||||||
|
// against the live gateway before this was pinned:
|
||||||
|
//
|
||||||
|
// ?key=***f9a9ed → total.reqs = 33847
|
||||||
|
// ?key=sk-gw-7ffe…9ed → total.reqs = 0
|
||||||
|
//
|
||||||
|
// So the Lua mask must match internal/gateway/server.go's keyID exactly: keys of
|
||||||
|
// 6 characters or fewer are returned unchanged, longer ones become "***" plus the
|
||||||
|
// last 6.
|
||||||
|
func TestUsageMasksKeyLikeTheGateway(t *testing.T) {
|
||||||
|
cases := []struct{ in, want string }{
|
||||||
|
{"sk-gw-7ffe12c5cbacec40bd39a7291df9a9ed", "***f9a9ed"},
|
||||||
|
{"sk-gw-7287aba64ef7ee665cf723222610a67d", "***10a67d"},
|
||||||
|
{"1234567", "***234567"},
|
||||||
|
// 6 or fewer: returned unchanged, on both sides.
|
||||||
|
{"short", "short"},
|
||||||
|
{"123456", "123456"},
|
||||||
|
}
|
||||||
|
src := readShippedPlugin(t, "usage.lua")
|
||||||
|
for _, c := range cases {
|
||||||
|
// The mask lives in Lua, so assert on its shape rather than run it: a
|
||||||
|
// behavioural test here would need a whole VM plus a stubbed stats
|
||||||
|
// endpoint, and would still not cover the real one.
|
||||||
|
if !strings.Contains(src, `return "***" .. string.sub(key, -6)`) {
|
||||||
|
t.Fatal("usage.lua no longer masks as \"***\" + last 6 characters")
|
||||||
|
}
|
||||||
|
if !strings.Contains(src, `if #key <= 6 then return key end`) {
|
||||||
|
t.Fatal("usage.lua no longer returns short keys unchanged")
|
||||||
|
}
|
||||||
|
_ = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUsageReportsZeroOnlyForDataItGot guards the failure shape. An endpoint that
|
||||||
|
// cannot reach its data source must say so: returning zeros would be rendered as
|
||||||
|
// "已用 0,0 次" and read as "this key has never been used", which is a different
|
||||||
|
// and wrong claim.
|
||||||
|
func TestUsageReportsZeroOnlyForDataItGot(t *testing.T) {
|
||||||
|
src := readShippedPlugin(t, "usage.lua")
|
||||||
|
if !strings.Contains(src, "usage unavailable") {
|
||||||
|
t.Error("usage.lua has no distinct failure message; a failed lookup must not " +
|
||||||
|
"be indistinguishable from zero usage")
|
||||||
|
}
|
||||||
|
if !strings.Contains(src, "if r == nil then") {
|
||||||
|
t.Error("usage.lua does not check for a refused or timed-out stats request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUsagePeriodIsAll pins the window. "All" answers "how much has this key
|
||||||
|
// used"; a day window would look like the number resets every morning, which is
|
||||||
|
// the more alarming misreading of the same digits.
|
||||||
|
func TestUsagePeriodIsAll(t *testing.T) {
|
||||||
|
if !strings.Contains(readShippedPlugin(t, "usage.lua"), `local PERIOD = "all"`) {
|
||||||
|
t.Error(`usage.lua no longer defaults to period "all"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
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.
|
// ModelByID returns the model definition if owned by this source.
|
||||||
func (p *Provider) ModelByID(id string) *config.Model {
|
func (p *Provider) ModelByID(id string) *config.Model {
|
||||||
for i := range p.cfg.Models {
|
for i := range p.cfg.Models {
|
||||||
|
|||||||
@ -196,12 +196,27 @@ func (r *Registry) ProviderForSlot(model, source string) *Provider {
|
|||||||
return nil
|
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.
|
// ModelStatus is a web-UI friendly snapshot per source.
|
||||||
type SourceStatus struct {
|
type SourceStatus struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Adapter string `json:"adapter"`
|
Adapter string `json:"adapter"`
|
||||||
BaseURL string `json:"base_url"`
|
BaseURL string `json:"base_url"`
|
||||||
Models []string `json:"models"`
|
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"`
|
Available bool `json:"available"`
|
||||||
Healthy bool `json:"healthy"`
|
Healthy bool `json:"healthy"`
|
||||||
MaxConcurrent int `json:"max_concurrent"`
|
MaxConcurrent int `json:"max_concurrent"`
|
||||||
@ -259,6 +274,7 @@ func (r *Registry) Status() []SourceStatus {
|
|||||||
Adapter: p.Adapter(),
|
Adapter: p.Adapter(),
|
||||||
BaseURL: p.Config().BaseURL,
|
BaseURL: p.Config().BaseURL,
|
||||||
Models: p.Models(),
|
Models: p.Models(),
|
||||||
|
ModelKinds: p.ModelsWithKind(),
|
||||||
Available: p.Available(),
|
Available: p.Available(),
|
||||||
Healthy: p.Available(),
|
Healthy: p.Available(),
|
||||||
MaxConcurrent: p.MaxConcurrent(),
|
MaxConcurrent: p.MaxConcurrent(),
|
||||||
|
|||||||
Reference in New Issue
Block a user