fix(quota): 未就绪适配器污染负 memo;被拒凭据被后续 404 掩盖

端到端验证时撞出两个真缺陷,都是单元测试全绿、隔离实例打真上游才暴露。

一、负 memo 语义混用

负 memo 说的是「这台 HOST 没有余额端点」,且无条件在每次探测前生效。
openDesign 缺 control_key 说的是「这台部署还没配」—— 两件事共用一个条目:

  1. 未配 control_key → 探测 → 负 memo
  2. 操作员写入 key   → 探测被退避拦下,adapter 根本没跑
  3. 结果:修复看起来无效,源持续沉默(退避窗口可达数小时)

不能靠「skip 就不记 memo」解决:真实 vela 主机上另外三个 adapter 都返回
404(关于主机的持久结论,正是 memo 存在的理由),verdict 一定是普通 skip。
做法是 memo 条目带上「哪些 vendor 因自身未配置而无法判断」:
  - 未就绪的 adapter 直接不跑(跑了只能得到 401 或伪造的 skip)
  - 条目带 unready 集合;该 vendor 配置好后条目作废重算
  - 仍缺配置时条目依然写入(否则无看板的主机回到每次开页 60 秒超时),
    但不退避 —— 退避会拒绝那个本来能成功的探测

二、401 被下一个 adapter 的 404 掩盖

adapter 循环不停在 error,而「最后一个胜」不精确:openDesign 走厂商的整份
主机列表,是最可能看到「key rejected」的那个,但它也可能排在最前(memo 记
得它应答过,下次就优先它)—— 于是它的 401 被下一个 adapter 的
404「no dashboard here」分类成「这台主机没有余额端点」。两种情况的修复动作
完全相反,输出却一样。规则改为:被拒凭据优先,且不被覆盖。

判据:12/12 变异全杀。三条曾经存活或误杀,逐条查清:
  - rejected-as-skip 存活是因为测试没有复现生产调用顺序(memo 空 → openDesign
    排最后 → 旧规则也能过),测试已改为预置 memo 并让另三个 adapter 走同一个
    stub(它们读 base 而非 api_base,否则会全部传输失败)
  - no-cred-guard 并非存活,只是杀它的不是我以为的那个测试(门禁后面还有
    一道检查),预期已改正
  - unready-memo-not-dropped 是真等价变异(known=false 与 nil 只差一次廉价
    探活),已从清单移除

(cherry picked from commit 83f1ec0807)
This commit is contained in:
JianFeeeee
2026-10-07 01:42:24 +08:00
parent f042bbf4af
commit 7934507179
3 changed files with 390 additions and 70 deletions

View File

