From 75a0d247a490278840f2ed555b7b682b164dcb7c Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Tue, 6 Oct 2026 23:43:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(quota):=20=E6=8E=A5=E5=85=A5=20OpenDesign/?= =?UTF-8?q?Vela=20=E4=BD=99=E9=A2=9D=E6=8E=A2=E6=B5=8B=EF=BC=88controlKey?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vela 一直显示 skipped,因为计费端点需要第二个凭据:推理走源自己的 runtimeKey,但 amr-api.open-design.ai 的 wallet/balance 和 billing/summary 只认桌面端登录时下发的 controlKey(runtimeKey 返回 401)。全量后拿到了 controlKey,本机 /root/.amr/config.json。 测了实测行为(两个端点各 200,约 1.3s): wallet/balance → balanceUsd / expiresAt / workspaceId billing/summary → creditsPerUsd / membershipTier / subscriptionCreditsUsd / totalConsumedUsd 等 实现要点: - controlKey 走插件自己的 prices 表(插件系统本就按插件持久化一张 prices,与 state 分离),用 PUT /api/plugins/quota/state 设置; base_url 保持推理主机,adapter 内部改写 amr-link→amr-api。 - 本账户 billingState=free,没有 codingPlan.windows,所以不设 rep.windows:页面显示"无周期数据",而不是编一个 100%。 - creditsPerUsd 缺失时置 credits_rate_missing 并跳过积分换算,不默认 10000;*Usd 字段是权威值,直接采用。 - 401 归为 error(凭据要重登),不是 skip(无计费端点),两者修复动作 不同。 - skip 时把原因带上:区分「未配置凭据」和「上游无该端点」。 新增 7 条判据 + 7 个变异全部杀死(含一条"skip 丢原因")。变异 harness 第一轮 6/6 时 no-cred-guard 存活,原因是该测试没设 api_base,探测被 出站白名单拦掉、与缺少凭据无法区分 —— 判据失效而非代码问题,已修。 --- internal/lua/plugins/quota.lua | 182 ++++++++++- internal/lua/quota_opendesign_test.go | 426 ++++++++++++++++++++++++++ 2 files changed, 606 insertions(+), 2 deletions(-) create mode 100644 internal/lua/quota_opendesign_test.go diff --git a/internal/lua/plugins/quota.lua b/internal/lua/plugins/quota.lua index 191d62c..990046f 100644 --- a/internal/lua/plugins/quota.lua +++ b/internal/lua/plugins/quota.lua @@ -105,6 +105,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 +231,156 @@ local function openaiDashboard(base, key) return rep, 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 = nil + if type(cfg) == "table" then api = cfg.api_base end + if type(api) ~= "string" or api == "" then + local origin = base:gsub("^https?://", ""):gsub("/+$", "") + origin = origin:gsub("^amr%-link%.", "amr-api.") + api = "https://" .. origin .. "/api/v1" + end + api = api:gsub("/+$", "") + + 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 +454,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 @@ -485,7 +647,15 @@ local function collectSource(src, budget) return { name = src.name, vendor = "", error = tostring(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 + return out end rep.name = src.name rep.host = hostOf(base) @@ -911,8 +1081,16 @@ PLUGIN.ui = { ' 个源正在退避(连续探测失败,等下次重试):'+escH(backing.map(function(r){return r.name;}).join("、"))+''; } 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+='
未列出 '+silent.length+ - ' 个源(未暴露余额接口):'+escH(silent.map(function(r){return r.name;}).join("、"))+'
'; + ' 个源:'+escH(why)+''; } } } diff --git a/internal/lua/quota_opendesign_test.go b/internal/lua/quota_opendesign_test.go new file mode 100644 index 0000000..3ed105d --- /dev/null +++ b/internal/lua/quota_opendesign_test.go @@ -0,0 +1,426 @@ +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. +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. +func TestQuotaOpenDesignRejectedControlKeyIsAnError(t *testing.T) { + srv := odServer(t, nil, `{"error":"unauthenticated"}`, `{"error":"unauthenticated"}`, + http.StatusUnauthorized, http.StatusUnauthorized) + defer srv.Close() + + ps, _ := odPlugins(t) + if err := ps.SetState("quota", map[string]interface{}{ + "prices": map[string]interface{}{ + "control_key": "vela_ctrl_WRONG", + "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") + } + 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. + 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) + } + 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 would probe the inference host, which answers +// no billing route at all, and every OpenDesign source would silently skip. +func TestQuotaOpenDesignHostRewritingIsUsed(t *testing.T) { + var paths []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + _, _ = w.Write([]byte(odWallet)) + })) + defer srv.Close() + + ps, src := odPlugins(t) + // api_base unset, so the adapter derives the host from base_url. The + // derived host (amr-api.open-design.ai) is not reachable from a test, so + // assert on the SOURCE instead — this is the guard that survives without a + // network. + _ = srv + if !strings.Contains(src, `origin:gsub("^amr%-link%.", "amr-api.")`) { + t.Fatal("quota.lua no longer rewrites amr-link.* to amr-api.* — every " + + "OpenDesign source would probe the inference host, which serves no " + + "billing route, and skip") + } + _ = ps +}