mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
插件出站白名单 = 已配置源的 host。这条规则对每个 adapter 都成立,直到
OpenDesign —— 它把推理和计费拆在两个域上:推理 amr-link,计费 amr-api。
adapter 从推理主机推导计费主机,于是它请求的 host 从来不是操作员配过的
那个,请求在离开进程前就被拒,报"不是允许的出站目标",读起来像插件 bug。
core.sourceHosts 现在同时加入派生主机,且仅当源本身就在 amr-link 上
(没有 OpenDesign 源的部署不会凭空获得这条授权)。
派生规则前后端各存一份,因为 Go 建白名单、Lua 建 URL,两边不一致的表现是
「请求被拒」而不是「命名不匹配的错误」。规则改成了完整主机名匹配:
`amr-link.` 前缀写法会把 amr-link.example.com 也改写成 OpenDesign 地址。
顺带修掉一个真缺陷:Lua 侧先 `gsub("/+$")` 再匹配主机名,而 base_url 是
`https://amr-link.open-design.ai/v1` —— 带路径时整串拿去匹配 `^...$`
永远不命中,改写静默失效,探测打到推理主机。改成先切路径。
这个缺陷是「行为断言」抓到的:上一版判据 grep 源码里的 gsub 模式,而
gsub 模式在改前改后都"看起来对"。8/8 变异全杀。
(cherry picked from commit 4a63b9dc0b)
452 lines
18 KiB
Go
452 lines
18 KiB
Go
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 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.
|
|
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.
|
|
if err := ps.SetState("quota", map[string]interface{}{
|
|
"prices": map[string]interface{}{"control_key": "vela_ctrl_TESTKEY"},
|
|
}); 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 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
|
|
}
|
|
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)
|
|
}
|
|
}
|