mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-08 10:17:21 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d9eb916df | |||
| d01712a697 | |||
| 3444d2d903 | |||
| 83f1ec0807 | |||
| 65075b6d44 | |||
| 4a63b9dc0b | |||
| 75a0d247a4 | |||
| 2fdb82b119 |
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "modelrouter-gui",
|
||||
"version": "1.9.7",
|
||||
"version": "1.9.8",
|
||||
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
|
||||
"author": "ModelRouter",
|
||||
"main": "main.js",
|
||||
|
||||
@ -650,11 +650,29 @@ func (c *Core) rebuildRegistry() error {
|
||||
// allowlist for plugin outbound HTTP. Unparseable URLs are skipped rather than
|
||||
// failing startup: a malformed base_url is already rejected when the provider is
|
||||
// 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 {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
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 {
|
||||
if s.BaseURL == "" {
|
||||
continue
|
||||
@ -663,11 +681,27 @@ func sourceHosts(cfg *config.Config) []string {
|
||||
if err != nil || u.Hostname() == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, u.Hostname())
|
||||
add(u.Hostname())
|
||||
add(deriveBillingHost(u.Hostname()))
|
||||
}
|
||||
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
|
||||
// against the current providers. Slot model ids are normalized to the exact
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@ -15,6 +15,11 @@ package gateway
|
||||
// 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 (
|
||||
@ -49,6 +54,23 @@ func TestRealMutants(t *testing.T) {
|
||||
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)
|
||||
@ -234,6 +256,23 @@ func runSuite(wd, filter string) string {
|
||||
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") {
|
||||
|
||||
@ -3000,9 +3000,25 @@ async function renderChat() {
|
||||
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",
|
||||
body: size ? { model: model, prompt: prompt, n: n, size: size } : { model: model, prompt: prompt, n: n },
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const empty = $("#it-empty");
|
||||
if (empty) empty.remove();
|
||||
@ -3483,7 +3499,14 @@ async function renderChat() {
|
||||
try {
|
||||
const r = await api("/api/sources/discover", {
|
||||
method: "POST",
|
||||
body: { base_url: baseUrl, api_key: apiKey },
|
||||
// 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
|
||||
|
||||
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
|
||||
}
|
||||
@ -281,3 +281,60 @@ func TestCloseFlushesTail(t *testing.T) {
|
||||
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]
|
||||
sp.idle = sp.idle[:n-1]
|
||||
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
|
||||
}
|
||||
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()
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
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.
|
||||
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
|
||||
// empty value clears the override and restores the plugin's own default.
|
||||
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 {
|
||||
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.
|
||||
|
||||
@ -43,7 +43,8 @@ PLUGIN.name = "quota"
|
||||
PLUGIN.version = "1.0.0"
|
||||
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
|
||||
-- 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
|
||||
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
|
||||
-- politeness here: several of these hosts sit behind DNS that intermittently
|
||||
-- refuses, and a single 000 turned into "provider unknown" on every poll.
|
||||
@ -220,6 +232,247 @@ local function openaiDashboard(base, key)
|
||||
return rep, nil
|
||||
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.
|
||||
local function commandCode(base, key)
|
||||
local H = {
|
||||
@ -293,6 +546,7 @@ local ADAPTERS = {
|
||||
{ vendor = "commandcode", probe = commandCode },
|
||||
{ vendor = "deepseek", probe = deepseekBalance },
|
||||
{ vendor = "openai-dash", probe = openaiDashboard },
|
||||
{ vendor = "opendesign", probe = openDesign },
|
||||
}
|
||||
|
||||
-- The probe memo remembers, per host, which adapter answered. Persisted in the
|
||||
@ -375,6 +629,24 @@ local function collectSource(src, budget)
|
||||
base = base:gsub("/+$", "")
|
||||
local key = tostring(src.api_key or "")
|
||||
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
|
||||
-- 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.
|
||||
@ -386,17 +658,59 @@ local function collectSource(src, budget)
|
||||
-- exactly the confusion that made an earlier revision drop five sources.
|
||||
if type(known) == "table" then
|
||||
local seen = known.at or 0
|
||||
-- A streak is a host that has failed repeatedly. Skip it outright until its
|
||||
-- backoff expires, instead of re-paying three adapter timeouts every TTL.
|
||||
if backoffSeconds(seen, known.streak) then
|
||||
return { name = src.name, host = host, skipped = true, backing_off = true,
|
||||
reason = "连续探测失败 " .. tostring(known.streak) .. " 次,退避中(等待下次重试)" }
|
||||
-- A memo written while an adapter could not judge the host is PROVISIONAL.
|
||||
-- It is discarded as soon as that adapter becomes configured, and it never
|
||||
-- earns a backoff while it is still waiting.
|
||||
--
|
||||
-- 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
|
||||
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
|
||||
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
|
||||
-- known == false means every adapter already came back "no endpoint here" for
|
||||
-- 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,
|
||||
reason = "probe budget exhausted" }
|
||||
end
|
||||
local r, k, e = a.probe(base, key)
|
||||
if r ~= nil then
|
||||
rep = r
|
||||
HOST_MEMO[host] = a.vendor
|
||||
break
|
||||
-- An adapter that cannot answer for want of its OWN configuration is not
|
||||
-- probed at all. Running it would produce a 401 (reported as ERROR "key
|
||||
-- rejected") or a fabricated skip, and would send the operator to re-issue a
|
||||
-- credential they have not configured yet.
|
||||
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
|
||||
kind, why = k, e
|
||||
end
|
||||
-- Remember the NEGATIVE result too. Only recording successes left every
|
||||
-- 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
|
||||
-- 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
|
||||
local prev = HOST_MEMO[host]
|
||||
local streak = 1
|
||||
if type(prev) == "table" then streak = (prev.streak or 1) + 1 end
|
||||
HOST_MEMO[host] = { at = os.time(), streak = streak }
|
||||
local prevUnready = provisionalEntry(prev)
|
||||
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
|
||||
if rep ~= nil then
|
||||
HOST_MEMO_AT[host] = os.time()
|
||||
end
|
||||
if rep == nil then
|
||||
if kind == "error" then
|
||||
return { name = src.name, vendor = "", error = tostring(why),
|
||||
-- An ERROR is reported when either:
|
||||
-- * 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) }
|
||||
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
|
||||
rep.name = src.name
|
||||
rep.host = hostOf(base)
|
||||
@ -552,8 +934,24 @@ PLUGIN.memo_record = function(payload)
|
||||
else
|
||||
local prev = HOST_MEMO[host]
|
||||
local streak = 1
|
||||
if type(prev) == "table" then streak = (prev.streak or 1) + 1 end
|
||||
HOST_MEMO[host] = { at = now, streak = streak }
|
||||
-- A provisional entry is replaced, not aged: its count describes a
|
||||
-- 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
|
||||
@ -911,8 +1309,16 @@ PLUGIN.ui = {
|
||||
' 个源正在退避(连续探测失败,等下次重试):'+escH(backing.map(function(r){return r.name;}).join("、"))+'</div>';
|
||||
}
|
||||
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+
|
||||
' 个源(未暴露余额接口):'+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
|
||||
// turn every skipped host into an ever-growing streak.
|
||||
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.skipped) return {host:r.host};
|
||||
return {host:r.host, vendor:r.vendor};
|
||||
|
||||
@ -177,6 +177,18 @@ func TestQuotaBackoffRecordsConsecutiveFailures(t *testing.T) {
|
||||
srv := newCountingServer(t)
|
||||
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{}{
|
||||
"sources": []interface{}{map[string]interface{}{
|
||||
"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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user