feat(quota): 接入 OpenDesign/Vela 余额探测(controlKey)

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,探测被
出站白名单拦掉、与缺少凭据无法区分 —— 判据失效而非代码问题,已修。

(cherry picked from commit 75a0d247a4)
This commit is contained in:
JianFeeeee
2026-10-06 23:43:30 +08:00
parent 0e747b0891
commit d92db3a9fd
2 changed files with 606 additions and 2 deletions

View File

@ -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("、"))+'</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>';
}
}
}