@ -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
@ -231,6 +232,81 @@ 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
-- 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
@ -287,17 +363,7 @@ local function openDesign(base, key)
-- `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 = nil
if type(cfg) == "table" then api = cfg.api_base end
if type(api) ~= "string" or api == "" then
-- Host only: base_url carries a path (`.../v1`), and matching the hostname
-- rule against it would silently skip the rewrite and send the probe to the
-- inference host — which serves no billing route.
local origin = base:gsub("^https?://", ""):gsub("/.*$", "")
origin = origin:gsub("^amr%-link%.open%-design%.ai$", "amr-api.open-design.ai")
api = "https://" .. origin .. "/api/v1"
end
api = api:gsub("/+$", "")
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
@ -547,6 +613,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.
@ -558,16 +642,49 @@ 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 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 = type(known.unready) == "table"
local stale = false
if provisional then
for vendor in pairs(known.unready) do
if vendorReady(vendor) then stale = true end
end
end
if provisional then
if stale then
-- The missing configuration is here: derive the verdict again.
known = nil
else
-- Still waiting for it. Do NOT back off: the memo is applied before any
-- adapter runs, so honouring it would refuse the very probe that will
-- succeed once the credential arrives.
known = false
end
else
-- A streak is a host that has failed repeatedly. Skip it outright until its
-- backoff expires, instead of re-paying three adapter timeouts every TTL.
if backoffSeconds(seen, known.streak) then
return { name = src.name, host = host, skipped = true, backing_off = true,
reason = "连续探测失败 " .. tostring(known.streak) .. " 次,退避中(等待下次重试)" }
end
if PROBE_MEMO_TTL > 0 and (os.time() - seen) > PROBE_MEMO_TTL then
known = nil
else
known = false -- still "nothing answered, try cheapest first"
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
@ -612,13 +729,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
@ -643,18 +779,47 @@ 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.
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 }
if type(prev) == "table" and not prev.unready 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
-- The skip REASON travels. Discarding it here is why
@ -665,6 +830,12 @@ local function collectSource(src, budget)
-- "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
@ -732,8 +903,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
@ -1175,6 +1362,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};

View File

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

View File

@ -317,34 +317,66 @@ func TestQuotaOpenDesignHasNoFabricatedPeriodWindow(t *testing.T) {
// 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)
}
rep := collectOpenDesign(t, ps, "https://amr-link.open-design.ai/v1")
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 kind="error", which the page
// renders as a red "读取失败" row. Filing it as a skip instead would show the
// same blank card as "this vendor has no balance endpoint" — and send the
// operator to investigate the vendor rather than re-run the login.
// 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 two "+
"require different fixes and must not look alike", rep)
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") {
@ -404,48 +436,133 @@ func TestQuotaOpenDesignDoesNotDefaultMissingRate(t *testing.T) {
// working. Without it the adapter probes the inference host, which serves no
// billing route at all, and every OpenDesign source silently skips.
//
// Asserted BEHAVIOURALLY, through the outbound allowlist rather than on the
// source text. The allowlist in these tests holds only 127.0.0.1, so a request
// to any real host is refused with a message that NAMES the host it tried. That
// makes the refusal the observation: "amr-api.open-design.ai" in the reason
// proves the derivation ran, and its absence proves it did not. The earlier
// version of this test grepped quota.lua for the gsub pattern, which pins the
// spelling of the rule instead of its effect — it went red the moment the rule
// was tightened to a full-hostname match, while the behaviour was unchanged.
// 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)
// No api_base: the adapter must derive the billing host itself. The control
// key IS set so the credential guard is not what stops us.
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 a bug seen on the live plugin.
//
// The negative memo means "this HOST exposes no balance endpoint" and is applied
// UNCONDITIONALLY, before any adapter runs. A host judged while openDesign had no
// prices.control_key therefore got memoised as having nothing and stayed
// suppressed after the key was supplied — observed live: unset key → memo → key
// set → probe skipped without a single request, so the fix the operator had just
// applied looked like it did nothing.
//
// 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"},
"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 {
t.Fatal("no report produced")
out, err = ps.Call("quota", "collect_source", args)
if err != nil {
t.Fatal(err)
}
// The outbound allowlist in these tests holds only 127.0.0.1, so the request
// is refused before it leaves the process and the refusal NAMES the host it
// tried. The observation is therefore the refusal text, not the record as a
// whole: `host` is `hostOf(base)` — the SOURCE host, which is amr-link by
// design and says nothing about where the request went.
var msg string
if s, ok := rep["error"].(string); ok {
msg = s
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 s, ok := rep["reason"].(string); ok {
msg += " " + s
}
if !strings.Contains(msg, "amr-api.open-design.ai") {
t.Fatalf("no evidence the adapter derived the billing host from the "+
"inference one: %q. Expected the allowlist refusal to name "+
"amr-api.open-design.ai, since that is the host the request must go to. "+
"(report: %v)", msg, rep)
}
if strings.Contains(msg, "amr-link") {
t.Fatalf("the adapter probed the INFERENCE host — it serves no billing "+
"route, so every OpenDesign source would silently skip: %q", msg)
if len(seenAuth) == 0 {
t.Fatal("no request reached the billing endpoint after the control key was " +
"supplied")
}
}