mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 17:57:23 +00:00
Compare commits
11 Commits
fix/channe
...
v1.9.4
| Author | SHA1 | Date | |
|---|---|---|---|
| 37545b6dbb | |||
| 314f4da8f2 | |||
| b93799a919 | |||
| c36200b78b | |||
| ba6de89d57 | |||
| 3b28fde22e | |||
| 9823773330 | |||
| 5eef5ad635 | |||
| 40636abcc2 | |||
| 22c9215b02 | |||
| e76e1623e4 |
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "modelrouter-gui",
|
||||
"version": "1.9.3",
|
||||
"version": "1.9.4",
|
||||
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
|
||||
"author": "ModelRouter",
|
||||
"main": "main.js",
|
||||
|
||||
@ -195,6 +195,36 @@ func applyRule(prices map[string]interface{}, rule *config.BillingRule, srcName
|
||||
if t.CacheDiscount != nil {
|
||||
entry["cache_discount"] = *t.CacheDiscount
|
||||
}
|
||||
// reasoning is optional and stays ABSENT (not nil, not 0) when the
|
||||
// rule does not set it, which is what makes the billing plugin skip
|
||||
// the repricing branch entirely. Writing an explicit 0 here would
|
||||
// mean "thinking is free" and silently under-bill every request
|
||||
// from a provider that bills it like ordinary output.
|
||||
if strings.TrimSpace(t.Reasoning) != "" {
|
||||
rp, err := usdPerM(t.Reasoning)
|
||||
if err != nil {
|
||||
return fmt.Errorf("model %q reasoning: %w", modelID, err)
|
||||
}
|
||||
entry["reasoning_price"] = rp
|
||||
}
|
||||
if lc := t.LongContext; lc != nil {
|
||||
if lc.Above <= 0 {
|
||||
return fmt.Errorf("model %q long_context: above must be > 0", modelID)
|
||||
}
|
||||
above, err := longContextRate(lc, prompt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("model %q long_context: %w", modelID, err)
|
||||
}
|
||||
entry["long_context"] = map[string]interface{}{
|
||||
"above": lc.Above,
|
||||
// Marginal, not blended. The plugin charges the tokens past the
|
||||
// threshold at this rate and the rest at the base rate, so the
|
||||
// threshold composes with a cache discount (which applies to the
|
||||
// cached portion) instead of forcing the plugin to guess how
|
||||
// the two interact.
|
||||
"prompt": above,
|
||||
}
|
||||
}
|
||||
if rule.Peak != nil {
|
||||
entry["peak"] = compilePeak(rule.Peak)
|
||||
}
|
||||
@ -262,3 +292,32 @@ func compilePeak(p *config.BillingPeak) map[string]interface{} {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// longContextRate resolves the marginal per-token rate for the tokens ABOVE a
|
||||
// threshold, given the model's base per-million prompt price.
|
||||
//
|
||||
// Two ways to express it are supported because providers publish both: an
|
||||
// absolute rate (OpenAI quotes \$3.00/M past 200k) and a multiple (DeepSeek
|
||||
// quotes exactly 2x past 128k). Neither may be missing: a step with no rate is a
|
||||
// silent no-op, and an operator who wrote one meant something.
|
||||
func longContextRate(lc *config.BillingLongContext, basePerToken float64) (float64, error) {
|
||||
hasPrompt := strings.TrimSpace(lc.Prompt) != ""
|
||||
hasMult := lc.Multiplier > 0
|
||||
switch {
|
||||
case hasPrompt && hasMult:
|
||||
p, err := usdPerM(lc.Prompt)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Both given: the absolute rate wins, because it is the one the provider
|
||||
// actually quotes. Silently multiplying instead would produce a number
|
||||
// the operator never wrote.
|
||||
return p, nil
|
||||
case hasPrompt:
|
||||
return usdPerM(lc.Prompt)
|
||||
case hasMult:
|
||||
return basePerToken * lc.Multiplier, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("needs either prompt or multiplier")
|
||||
}
|
||||
}
|
||||
|
||||
@ -86,3 +86,129 @@ func TestSpecificURLTokenRuleStillMarksItsSource(t *testing.T) {
|
||||
t.Error("an unrelated source was marked priced by a specific-url rule")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReasoningPriceIsOmittedUnlessSet pins the absence convention for the
|
||||
// thinking-token rate.
|
||||
//
|
||||
// A rule that says nothing about reasoning must produce a price entry with NO
|
||||
// reasoning_price key, so the billing plugin skips the repricing branch and
|
||||
// bills thinking at the ordinary output rate — which is what every provider
|
||||
// actually charges. Writing an explicit 0 instead would read as "thinking is
|
||||
// free" and silently under-bill every request from that model.
|
||||
func TestReasoningPriceIsOmittedUnlessSet(t *testing.T) {
|
||||
sources := []config.Source{{Name: "up", BaseURL: "https://up.example/v1"}}
|
||||
|
||||
// Case 1: no reasoning declared → the key must not appear.
|
||||
p1 := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{
|
||||
URL: "https://up.example/v1", Mode: "token",
|
||||
Models: map[string]config.BillingToken{
|
||||
"m": {Prompt: "1.00", Completion: "2.00"},
|
||||
},
|
||||
}}}
|
||||
prices, err := Compile(p1, sources)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry := prices["models"].(map[string]interface{})["m"].(map[string]interface{})
|
||||
if _, present := entry["reasoning_price"]; present {
|
||||
t.Errorf("reasoning_price present (%v) without a rule; the plugin would treat 0 as free",
|
||||
entry["reasoning_price"])
|
||||
}
|
||||
|
||||
// Case 2: declared → present, and converted to per-token like every other rate.
|
||||
p2 := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{
|
||||
URL: "https://up.example/v1", Mode: "token",
|
||||
Models: map[string]config.BillingToken{
|
||||
"m": {Prompt: "1.00", Completion: "2.00", Reasoning: "6.00"},
|
||||
},
|
||||
}}}
|
||||
prices2, err := Compile(p2, sources)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e2 := prices2["models"].(map[string]interface{})["m"].(map[string]interface{})
|
||||
rp, ok := e2["reasoning_price"].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("reasoning_price missing or wrong type: %#v", e2["reasoning_price"])
|
||||
}
|
||||
if rp != 6e-6 {
|
||||
t.Errorf("reasoning_price = %v, want 6e-6 (USD per TOKEN)", rp)
|
||||
}
|
||||
// A malformed rate must be rejected, not silently ignored: an operator who
|
||||
// typed "6 USD" expects either that value or an error, never a rule that
|
||||
// looks applied and bills nothing.
|
||||
p3 := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{
|
||||
URL: "https://up.example/v1", Mode: "token",
|
||||
Models: map[string]config.BillingToken{
|
||||
"m": {Prompt: "1.00", Completion: "2.00", Reasoning: "six dollars"},
|
||||
},
|
||||
}}}
|
||||
if _, err := Compile(p3, sources); err == nil {
|
||||
t.Error("a non-numeric reasoning rate must fail the compile")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLongContextStepCompiles pins the long-context step's two accepted forms
|
||||
// and its rejection of a step that would silently do nothing.
|
||||
//
|
||||
// Providers publish both shapes — DeepSeek says "2x past 128k", OpenAI quotes
|
||||
// an absolute "$3.00/M past 200k" — so both must compile. Neither may be
|
||||
// missing: a step with no rate is a rule that LOOKS like it handles long
|
||||
// context and does not, which under-bills without any visible sign.
|
||||
func TestLongContextStepCompiles(t *testing.T) {
|
||||
sources := []config.Source{{Name: "up", BaseURL: "https://up.example/v1"}}
|
||||
|
||||
compile := func(lc config.BillingLongContext) (map[string]interface{}, error) {
|
||||
p := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{
|
||||
URL: "https://up.example/v1", Mode: "token",
|
||||
Models: map[string]config.BillingToken{
|
||||
"m": {Prompt: "1.00", Completion: "2.00", LongContext: &lc},
|
||||
},
|
||||
}}}
|
||||
prices, err := Compile(p, sources)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e := prices["models"].(map[string]interface{})["m"].(map[string]interface{})
|
||||
return e["long_context"].(map[string]interface{}), nil
|
||||
}
|
||||
|
||||
// Absolute rate (OpenAI's published shape).
|
||||
lc1, err := compile(config.BillingLongContext{Above: 200000, Prompt: "3.00"})
|
||||
if err != nil {
|
||||
t.Fatalf("absolute long_context rate rejected: %v", err)
|
||||
}
|
||||
if lc1["above"] != 200000 || lc1["prompt"] != 3e-6 {
|
||||
t.Errorf("absolute step = %#v, want above=200000 prompt=3e-6", lc1)
|
||||
}
|
||||
|
||||
// Multiplier (DeepSeek's shape): 2x of the 1.00/M base.
|
||||
lc2, err := compile(config.BillingLongContext{Above: 128000, Multiplier: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("multiplier long_context rejected: %v", err)
|
||||
}
|
||||
if lc2["prompt"] != 2e-6 {
|
||||
t.Errorf("multiplier step = %#v, want prompt=2e-6", lc2)
|
||||
}
|
||||
|
||||
// Both given: the absolute rate wins, because that is the number the
|
||||
// provider quotes. Multiplying instead would produce a figure the operator
|
||||
// never wrote.
|
||||
lc3, err := compile(config.BillingLongContext{Above: 128000, Prompt: "5.00", Multiplier: 2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lc3["prompt"] != 5e-6 {
|
||||
t.Errorf("both given = %#v, want the quoted absolute rate 5e-6", lc3)
|
||||
}
|
||||
|
||||
// Neither: a no-op step must fail, not compile into a rule that silently
|
||||
// bills long context at the base rate.
|
||||
if _, err := compile(config.BillingLongContext{Above: 128000}); err == nil {
|
||||
t.Error("a step with neither prompt nor multiplier must be rejected")
|
||||
}
|
||||
// A zero threshold would make every request "long".
|
||||
if _, err := compile(config.BillingLongContext{Above: 0, Multiplier: 2}); err == nil {
|
||||
t.Error("above=0 must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,6 +39,15 @@ type Config struct {
|
||||
// so peak traffic was billed at off-peak rates — no error, the bill just
|
||||
// quietly halved. A schema-checked config file catches both at load.
|
||||
BillingDSL *BillingDSL `yaml:"billing,omitempty"`
|
||||
// PluginVisibility overrides the audience a plugin declared for itself,
|
||||
// keyed by plugin name: "admin" hides a plugin (and its state) from user
|
||||
// keys, "all" widens one that shipped admin-only, "user,admin" names roles
|
||||
// explicitly. An absent name leaves the plugin's own declaration in force.
|
||||
//
|
||||
// This exists because a plugin author cannot know the operator's policy, and
|
||||
// the operator should not have to edit a bundled .lua to change one — the
|
||||
// next upgrade overwrites it.
|
||||
PluginVisibility map[string]string `yaml:"plugin_visibility,omitempty" json:"plugin_visibility,omitempty"`
|
||||
// box seals credentials (sources' api_key/headers, keys' key) at rest.
|
||||
// In-memory values are always plaintext; only the bytes on disk are sealed.
|
||||
// Wired by AttachSecretBox — Load leaves it nil so `-check` and tests stay
|
||||
@ -444,6 +453,19 @@ type BillingToken struct {
|
||||
Prompt string `yaml:"prompt" json:"prompt"` // USD per million tokens, e.g. "0.15"
|
||||
Completion string `yaml:"completion" json:"completion"` // USD per million tokens
|
||||
CacheDiscount *float64 `yaml:"cache_discount,omitempty" json:"cache_discount,omitempty"`
|
||||
// Reasoning is the USD-per-million rate for thinking tokens, for the rare
|
||||
// provider that charges them differently from ordinary output. Omit it and
|
||||
// reasoning is billed at Completion — which is what OpenAI, DeepSeek and
|
||||
// Anthropic all do, and what every source on this deployment reports.
|
||||
//
|
||||
// It is a rate for a SUBSET of completion tokens, never an extra charge: the
|
||||
// billing plugin applies only the difference between this and Completion, so
|
||||
// setting it to a multiple of Completion reprices the thinking share rather
|
||||
// than billing it twice.
|
||||
Reasoning string `yaml:"reasoning,omitempty" json:"reasoning,omitempty"`
|
||||
// LongContext is the prompt-price step above a token threshold. Nil = one
|
||||
// flat rate, which is what a rule means when it says nothing.
|
||||
LongContext *BillingLongContext `yaml:"long_context,omitempty" json:"long_context,omitempty"`
|
||||
}
|
||||
|
||||
// BillingPeak declares peak-hour pricing.
|
||||
@ -455,6 +477,31 @@ type BillingPeak struct {
|
||||
Hours [][2]int `yaml:"hours,omitempty" json:"hours,omitempty"`
|
||||
}
|
||||
|
||||
// BillingLongContext is a prompt-price step for long inputs.
|
||||
//
|
||||
// Every major provider charges the input tokens ABOVE a threshold at a higher
|
||||
// rate — DeepSeek doubles past 128k, OpenAI and Anthropic step up as well. A
|
||||
// flat prompt price therefore under-bills exactly the traffic an agent workload
|
||||
// generates, and it under-bills it silently: the number is plausible, the bill
|
||||
// adds up, and nothing says the rate was wrong.
|
||||
//
|
||||
// The step applies to the PROMPT only. Output and cache reads are billed at
|
||||
// their own rates regardless of input length, which is how providers publish
|
||||
// it — the higher rate is for the extra context you made the model read, not for
|
||||
// what it wrote back.
|
||||
type BillingLongContext struct {
|
||||
// Above is the token count the step starts at. A request with exactly this
|
||||
// many prompt tokens is still at the base rate; the step covers the tokens
|
||||
// BEYOND it. Inclusive-below matches how the providers document it.
|
||||
Above int `yaml:"above" json:"above"`
|
||||
// Prompt is the USD-per-million rate for the tokens above Above.
|
||||
Prompt string `yaml:"prompt" json:"prompt"`
|
||||
// Multiplier is an alternative to Prompt: a factor applied to the base rate
|
||||
// (DeepSeek's published step is exactly 2x). When both are set Prompt wins,
|
||||
// and setting neither is a validation error rather than a silent no-op.
|
||||
Multiplier float64 `yaml:"multiplier,omitempty" json:"multiplier,omitempty"`
|
||||
}
|
||||
|
||||
// Validate rejects a BillingDSL that could not work as written.
|
||||
func (b *BillingDSL) Validate() error {
|
||||
if b == nil {
|
||||
|
||||
@ -11,6 +11,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@ -68,6 +69,29 @@ func NewFromConfig(cfg *config.Config) (*Core, error) {
|
||||
// to boot the gateway because a third-party .lua has a typo would be the
|
||||
// wrong trade.
|
||||
c.plugins = lua.NewPlugins(c.vm, cfg.PluginDir)
|
||||
// Plugin outbound HTTP is scoped to the hosts of configured sources. Set
|
||||
// before LoadDir so a plugin that requests on its first hook already sees
|
||||
// the real allowlist rather than the deny-all placeholder.
|
||||
c.plugins.SetOutboundHosts(sourceHosts(cfg))
|
||||
// Let the plugin layer validate access specs against the keys that exist.
|
||||
// A stored allowlist naming a key that does not exist refuses every caller,
|
||||
// which looks exactly like a broken endpoint.
|
||||
lua.SetKeyNameLookup(func() []string {
|
||||
// ListKeys is the structured view (bare `gateway_keys:` entries are
|
||||
// expanded into records with names); cfg.GatewayKeys is still []string.
|
||||
keys := c.ListKeys()
|
||||
out := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
if k.Name != "" {
|
||||
out = append(out, k.Name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
})
|
||||
// Operator overrides win over the plugin's own declared audience.
|
||||
for name, aud := range cfg.PluginVisibility {
|
||||
c.plugins.SetPluginVisibility(name, aud)
|
||||
}
|
||||
if err := c.plugins.SeedBundled(); err != nil {
|
||||
log.Printf("[core] seed bundled plugins: %v", err)
|
||||
}
|
||||
@ -615,9 +639,35 @@ func (c *Core) rebuildRegistry() error {
|
||||
c.buildAutoChain()
|
||||
c.buildAutoImageChain()
|
||||
c.rebuildKeyAutoChains()
|
||||
// A source edit can add or remove an upstream host, which changes what a
|
||||
// plugin is allowed to reach. Keeping this beside the chain rebuilds means
|
||||
// one call site owns "config changed, refresh derived state".
|
||||
c.plugins.SetOutboundHosts(sourceHosts(c.cfg))
|
||||
return nil
|
||||
}
|
||||
|
||||
// sourceHosts extracts the host of every configured source's base_url — the
|
||||
// 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.
|
||||
func sourceHosts(cfg *config.Config) []string {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(cfg.Sources))
|
||||
for _, s := range cfg.Sources {
|
||||
if s.BaseURL == "" {
|
||||
continue
|
||||
}
|
||||
u, err := url.Parse(s.BaseURL)
|
||||
if err != nil || u.Hostname() == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, u.Hostname())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@ -464,6 +464,13 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
"by_status": out.Status,
|
||||
"buckets": out.Bucket,
|
||||
"truncated": out.Truncated,
|
||||
// "Active requests" is a live gauge (requests in flight right now),
|
||||
// not a period aggregate. Omitting it from the period response made
|
||||
// the KPI read `st.active || 0` → 0 on every day/week/month view while
|
||||
// the all-time view showed the truth, which read as "the gateway is
|
||||
// idle" precisely when it was busiest. A calendar window cannot
|
||||
// filter it, so it travels with every response unfiltered.
|
||||
"active": g.stats.Active(),
|
||||
"records": page.Records,
|
||||
// The paging cursor has to travel with the first page, otherwise
|
||||
// the scroll handler has nothing to page from and the table is
|
||||
|
||||
@ -789,6 +789,18 @@ func recordChatUsage(rec *Req, req *types.ChatRequest, resp *types.UnifiedRespon
|
||||
// Cache accounting: whichever source format the adapter normalized into
|
||||
// (prompt_tokens_details.cached_tokens or legacy prompt_cache_hit_tokens),
|
||||
// read it back so the request record carries the hit/miss split.
|
||||
// reasoning_tokens is a SUBSET of completion_tokens and is billed at the
|
||||
// completion rate by every provider measured, so it must NOT be added to
|
||||
// Compl. Recording it separately is what lets a price rule split thinking
|
||||
// from output for the providers that do price them differently, and what
|
||||
// keeps the billing plugin from guessing.
|
||||
//
|
||||
// Leaving it unrecorded is worse than useless: the adapters already parse it
|
||||
// and forward it (opencodego/gemini/anthropic all do), so the number exists
|
||||
// in the response and is thrown away at the one place that could keep it.
|
||||
if d := resp.TokenUsage.CompletionTokensDetails; d != nil && d.ReasoningTokens > 0 {
|
||||
rec.Reasoning = int64(d.ReasoningTokens)
|
||||
}
|
||||
if d := resp.TokenUsage.PromptTokensDetails; d != nil && d.CachedTokens > 0 {
|
||||
rec.CacheHit = int64(d.CachedTokens)
|
||||
}
|
||||
@ -1044,6 +1056,9 @@ func (g *Gateway) fireEnd(rec *Req) {
|
||||
// carried this (rec.CacheReported); the plugin payload did not, so any
|
||||
// plugin-level cache accounting had to guess.
|
||||
"cache_reported": rec.CacheReported,
|
||||
// A SUBSET of completion_tokens, not an extra. Billing plugins that
|
||||
// add it to completion would double-charge every thinking token.
|
||||
"reasoning_tokens": rec.Reasoning,
|
||||
"image_count": rec.ImageCount,
|
||||
"error": rec.Err,
|
||||
"time": rec.Time,
|
||||
|
||||
71
internal/gateway/chat_reasoning_test.go
Normal file
71
internal/gateway/chat_reasoning_test.go
Normal file
@ -0,0 +1,71 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/types"
|
||||
)
|
||||
|
||||
// TestRecordChatUsageKeepsReasoningTokens pins the reasoning-token plumbing.
|
||||
//
|
||||
// The adapters already parse completion_tokens_details.reasoning_tokens and
|
||||
// forward it (opencodego, gemini, anthropic all do), and types.go has carried
|
||||
// the field since it was defined — but nothing ever READ it. The number arrived
|
||||
// in the response and was discarded at the one place that could have kept it,
|
||||
// so a pricing rule had no way to split thinking from output.
|
||||
//
|
||||
// The property that matters most: reasoning is a SUBSET of completion. Every
|
||||
// provider measured bills it at the completion rate, so a billing plugin that
|
||||
// added it to completion_tokens would double-charge every thinking token. The
|
||||
// test asserts completion is left alone.
|
||||
func TestRecordChatUsageKeepsReasoningTokens(t *testing.T) {
|
||||
rec := &Req{}
|
||||
resp := &types.UnifiedResponse{
|
||||
TokenUsage: types.TokenUsage{
|
||||
Prompt: 100,
|
||||
Completion: 500,
|
||||
CompletionTokensDetails: &types.CompletionTokensDetails{
|
||||
ReasoningTokens: 320,
|
||||
},
|
||||
},
|
||||
}
|
||||
recordChatUsage(rec, &types.ChatRequest{}, resp)
|
||||
|
||||
if rec.Reasoning != 320 {
|
||||
t.Errorf("Reasoning = %d, want 320", rec.Reasoning)
|
||||
}
|
||||
// The subset must not be folded into completion — it is already counted there.
|
||||
if rec.Compl != 500 {
|
||||
t.Errorf("Compl = %d, want 500 (reasoning is a subset, never additive)", rec.Compl)
|
||||
}
|
||||
if rec.Prompt != 100 {
|
||||
t.Errorf("Prompt = %d, want 100", rec.Prompt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordChatUsageReasoningAbsentStaysZero: "not reported" and "reported as
|
||||
// zero" must both leave the field at 0, and neither may invent a number. A
|
||||
// plugin reading Reasoning cannot tell them apart, which is why the field is
|
||||
// documented as "0 means not reported" rather than "0 means no thinking".
|
||||
func TestRecordChatUsageReasoningAbsentStaysZero(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
u types.TokenUsage
|
||||
}{
|
||||
{"no details object", types.TokenUsage{Prompt: 10, Completion: 20}},
|
||||
{"details without reasoning", types.TokenUsage{
|
||||
Prompt: 10,
|
||||
Completion: 20,
|
||||
CompletionTokensDetails: &types.CompletionTokensDetails{},
|
||||
}},
|
||||
} {
|
||||
rec := &Req{}
|
||||
recordChatUsage(rec, &types.ChatRequest{}, &types.UnifiedResponse{TokenUsage: tc.u})
|
||||
if rec.Reasoning != 0 {
|
||||
t.Errorf("%s: Reasoning = %d, want 0", tc.name, rec.Reasoning)
|
||||
}
|
||||
if rec.Compl != 20 {
|
||||
t.Errorf("%s: Compl = %d, want 20", tc.name, rec.Compl)
|
||||
}
|
||||
}
|
||||
}
|
||||
150
internal/gateway/plugin_call_api_test.go
Normal file
150
internal/gateway/plugin_call_api_test.go
Normal file
@ -0,0 +1,150 @@
|
||||
package gateway
|
||||
|
||||
// The /api/plugins/{name}/call/{fn} endpoint splits its path with string
|
||||
// surgery, and the split point is easy to get wrong in a way that still
|
||||
// "works": keeping the "call/" segment in the function name produced
|
||||
// `does not export "call/collect_account"` — the plugin was correct, the router
|
||||
// was not, and every call 502'd while the exports list looked perfectly fine.
|
||||
//
|
||||
// These tests drive the real router, so a refactor that reintroduces the
|
||||
// off-by-one is caught here rather than in production.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/core"
|
||||
)
|
||||
|
||||
// callPlugin exposes exactly one export plus a function that is NOT exported.
|
||||
// If the exports gate is not honoured, secretFn is reachable and with it the
|
||||
// plugin's private state — for a plugin holding an API key that means handing
|
||||
// the key to anyone who can POST to the gateway.
|
||||
const callPlugin = `
|
||||
local P = {}
|
||||
P.name = "calltest"
|
||||
P.version = "1.0.0"
|
||||
P.exports = { "add" }
|
||||
P.secret = "do-not-leak"
|
||||
function P.add(a)
|
||||
return { result = (tonumber(a and a.x) or 0) + 1 }
|
||||
end
|
||||
function P.secretFn()
|
||||
return { leaked = P.secret }
|
||||
end
|
||||
return P
|
||||
`
|
||||
|
||||
func gatewayWithCallPlugin(t *testing.T) *Gateway {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.yaml")
|
||||
body := "listen: :0\n" +
|
||||
"adapter_dir: " + filepath.Join(dir, "adapters") + "\n" +
|
||||
"plugin_dir: " + filepath.Join(dir, "plugins") + "\n" +
|
||||
"runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" +
|
||||
"gateway_keys:\n - sk-test\n"
|
||||
if err := os.WriteFile(cfgPath, []byte(body), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := core.NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("core: %v", err)
|
||||
}
|
||||
t.Cleanup(c.Close)
|
||||
if err := c.Plugins().LoadSource("calltest", callPlugin); err != nil {
|
||||
t.Fatalf("load calltest: %v", err)
|
||||
}
|
||||
g, err := New(c)
|
||||
if err != nil {
|
||||
t.Fatalf("gateway: %v", err)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
func TestPluginCallSplitsFunctionNameCorrectly(t *testing.T) {
|
||||
g := gatewayWithCallPlugin(t)
|
||||
rr := doReq(t, g, http.MethodPost,
|
||||
"/api/plugins/calltest/call/add", `{"x":41}`)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("call failed: HTTP %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var out struct {
|
||||
Result struct {
|
||||
Result int `json:"result"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decode: %v (body=%s)", err, rr.Body.String())
|
||||
}
|
||||
// 41 + 1 == 42. Reaching 42 proves BOTH halves of the contract: the "/call/"
|
||||
// segment did not leak into the function name, and the payload arrived.
|
||||
if out.Result.Result != 42 {
|
||||
t.Fatalf("got %d, want 42 — either the /call/ segment leaked into the "+
|
||||
"function name or the argument table never reached Lua", out.Result.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginCallRefusesUndeclaredFunction(t *testing.T) {
|
||||
g := gatewayWithCallPlugin(t)
|
||||
rr := doReq(t, g, http.MethodPost,
|
||||
"/api/plugins/calltest/call/secretFn", `{}`)
|
||||
if rr.Code == http.StatusOK {
|
||||
t.Fatalf("an unexported function must not be callable, got 200: %s",
|
||||
rr.Body.String())
|
||||
}
|
||||
if strings.Contains(rr.Body.String(), "do-not-leak") {
|
||||
t.Fatal("the refusal leaked the plugin's private state")
|
||||
}
|
||||
}
|
||||
|
||||
// A plugin that declares no exports at all has no callable surface. Reading the
|
||||
// missing list as "allow everything" would turn every plugin into a remote
|
||||
// evaluator the moment this endpoint ships.
|
||||
func TestPluginCallWithoutExportsHasNoCallableSurface(t *testing.T) {
|
||||
g := gatewayWithCallPlugin(t)
|
||||
const bare = `
|
||||
local P = {}
|
||||
P.name = "bare"
|
||||
P.version = "1.0.0"
|
||||
function P.anything()
|
||||
return { ok = true }
|
||||
end
|
||||
return P
|
||||
`
|
||||
if err := g.core.Plugins().LoadSource("bare", bare); err != nil {
|
||||
t.Fatalf("load bare: %v", err)
|
||||
}
|
||||
rr := doReq(t, g, http.MethodPost,
|
||||
"/api/plugins/bare/call/anything", `{}`)
|
||||
if rr.Code == http.StatusOK {
|
||||
t.Fatalf("a plugin with no plugin.exports must expose nothing, got 200: %s",
|
||||
rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginCallIsAdminOnly(t *testing.T) {
|
||||
g := gatewayWithCallPlugin(t)
|
||||
// A real user-role key, not a bare one from config: sk-test/sk-user in the
|
||||
// config both default to admin, so a test using them would pass the gate for
|
||||
// the wrong reason and prove nothing.
|
||||
rec, err := g.core.CreateKey("viewer", "user", nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rr := doReqAs(t, g, rec.Key, http.MethodPost,
|
||||
"/api/plugins/calltest/call/add", `{"x":1}`)
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("a non-admin key reached the call endpoint: HTTP %d %s",
|
||||
rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
207
internal/gateway/plugin_http_routes.go
Normal file
207
internal/gateway/plugin_http_routes.go
Normal file
@ -0,0 +1,207 @@
|
||||
package gateway
|
||||
|
||||
// Mount for plugin-registered HTTP endpoints.
|
||||
//
|
||||
// The gateway owns the listener, the authentication, the role check and the
|
||||
// response framing; the plugin owns the logic. That split is the point: a plugin
|
||||
// cannot open a socket, cannot bypass auth, and cannot choose a path outside
|
||||
// /api/plugins/http/.
|
||||
//
|
||||
// Why this exists rather than "plugins can call each other": some things are
|
||||
// worth querying from OUTSIDE the browser — a CI job asking whether the upstream
|
||||
// balance is above zero, a monitoring probe, another tool on the box. billing
|
||||
// never wanted this (it only receives hooks); a quota plugin does. Hard-coding a
|
||||
// route per plugin would mean a gateway change for every such plugin, and an
|
||||
// arbitrary plugin's code in the main mux is not something the routing table
|
||||
// should ever grow.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// pluginHTTPPrefix is the mount point. It is deliberately narrow: a plugin's
|
||||
// routes live under one prefix, which makes them greppable, makes them easy to
|
||||
// revoke wholesale, and means a plugin cannot shadow /api/keys or /api/stats.
|
||||
const pluginHTTPPrefix = "/api/plugins/http/"
|
||||
|
||||
// handlePluginHTTP dispatches a request to whichever plugin registered the
|
||||
// longest matching path.
|
||||
//
|
||||
// Longest-match rather than first-match: a plugin may register /health and
|
||||
// another /health/deep, and both must work. With registration order deciding it,
|
||||
// the outcome would depend on load order and change when a plugin is added.
|
||||
func (g *Gateway) handlePluginHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
rest := strings.TrimPrefix(r.URL.Path, pluginHTTPPrefix)
|
||||
ps := g.core.Plugins()
|
||||
if ps == nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "no plugins loaded")
|
||||
return
|
||||
}
|
||||
|
||||
matches := ps.HTTPHandlers()
|
||||
var chosen *lua.HTTPHandler
|
||||
var params map[string]string
|
||||
for i := range matches {
|
||||
h := matches[i]
|
||||
if p, ok := matchHandlerPath(h.Path, rest); ok {
|
||||
if chosen == nil || segmentCount(h.Path) > segmentCount(chosen.Path) {
|
||||
chosen, params = &h, p
|
||||
}
|
||||
}
|
||||
}
|
||||
if chosen == nil {
|
||||
writeError(w, http.StatusNotFound, "not_found",
|
||||
"no plugin serves this path")
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.EqualFold(r.Method, chosen.Method) {
|
||||
w.Header().Set("Allow", chosen.Method)
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed",
|
||||
"this endpoint accepts "+chosen.Method+" only")
|
||||
return
|
||||
}
|
||||
|
||||
role := reqRole(r.Context())
|
||||
if !chosen.Allows(role, reqKeyName(r.Context())) {
|
||||
writeError(w, http.StatusForbidden, "forbidden",
|
||||
handlerAccessHint(*chosen))
|
||||
return
|
||||
}
|
||||
|
||||
body, err := readBody(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
req := &lua.HandlerRequest{
|
||||
Method: r.Method,
|
||||
Path: rest,
|
||||
Query: flattenQuery(r.URL.Query()),
|
||||
Params: params,
|
||||
Headers: flattenHeader(r.Header),
|
||||
Body: body,
|
||||
Role: role,
|
||||
Key: reqKey(r.Context()),
|
||||
}
|
||||
resp, err := ps.InvokeHandler(handlerOwner(chosen), chosen.Handler, req)
|
||||
if err != nil {
|
||||
// A plugin error is a 502, not a 500: the gateway is fine, the plugin is
|
||||
// not, and the operator needs to tell those apart in their logs.
|
||||
writeError(w, http.StatusBadGateway, "plugin_error", err.Error())
|
||||
return
|
||||
}
|
||||
lua.WriteHandlerResponse(w, resp)
|
||||
}
|
||||
|
||||
// handlerAccessHint explains, in the refusal, exactly which declaration would
|
||||
// open this endpoint. A 403 with no reason is the kind of thing an operator
|
||||
// opens the source to work out, and then the fix is a guess.
|
||||
func handlerAccessHint(h lua.HTTPHandler) string {
|
||||
switch {
|
||||
case len(h.Keys) > 0:
|
||||
return "this endpoint allows only the named keys (" +
|
||||
strings.Join(h.Keys, ", ") + "); authenticate with one of them"
|
||||
case len(h.Roles) > 0:
|
||||
return "this endpoint requires role " + strings.Join(h.Roles, " or ") +
|
||||
" (widen it in the plugin's http declaration, or set public=true)"
|
||||
default:
|
||||
return "this endpoint declares no roles, so it is admin-only; " +
|
||||
"set roles=… or keys=… in the plugin's http declaration"
|
||||
}
|
||||
}
|
||||
|
||||
// handlerOwner returns the plugin name that registered h. The gateway stores the
|
||||
// owner alongside the handler precisely so this does not have to be guessed from
|
||||
// the path prefix.
|
||||
func handlerOwner(h *lua.HTTPHandler) string { return h.Plugin }
|
||||
|
||||
// segmentCount is how deep a route is, used for longest-match. A path with more
|
||||
// segments wins, so /health/deep beats /health when both are registered.
|
||||
func segmentCount(p string) int { return strings.Count(p, "/") }
|
||||
|
||||
// matchHandlerPath matches a route pattern against a concrete path, capturing
|
||||
// ":name" segments.
|
||||
//
|
||||
// Returns ok=false on a segment-count mismatch or a missing value for a
|
||||
// param — a route must not match with an empty capture, or two different URLs
|
||||
// would reach the same handler indistinguishably.
|
||||
func matchHandlerPath(pattern, path string) (map[string]string, bool) {
|
||||
pp := strings.Split(strings.Trim(pattern, "/"), "/")
|
||||
sp := strings.Split(strings.Trim(path, "/"), "/")
|
||||
if len(pp) != len(sp) {
|
||||
return nil, false
|
||||
}
|
||||
var params map[string]string
|
||||
for i := range pp {
|
||||
if strings.HasPrefix(pp[i], ":") {
|
||||
name := pp[i][1:]
|
||||
if sp[i] == "" {
|
||||
return nil, false
|
||||
}
|
||||
if params == nil {
|
||||
params = map[string]string{}
|
||||
}
|
||||
params[name] = sp[i]
|
||||
continue
|
||||
}
|
||||
if pp[i] != sp[i] {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return params, true
|
||||
}
|
||||
|
||||
func flattenQuery(q map[string][]string) map[string]string {
|
||||
out := make(map[string]string, len(q))
|
||||
for k, v := range q {
|
||||
if len(v) > 0 {
|
||||
out[k] = v[0]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func flattenHeader(h http.Header) map[string]string {
|
||||
out := make(map[string]string, len(h))
|
||||
for k := range h {
|
||||
out[strings.ToLower(k)] = h.Get(k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func readBody(r *http.Request) (string, error) {
|
||||
if r.Body == nil {
|
||||
return "", nil
|
||||
}
|
||||
defer r.Body.Close()
|
||||
// A cap, because the handler runs inside the gateway process: an unbounded
|
||||
// body from an authenticated caller is a memory exhaustion vector.
|
||||
const maxBody = 4 << 20
|
||||
buf := make([]byte, 0, 4096)
|
||||
tmp := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := r.Body.Read(tmp)
|
||||
if n > 0 {
|
||||
if len(buf)+n > maxBody {
|
||||
return "", &bodyTooLarge{}
|
||||
}
|
||||
buf = append(buf, tmp[:n]...)
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
|
||||
type bodyTooLarge struct{}
|
||||
|
||||
func (bodyTooLarge) Error() string { return "request body exceeds 4 MiB" }
|
||||
|
||||
// limitBody is kept for the numeric form some callers want in a log line.
|
||||
var _ = strconv.Itoa
|
||||
@ -93,6 +93,30 @@ func (g *Gateway) handlePluginsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// /api/plugins/{name}/http/{path}/access — the operator's view of, and edit
|
||||
// surface for, which keys an endpoint answers.
|
||||
if i := strings.Index(path, "/http/"); i >= 0 {
|
||||
name := path[:i]
|
||||
rest := path[i+len("/http/"):]
|
||||
if strings.HasSuffix(rest, "/access") {
|
||||
epPath := strings.TrimSuffix(rest, "/access")
|
||||
g.handleEndpointAccess(w, r, name, epPath)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// /api/plugins/{name}/call/{fn}
|
||||
if i := strings.Index(path, "/call/"); i >= 0 {
|
||||
name := path[:i]
|
||||
fn := path[i+len("/call/"):]
|
||||
if name == "" || fn == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "call needs a plugin and a function name")
|
||||
return
|
||||
}
|
||||
g.handlePluginCall(w, r, name, fn)
|
||||
return
|
||||
}
|
||||
|
||||
if reqRole(r.Context()) != "admin" {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
||||
return
|
||||
@ -106,6 +130,11 @@ func (g *Gateway) handlePluginsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
"on_disk": ps.OnDisk(),
|
||||
"hook_errors": ps.HookErrors(),
|
||||
"plugin_dir": g.pluginDir(),
|
||||
// Visibility overrides and the endpoint access table, so the
|
||||
// plugin page renders what is IN FORCE rather than making the
|
||||
// operator read .lua files or config.yaml to find out.
|
||||
"visibility": ps.VisibilityOverrides(),
|
||||
"http_access": ps.EndpointAccess(),
|
||||
})
|
||||
case http.MethodPost:
|
||||
var body struct {
|
||||
@ -211,15 +240,33 @@ func (g *Gateway) handlePluginState(w http.ResponseWriter, r *http.Request, name
|
||||
ps := g.core.Plugins()
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
// Any role may read: plugin state is reporting data (cost, counts),
|
||||
// and the caller has already been authenticated. Admin-only would stop
|
||||
// a user key's own billing widget from rendering.
|
||||
// The plugin's audience gates its DATA as well as its page. Hiding the UI
|
||||
// while leaving GET /state open would put the numbers one curl away, which
|
||||
// is the leak the audience field exists to close — and billing's state is
|
||||
// a global ledger, not the caller's own usage.
|
||||
//
|
||||
// A plugin that declares no audience is unchanged (every role may read
|
||||
// it), so a single-purpose widget keeps working without edits.
|
||||
role := reqRole(r.Context())
|
||||
if !ps.StateVisibleTo(name, role) {
|
||||
writeError(w, http.StatusForbidden, "forbidden",
|
||||
"plugin "+name+" is not visible to role "+role)
|
||||
return
|
||||
}
|
||||
// prices rides along: the billing rules editor must show the table
|
||||
// that is actually in effect, not a reconstruction from config. It is
|
||||
// absent for plugins with no prices (every plugin but billing).
|
||||
state := ps.State(name)
|
||||
// A plugin may SPLIT its state: global buckets stay deployment-wide while
|
||||
// per-caller buckets are narrowed to the authenticated key. Role
|
||||
// visibility alone cannot express this — the same role is allowed to read
|
||||
// the plugin, just not another caller's row.
|
||||
if scope := ps.StateScopeFor(name); scope != nil && role != "admin" {
|
||||
state = splitStateForKey(state, scope, reqKey(r.Context()))
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"plugin": name,
|
||||
"state": ps.State(name),
|
||||
"state": state,
|
||||
}
|
||||
if pr := ps.Prices(name); pr != nil {
|
||||
payload["prices"] = pr
|
||||
@ -245,6 +292,45 @@ func (g *Gateway) handlePluginState(w http.ResponseWriter, r *http.Request, name
|
||||
}
|
||||
}
|
||||
|
||||
// Call invokes a named function on a plugin's Lua state and returns its result.
|
||||
//
|
||||
// This exists because plugin pages otherwise have NO way to reach their own
|
||||
// plugin code: pluginAPI offers state read/write and a couple of callbacks, and
|
||||
// that is enough for billing (whose numbers arrive on hooks during request
|
||||
// forwarding) but useless for a plugin that must do work on demand. A quota
|
||||
// page has to ask the vendor when the operator clicks Refresh, and no hook
|
||||
// fires at that moment.
|
||||
//
|
||||
// It is deliberately narrow:
|
||||
// - The function must be one the plugin explicitly lists in plugin.exports.
|
||||
// Anything else is refused, so this endpoint cannot become a generic "run
|
||||
// arbitrary Lua with my credentials" oracle over a plugin's private state.
|
||||
// - The caller must be admin, and the result is JSON — no Lua value is ever
|
||||
// handed back to Go as a live closure.
|
||||
// - Errors come back as (nil, message) from the plugin, matching every other
|
||||
// plugin entry point, and surface as a 502 rather than raising.
|
||||
func (g *Gateway) handlePluginCall(w http.ResponseWriter, r *http.Request, name, fn string) {
|
||||
if reqRole(r.Context()) != "admin" {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
|
||||
return
|
||||
}
|
||||
var args map[string]interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
return
|
||||
}
|
||||
result, err := g.core.Plugins().Call(name, fn, args)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "plugin_error", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"result": result})
|
||||
}
|
||||
|
||||
func (g *Gateway) pluginDir() string {
|
||||
if c := g.core.Config(); c != nil {
|
||||
return c.PluginDir
|
||||
@ -282,3 +368,178 @@ func (g *Gateway) removePlugin(name string) error {
|
||||
_ = os.Remove(filepath.Join(g.pluginDir(), name+".lua"))
|
||||
return g.core.Plugins().Unload(name)
|
||||
}
|
||||
|
||||
|
||||
// handleEndpointAccess serves the operator's view and edit surface for one
|
||||
// plugin endpoint's reachable keys.
|
||||
//
|
||||
// GET …/http/{path}/access the declared rules plus any override
|
||||
// PUT …/http/{path}/access body {"access": "admin,@homeagent"}
|
||||
// DELETE …/http/{path}/access clear the override
|
||||
//
|
||||
// The spec string is a comma list: a bare word is a role, "@name" is a gateway
|
||||
// key's configured name, and the literal "public" opens it to any
|
||||
// authenticated key. That is the same grammar the visibility override uses, and
|
||||
// keeping one grammar means an operator learns it once.
|
||||
//
|
||||
// Key names are validated against the configured keys BEFORE being stored. An
|
||||
// allowlist naming a key that does not exist is a silent no-op — every caller
|
||||
// is refused — and the operator would have no way to tell that apart from the
|
||||
// endpoint simply working.
|
||||
func (g *Gateway) handleEndpointAccess(w http.ResponseWriter, r *http.Request, name, epPath string) {
|
||||
if reqRole(r.Context()) != "admin" {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
||||
return
|
||||
}
|
||||
ps := g.core.Plugins()
|
||||
|
||||
// epPath "*" is the WHOLE-PLUGIN visibility override, not an endpoint. It
|
||||
// shares this route (and therefore this store) on purpose: an operator
|
||||
// setting "who can see this plugin" and "who can call this endpoint" is
|
||||
// asking the same question at two granularities, and two stores would let
|
||||
// them disagree. SetEndpointAccess cannot serve it — it validates that the
|
||||
// endpoint exists, which "*" by definition does not.
|
||||
if epPath == pluginWholeOverride {
|
||||
g.handlePluginVisibility(w, r, name)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
for _, e := range ps.EndpointAccess() {
|
||||
if e.Plugin == name && e.Path == epPath {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"endpoint": e})
|
||||
return
|
||||
}
|
||||
}
|
||||
writeError(w, http.StatusNotFound, "not_found", "no such endpoint")
|
||||
case http.MethodPut, http.MethodDelete:
|
||||
spec := ""
|
||||
if r.Method == http.MethodPut {
|
||||
var body struct {
|
||||
Access string `json:"access"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
return
|
||||
}
|
||||
spec = body.Access
|
||||
}
|
||||
if err := ps.ValidateAccessSpec(spec); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
if err := ps.SetEndpointAccess(name, epPath, spec); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "access": spec})
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// splitStateForKey rewrites a plugin state so that a non-admin caller sees only
|
||||
// the buckets that belong to them.
|
||||
//
|
||||
// The plugin declares which buckets are global and which are per-key. A per-key
|
||||
// bucket is keyed by the MASKED key ("***a1b2c3" — the same value the audit log
|
||||
// uses), so the caller's own row is found by masking the presented secret and
|
||||
// matching the last six characters.
|
||||
//
|
||||
// Keys that do not match are DROPPED, not zeroed: a zeroed row would render as
|
||||
// "you spent nothing" and, worse, would leak that a row exists for someone else.
|
||||
func splitStateForKey(state interface{}, scope *lua.StateScope, key string) interface{} {
|
||||
m, ok := state.(map[string]interface{})
|
||||
if !ok || scope == nil || key == "" {
|
||||
return state
|
||||
}
|
||||
masked := maskKey(key)
|
||||
out := make(map[string]interface{}, len(m))
|
||||
for k, v := range m {
|
||||
if !scope.IsGlobal(k) {
|
||||
// Per-key bucket: keep only the caller's rows, at every nesting level
|
||||
// the scope mentions (by_day_key is {day: {masked: bucket}}).
|
||||
out[k] = filterKeyBuckets(v, masked, scope.DepthOf(k))
|
||||
continue
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// filterKeyBuckets narrows a keyed structure to one masked key.
|
||||
//
|
||||
// depth 1 is {masked: bucket}; depth 2 is {day: {masked: bucket}}. The level is
|
||||
// declared rather than sniffed, because both shapes are dicts and sniffing would
|
||||
// confuse an empty bucket with a nested one.
|
||||
func filterKeyBuckets(v interface{}, masked string, depth int) interface{} {
|
||||
switch depth {
|
||||
case 0:
|
||||
return v // not a per-key bucket; leave alone
|
||||
case 1:
|
||||
m, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
return v
|
||||
}
|
||||
if row, ok := m[masked]; ok {
|
||||
return map[string]interface{}{masked: row}
|
||||
}
|
||||
return map[string]interface{}{}
|
||||
default:
|
||||
m, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
return v
|
||||
}
|
||||
out := make(map[string]interface{}, len(m))
|
||||
for day, inner := range m {
|
||||
im, ok := inner.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if row, ok := im[masked]; ok {
|
||||
out[day] = map[string]interface{}{masked: row}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
// pluginWholeOverride is the epPath that addresses a plugin's overall visibility
|
||||
// rather than one of its endpoints.
|
||||
const pluginWholeOverride = "*"
|
||||
|
||||
// handlePluginVisibility is the whole-plugin half of the access API. The
|
||||
// audience string uses the same vocabulary as an endpoint spec (roles, @key
|
||||
// names, public) so the UI can drive both from one input.
|
||||
func (g *Gateway) handlePluginVisibility(w http.ResponseWriter, r *http.Request, name string) {
|
||||
ps := g.core.Plugins()
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"plugin": name,
|
||||
"visibility": ps.VisibilityOverrides()[name],
|
||||
})
|
||||
case http.MethodPut, http.MethodDelete:
|
||||
spec := ""
|
||||
if r.Method == http.MethodPut {
|
||||
var body struct {
|
||||
Access string `json:"access"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
return
|
||||
}
|
||||
spec = body.Access
|
||||
}
|
||||
if err := ps.ValidateAccessSpec(spec); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
ps.SetPluginVisibility(name, spec)
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "visibility": spec})
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method+" is not allowed here")
|
||||
}
|
||||
}
|
||||
|
||||
@ -210,3 +210,54 @@ func TestPluginStatePUTIsAdminOnly(t *testing.T) {
|
||||
t.Errorf("admin PUT state = %d: %s", adm.Code, adm.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginsListCarriesAccessTables: the plugin page renders visibility
|
||||
// overrides and the endpoint access table from /api/plugins. Without them in the
|
||||
// response the operator has to open a .lua or config.yaml to learn who can see
|
||||
// what, which is exactly what the UI is meant to avoid.
|
||||
func TestPluginsListCarriesAccessTables(t *testing.T) {
|
||||
g := gatewayWithCallPlugin(t)
|
||||
rr := doReq(t, g, http.MethodGet, "/api/plugins", "")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET /api/plugins = %d", rr.Code)
|
||||
}
|
||||
var out struct {
|
||||
Visibility map[string]string `json:"visibility"`
|
||||
HTTPAccess []map[string]interface{} `json:"http_access"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decode: %v (body=%s)", err, rr.Body.String())
|
||||
}
|
||||
// The calltest plugin registers no endpoints, so the table is present but
|
||||
// empty — the KEY is that the field exists at all. A missing field decodes to
|
||||
// nil and the UI silently renders nothing, which looks identical to "this
|
||||
// plugin has no endpoints".
|
||||
if !strings.Contains(rr.Body.String(), "http_access") {
|
||||
t.Error("/api/plugins response lacks the http_access field")
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), "visibility") {
|
||||
t.Error("/api/plugins response lacks the visibility field")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginWholeVisibilityViaAccessAPI covers the "*" path: the plugin page
|
||||
// edits a plugin's overall audience through the same route it edits endpoints
|
||||
// with. Before this existed the UI had a visibility selector whose save 404'd,
|
||||
// because SetEndpointAccess validates that the endpoint exists and "*" does not.
|
||||
func TestPluginWholeVisibilityViaAccessAPI(t *testing.T) {
|
||||
g := gatewayWithCallPlugin(t)
|
||||
rr := doReq(t, g, http.MethodPut, "/api/plugins/calltest/http/*/access",
|
||||
`{"access":"admin, user"}`)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("PUT */access = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), `"ok":true`) {
|
||||
t.Errorf("unexpected body: %s", rr.Body.String())
|
||||
}
|
||||
// A user role is a legitimate audience; a nonsense one is not.
|
||||
bad := doReq(t, g, http.MethodPut, "/api/plugins/calltest/http/*/access",
|
||||
`{"access":"../../etc"}`)
|
||||
if bad.Code != http.StatusBadRequest {
|
||||
t.Errorf("traversal-shaped audience = %d, want 400: %s", bad.Code, bad.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@ -235,6 +235,10 @@ func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) {
|
||||
// treats anything after the name as a plugin sub-resource and would
|
||||
// 404 on "rules" instead of reaching the billing editor.
|
||||
g.handleBillingRules(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, pluginHTTPPrefix):
|
||||
// Before the generic plugin router: these paths belong to a PLUGIN, not to
|
||||
// the plugin manager, and "http" is a valid sub-resource name otherwise.
|
||||
g.handlePluginHTTP(w, r)
|
||||
case r.URL.Path == "/api/plugins" || strings.HasPrefix(r.URL.Path, "/api/plugins/"):
|
||||
g.handlePluginsAPI(w, r)
|
||||
case r.URL.Path == "/api/chat":
|
||||
@ -305,20 +309,43 @@ func (g *Gateway) auth(next http.Handler) http.Handler {
|
||||
http.Redirect(w, r, "/login?continue="+url.QueryEscape(r.URL.Path), http.StatusFound)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(withAuth(r.Context(), key, rec.Role)))
|
||||
next.ServeHTTP(w, r.WithContext(withAuthNamed(r.Context(), key, rec.Role, rec.Name)))
|
||||
})
|
||||
}
|
||||
|
||||
// authCtx carries the authenticated gateway key and its role.
|
||||
// authCtx carries the authenticated gateway key, its role, and the key's
|
||||
// configured NAME.
|
||||
//
|
||||
// The name is here because plugin HTTP endpoints authorise by key name rather
|
||||
// than by secret, and looking the name up from the key at the point of use would
|
||||
// mean reading config on every request. It is "" for a key configured without
|
||||
// one, which such an endpoint can never match.
|
||||
type authCtx struct {
|
||||
key string
|
||||
role string
|
||||
name string
|
||||
}
|
||||
|
||||
type authCtxKeyT struct{}
|
||||
|
||||
func withAuth(ctx context.Context, key, role string) context.Context {
|
||||
return context.WithValue(ctx, authCtxKeyT{}, authCtx{key: key, role: role})
|
||||
return withAuthNamed(ctx, key, role, "")
|
||||
}
|
||||
|
||||
// withAuthNamed is withAuth plus the key's configured name.
|
||||
func withAuthNamed(ctx context.Context, key, role, name string) context.Context {
|
||||
return context.WithValue(ctx, authCtxKeyT{}, authCtx{key: key, role: role, name: name})
|
||||
}
|
||||
|
||||
// reqKeyName returns the authenticated key's configured name, or "" if it has
|
||||
// none. Plugin endpoint authorisation is written against this, not against the
|
||||
// secret: a secret is rotated without notice, so a rule keyed on it would
|
||||
// silently stop matching.
|
||||
func reqKeyName(ctx context.Context) string {
|
||||
if a, ok := ctx.Value(authCtxKeyT{}).(authCtx); ok {
|
||||
return a.name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// reqKey returns the authenticated gateway key id (masked suffix for display).
|
||||
|
||||
@ -43,6 +43,16 @@ type Req struct {
|
||||
// accounting at all (even when the hit count is 0). The WebUI shows
|
||||
// "0%" instead of "—" for such rows.
|
||||
CacheReported bool `json:"cache_reported,omitempty"`
|
||||
// Reasoning is completion_tokens_details.reasoning_tokens.
|
||||
//
|
||||
// It is a SUBSET of Compl, not an addition: reasoning tokens are billed at
|
||||
// the completion rate and already counted there. This field exists so a
|
||||
// pricing rule can split them when a provider prices thinking tokens
|
||||
// differently (most do NOT — OpenAI and DeepSeek bill them as ordinary
|
||||
// output), and so a reader can see what fraction of a bill is thinking.
|
||||
// 0 means "not reported", which is NOT the same as "no reasoning happened" —
|
||||
// three of the sources measured return no such field at all.
|
||||
Reasoning int64 `json:"reasoning_tokens,omitempty"`
|
||||
OK bool `json:"ok"`
|
||||
// Status http status code
|
||||
Status int `json:"status"`
|
||||
@ -1211,3 +1221,16 @@ func (s *Stats) Snapshot(limit int, key string) map[string]interface{} {
|
||||
"replay_partial": s.replayPartial,
|
||||
}
|
||||
}
|
||||
|
||||
// Active reports requests in flight right now.
|
||||
//
|
||||
// It is a live gauge, not an aggregate: there is no calendar period that scopes
|
||||
// it, which is why the period-scoped /api/stats response has to carry it
|
||||
// explicitly. Before it did, the day/week/month views fed `st.active || 0` a
|
||||
// missing key and showed a hard 0 — the same gateway that was serving hundreds
|
||||
// of requests a second looked idle in three of the four views.
|
||||
func (s *Stats) Active() int64 {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.active
|
||||
}
|
||||
|
||||
@ -401,3 +401,34 @@ func TestPeriodWeekTotalCanExceedMonthTotal(t *testing.T) {
|
||||
t.Fatal("today must be contained in both the week and the month totals")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPeriodStatsCarryActiveGauge pins the live "active requests" gauge onto
|
||||
// every period response.
|
||||
//
|
||||
// It is a live gauge, not an aggregate, so no calendar window can scope it — but
|
||||
// the period response is what the day/week/month views render from, and it used
|
||||
// to omit the field entirely. The UI then evaluated `st.active || 0` and printed
|
||||
// a hard 0 in three of four views, while the all-time view (whose response did
|
||||
// carry the field) showed the truth. Reading "0 active" off a busy gateway is
|
||||
// worse than reading nothing: it is a confident wrong answer.
|
||||
func TestPeriodStatsCarryActiveGauge(t *testing.T) {
|
||||
for _, period := range []string{"day", "week", "month", "all"} {
|
||||
g := newTestGateway(t)
|
||||
rr := doReq(t, g, http.MethodGet, "/api/stats?period="+period, "")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("period=%s: %d", period, rr.Code)
|
||||
}
|
||||
var out map[string]interface{}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("period=%s decode: %v", period, err)
|
||||
}
|
||||
v, ok := out["active"]
|
||||
if !ok {
|
||||
t.Errorf("period=%s response has no active field; the UI renders 0", period)
|
||||
continue
|
||||
}
|
||||
if _, ok := v.(float64); !ok {
|
||||
t.Errorf("period=%s active is %T, want a number", period, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -815,8 +815,21 @@
|
||||
plTitle: "插件",
|
||||
plState: "状态",
|
||||
plStages: "阶段",
|
||||
plActive: "启用中",
|
||||
plDisabled: "已禁用",
|
||||
|
||||
plVisibility: "可见性",
|
||||
plEndpoints: "端点",
|
||||
plAccessTitle: "访问控制",
|
||||
plAccessHint: "填写允许访问的角色或密钥(@名称)。留空=仅 admin。填 public=任何人。指定 @密钥 时只有该密钥能访问,不受角色规则影响。",
|
||||
plWholePlugin: "以下端点",
|
||||
plKeyHint: "可用密钥名",
|
||||
plAccessSave: "保存访问控制", plActive: "启用中",
|
||||
|
||||
tVisDefault: "跟随插件",
|
||||
tVisAdmin: "仅 admin",
|
||||
tVisRoles: "admin + user",
|
||||
tVisAll: "所有人",
|
||||
plOverridden: "已覆盖插件声明",
|
||||
plEpsN: "%d 个端点", plDisabled: "已禁用",
|
||||
plBroken: "加载失败",
|
||||
plBuiltin: "内置",
|
||||
plHooks: "个阶段",
|
||||
@ -1090,8 +1103,21 @@
|
||||
plTitle: "Plugins",
|
||||
plState: "State",
|
||||
plStages: "Stages",
|
||||
plActive: "Active",
|
||||
plDisabled: "Disabled",
|
||||
|
||||
plVisibility: "Visibility",
|
||||
plEndpoints: "Endpoints",
|
||||
plAccessTitle: "Access control",
|
||||
plAccessHint: "Roles or keys (@name) allowed to reach each endpoint. Empty = admin only. public = anyone. Listing @keys replaces the role rules entirely.",
|
||||
plWholePlugin: "endpoints below",
|
||||
plKeyHint: "Key names",
|
||||
plAccessSave: "Save access", plActive: "Active",
|
||||
|
||||
tVisDefault: "follow plugin",
|
||||
tVisAdmin: "admin only",
|
||||
tVisRoles: "admin + user",
|
||||
tVisAll: "everyone",
|
||||
plOverridden: "overrides plugin",
|
||||
plEpsN: "%d endpoints", plDisabled: "Disabled",
|
||||
plBroken: "Failed to load",
|
||||
plBuiltin: "Built-in",
|
||||
plHooks: "stages",
|
||||
@ -1994,7 +2020,12 @@
|
||||
const e = $(sel);
|
||||
if (e) e.textContent = txt;
|
||||
};
|
||||
kset('[data-kpi="active"]', st.active || 0);
|
||||
// No `|| 0` fallback: that fallback is what turned a MISSING field
|
||||
// into a confident "0 active" on three of four views. If the server
|
||||
// ever stops sending it again, an em dash says "unknown" and the
|
||||
// regression is visible, instead of the gauge quietly claiming the
|
||||
// gateway is idle while it serves traffic.
|
||||
kset('[data-kpi="active"]', st.active === undefined ? "—" : fmtN(st.active));
|
||||
kset('[data-kpi="reqs"]', fmtN(tot.reqs));
|
||||
const rsub = $('[data-kpi="reqs-sub"]');
|
||||
if (rsub)
|
||||
@ -4473,6 +4504,8 @@ function afterChainEdit() {
|
||||
<td><b>${esc(p.name)}</b>${builtin}<div class="muted">${esc(p.description || p.version || "")}</div>${err}</td>
|
||||
<td>${state}</td>
|
||||
<td>${hooks}</td>
|
||||
<td>${plVisCell(j, p)}</td>
|
||||
<td>${plEpsCell(j, p)}</td>
|
||||
<td style="white-space:nowrap">
|
||||
<button class="small" onclick="editPlugin('${escAttr(p.name)}')">${t("plEdit")}</button>
|
||||
${toggle}
|
||||
@ -4494,8 +4527,9 @@ function afterChainEdit() {
|
||||
$("#tab-plugins").innerHTML = `
|
||||
<div class="card"><h2>${t("plTitle")} (${(j.on_disk || []).length})</h2>
|
||||
<div class="tbl-wrap"><table><tr>
|
||||
<th>${t("tName")}</th><th>${t("plState")}</th><th>${t("plStages")}</th><th></th>
|
||||
</tr>${rows || `<tr><td colspan="4" class="empty">${t("plEmpty")}</td></tr>`}</table></div>
|
||||
<th>${t("tName")}</th><th>${t("plState")}</th><th>${t("plStages")}</th><th>${t("plVisibility")}</th><th>${t("plEndpoints")}</th><th></th>
|
||||
</tr>${rows || `<tr><td colspan="6" class="empty">${t("plEmpty")}</td></tr>`}</table></div>
|
||||
<div id="pl-access"></div>
|
||||
${errList ? `<p class="muted" style="color:var(--danger)">${t("plHookErr")}</p><ul style="font-size:12px">${errList}</ul>` : ""}
|
||||
<p class="muted">${t("plDir")} <code>${esc(j.plugin_dir)}</code></p>
|
||||
</div>
|
||||
@ -4508,6 +4542,161 @@ function afterChainEdit() {
|
||||
<p><button onclick="installPlugin()">${t("plInstallBtn")}</button></p>
|
||||
</div>`;
|
||||
bindDropzone("pl-dz", "pl-file", "#pl-name", "#pl-code");
|
||||
renderPluginAccess(j);
|
||||
}
|
||||
|
||||
|
||||
const PL_VIS = [
|
||||
["", "tVisDefault"],
|
||||
["admin", "tVisAdmin"],
|
||||
["user,admin", "tVisRoles"],
|
||||
["all", "tVisAll"],
|
||||
];
|
||||
|
||||
// plVisCell shows the plugin's own declaration next to any operator
|
||||
// override, because "admin-only" means something different depending on
|
||||
// whether the plugin chose it or the deployment imposed it. Editing goes
|
||||
// through the same access API as endpoints so there is one place where
|
||||
// visibility is stored.
|
||||
function plVisCell(j, p) {
|
||||
const decl = (p.ui && p.ui.visibility) || "";
|
||||
const ov = (j.visibility || {})[p.name] || "";
|
||||
const cur = ov || decl;
|
||||
// Compare as SETS. The server stores whatever order the operator typed
|
||||
// and we round-trip it verbatim, so "admin, user" must still light up a
|
||||
// choice — otherwise the selector silently shows "follow plugin", and
|
||||
// saving from there writes an empty override that WIDENS access back to
|
||||
// the plugin's own declaration.
|
||||
const norm = (v) =>
|
||||
(v || "")
|
||||
.split(",")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.join(",");
|
||||
const curN = norm(cur);
|
||||
// An audience with no matching choice (one naming a @key, say) is offered
|
||||
// as itself so the value in force is never hidden from the operator.
|
||||
const opts = PL_VIS.some((c) => norm(c[0]) === curN)
|
||||
? PL_VIS
|
||||
: PL_VIS.concat([[cur, "plCustomVis"]]);
|
||||
return `<select data-plvis="${escAttr(p.name)}" style="font-size:11.5px;max-width:180px">
|
||||
${opts
|
||||
.map(
|
||||
(c) =>
|
||||
`<option value="${c[0]}"${norm(c[0]) === curN ? " selected" : ""}>${
|
||||
c[1] === "plCustomVis" ? "自定义: " + cur : t(c[1])
|
||||
}</option>`,
|
||||
)
|
||||
.join("")}
|
||||
</select>${
|
||||
ov && decl && ov !== decl
|
||||
? `<div class="muted" style="font-size:10.5px" title="${esc(decl)}">${t("plOverridden")}</div>`
|
||||
: ""
|
||||
}`;
|
||||
}
|
||||
|
||||
function plEpsCell(j, p) {
|
||||
const n = (j.http_access || []).filter((e) => e.plugin === p.name).length;
|
||||
return n
|
||||
? `<a href="#" onclick="document.getElementById('pl-access').scrollIntoView({behavior:'smooth'});return false">${t("plEpsN").replace("%d", n)}</a>`
|
||||
: `<span class="muted">—</span>`;
|
||||
}
|
||||
|
||||
// ---- visibility & endpoint access ----
|
||||
//
|
||||
// The plugin page is where an operator edits who can see what. The
|
||||
// alternatives are editing a bundled .lua — overwritten on the next
|
||||
// upgrade — or hand-editing config.yaml, with no feedback about what is
|
||||
// actually in force. Each row shows the rule next to whether it was
|
||||
// DECLARED by the plugin or OVERRIDDEN here, because "admin-only" means
|
||||
// something different depending on which.
|
||||
function renderPluginAccess(j) {
|
||||
const host = $("#pl-access");
|
||||
if (!host) return;
|
||||
const eps = j.http_access || [];
|
||||
if (!eps.length) { host.innerHTML = ""; return; }
|
||||
const keys = (window.__gwKeyRows || []).map((k) => k.name).filter(Boolean);
|
||||
const byPlugin = {};
|
||||
eps.forEach((e) => { (byPlugin[e.plugin] = byPlugin[e.plugin] || []).push(e); });
|
||||
let h = `<div class="card" style="margin-top:12px"><h2>${t("plAccessTitle")}</h2>
|
||||
<p class="muted" style="font-size:12px">${t("plAccessHint")}</p>`;
|
||||
Object.keys(byPlugin).forEach((pn) => {
|
||||
h += `<div style="margin:0 0 14px">
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 6px">
|
||||
<b style="font-size:13px">${esc(pn)}</b>
|
||||
<span class="muted" style="font-size:11.5px">${t("plWholePlugin")}</span>
|
||||
</div>`;
|
||||
(byPlugin[pn] || []).forEach((e) => {
|
||||
const ovr = (e.override_keys && e.override_keys.length) ||
|
||||
(e.override_roles && e.override_roles.length);
|
||||
const cur = ovr
|
||||
? [...(e.override_keys || []).map((k) => "@" + k), ...(e.override_roles || [])].join(",")
|
||||
: [...(e.keys || []).map((k) => "@" + k), ...(e.roles || []), e.public ? "public" : ""]
|
||||
.filter(Boolean).join(",") || "admin";
|
||||
h += `<div style="margin:6px 0 0;padding-left:10px;border-left:2px solid var(--bd)">
|
||||
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
|
||||
<code style="font-size:11.5px">${esc(e.method)} ${esc(e.full_path)}</code>
|
||||
<span class="muted" style="font-size:11px">→ ${esc(e.handler)}()</span>
|
||||
${e.description ? `<span class="muted" style="font-size:11px">${esc(e.description)}</span>` : ""}
|
||||
<span class="grow"></span>
|
||||
<input data-ep="${esc(pn)}|${esc(e.path)}" value="${esc(cur)}"
|
||||
style="max-width:340px;font-size:11.5px"
|
||||
placeholder="admin, user, @homeagent, public">
|
||||
</div>
|
||||
${keys.length ? `<div style="font-size:11px;opacity:.7;margin-top:3px">${t("plKeyHint")}: ${esc(keys.join(", "))}</div>` : ""}
|
||||
</div>`;
|
||||
});
|
||||
h += `</div>`;
|
||||
});
|
||||
h += `<p><button id="pl-access-save" onclick="savePluginAccess(this)">${t("plAccessSave")}</button></p></div>`;
|
||||
host.innerHTML = h;
|
||||
}
|
||||
|
||||
async function savePluginAccess(btn) {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
// Whole-plugin visibility first. Sent through the SAME access API as
|
||||
// endpoints (path "*") so visibility has exactly one storage path.
|
||||
const viss = Array.prototype.slice.call(
|
||||
document.querySelectorAll("[data-plvis]"));
|
||||
for (const sel of viss) {
|
||||
const pn = sel.getAttribute("data-plvis");
|
||||
const url =
|
||||
"/api/plugins/" + encodeURIComponent(pn) + "/http/*/access";
|
||||
const v = sel.value;
|
||||
if (v) {
|
||||
await api(url, { method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ access: v }) });
|
||||
} else {
|
||||
await api(url, { method: "DELETE" });
|
||||
}
|
||||
}
|
||||
const inputs = Array.prototype.slice.call(
|
||||
document.querySelectorAll("#pl-access [data-ep]"));
|
||||
for (const inp of inputs) {
|
||||
const raw = inp.getAttribute("data-ep");
|
||||
const sep = raw.indexOf("|");
|
||||
const pn = raw.slice(0, sep), ep = raw.slice(sep + 1);
|
||||
const url = "/api/plugins/" + encodeURIComponent(pn) + "/http/" +
|
||||
encodeURIComponent(ep) + "/access";
|
||||
const v = inp.value.trim();
|
||||
if (v) {
|
||||
await api(url, { method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ access: v }) });
|
||||
} else {
|
||||
await api(url, { method: "DELETE" });
|
||||
}
|
||||
}
|
||||
toast(t("toastSaved"));
|
||||
renderPlugins();
|
||||
} catch (e) {
|
||||
toast(tFmt("toastSaveFail", e.message));
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePlugin(name, disabled) {
|
||||
@ -5570,6 +5759,28 @@ toast(t("kAutoChainGlobal"));
|
||||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error?.message || r.status);
|
||||
return true;
|
||||
},
|
||||
// call invokes one of the plugin's OWN exported functions. Billing never
|
||||
// needs this — its numbers arrive on hooks while the gateway forwards a
|
||||
// request — but a plugin that must do work on demand (asking a vendor
|
||||
// for a balance when the operator clicks Refresh) has no other way to
|
||||
// reach its own code: no hook fires on a click.
|
||||
//
|
||||
// Admin-only server side, and the plugin must have listed the function in
|
||||
// plugin.exports, so this cannot be used to reach a plugin's internals.
|
||||
async call(name, fn, args) {
|
||||
const r = await fetch(
|
||||
"/api/plugins/" + encodeURIComponent(name) + "/call/" + encodeURIComponent(fn),
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(args || {}),
|
||||
},
|
||||
);
|
||||
const body = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(body.error?.message || r.status);
|
||||
return body.result;
|
||||
},
|
||||
onTabShown(fn) {
|
||||
PLUGIN_TAB_CBS.__last = PLUGIN_TAB_CBS.__last || [];
|
||||
PLUGIN_TAB_CBS.__last.push(fn);
|
||||
|
||||
@ -388,3 +388,26 @@ func TestPluginSidebarEntryCarriesATitle(t *testing.T) {
|
||||
t.Errorf("plugin sidebar button renders the icon only; a label is required. "+
|
||||
"got: %.200s", stmt)
|
||||
}
|
||||
|
||||
// TestPluginPageHasAccessEditor: the plugin page must let an operator configure
|
||||
// endpoint access. The APIs existed with zero UI entry point, which meant the
|
||||
// only way to change who could reach a plugin endpoint was to hand-edit
|
||||
// config.yaml — the thing the UI is meant to replace.
|
||||
func TestPluginPageHasAccessEditor(t *testing.T) {
|
||||
src := uiSource(t)
|
||||
for _, needle := range []string{
|
||||
`renderPluginAccess`, // called after rendering the plugin list
|
||||
`savePluginAccess`, // wired to a button
|
||||
`/access`, // hits the per-endpoint access API
|
||||
`id="pl-access"`, // container exists
|
||||
`http_access`, // reads the table from /api/plugins
|
||||
`data-ep=`, // one input per endpoint
|
||||
`data-plvis=`, // one visibility selector per plugin
|
||||
`/http/*/access`, // whole-plugin visibility shares the access API
|
||||
`plVisCell`, // renders the declared-vs-override distinction
|
||||
} {
|
||||
if !strings.Contains(src, needle) {
|
||||
t.Errorf("plugin page lacks %q", needle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,12 +2,25 @@ package lua
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// pluginLoaded reports whether a plugin with this name is present, ignoring any
|
||||
// LoadError: a plugin that is present but broken still counts as "loaded here",
|
||||
// and the caller decides whether the error matters.
|
||||
func pluginLoaded(ps *Plugins, name string) bool {
|
||||
for _, p := range ps.List() {
|
||||
if fmt.Sprint(p["name"]) == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// The billing plugin ships with the gateway, so its arithmetic is a contract:
|
||||
// a wrong price silently produces wrong money. These tests drive it through the
|
||||
// real hook path and check the NUMBERS, not merely that it loads.
|
||||
@ -447,8 +460,16 @@ return plugin`)
|
||||
}
|
||||
|
||||
// Order must be honoured so the sidebar is predictable.
|
||||
if len(ui.Pages) != 4 {
|
||||
t.Fatalf("expected all 4 pages to merge, got %d: %v", len(ui.Pages), got)
|
||||
//
|
||||
// The page COUNT is not asserted: it used to be 4, and it grew every time a
|
||||
// bundled plugin added a page. A hard count here reads as "the merge is
|
||||
// broken" whenever the product gains a feature, which trains the reader to
|
||||
// bump a number instead of reading the failure. What matters is that all
|
||||
// three pages this test contributed merged, and that they came out sorted.
|
||||
for _, want := range []string{"third-a", "third-b", "other-page"} {
|
||||
if !got[want] {
|
||||
t.Errorf("page %s did not merge: %v", want, got)
|
||||
}
|
||||
}
|
||||
for i := 1; i < len(ui.Pages); i++ {
|
||||
if ui.Pages[i].Order < ui.Pages[i-1].Order {
|
||||
@ -462,8 +483,15 @@ return plugin`)
|
||||
// configuration, since seeding only happens on a fresh plugin dir.
|
||||
func TestBillingPluginLoadedByDefault(t *testing.T) {
|
||||
ps, pdir := billingVM(t)
|
||||
if ps.Count() != 1 {
|
||||
t.Fatalf("expected 1 bundled plugin, got %d", ps.Count())
|
||||
// billing must be among the bundled plugins, not the only one. The count
|
||||
// grows with every shipped plugin and asserting it makes this test a
|
||||
// changelog detector.
|
||||
if !pluginLoaded(ps, "billing") {
|
||||
var names []string
|
||||
for _, p := range ps.List() {
|
||||
names = append(names, fmt.Sprint(p["name"]))
|
||||
}
|
||||
t.Fatalf("billing is not loaded; bundled plugins are %v", names)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(pdir, "billing.lua")); err != nil {
|
||||
t.Errorf("billing.lua was not written to the plugin dir: %v", err)
|
||||
@ -966,3 +994,245 @@ func TestBillingCacheHitClampedInStats(t *testing.T) {
|
||||
tot["cache_fresh_tokens"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnpricedIsTrackedPerDay pins the day-keyed unpriced tally.
|
||||
//
|
||||
// The lifetime counters cannot answer the question an operator actually has —
|
||||
// "is it STILL happening?" — because they keep the debt from before a rule was
|
||||
// added. After pricing a mispriced source the lifetime number never moves, so a
|
||||
// dashboard reading "unpriced: 6376" is indistinguishable from a system that is
|
||||
// still billing nothing. Day buckets are what make a pricing fix observable: the
|
||||
// offending day keeps its count and the next day starts at zero for it.
|
||||
func TestUnpricedIsTrackedPerDay(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
// Nothing is priced in the default config, so every request is unpriced.
|
||||
// Two requests on two different UTC days.
|
||||
for _, ms := range []int64{1790000000000, 1790100000000} {
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "mystery-model", "source": "nowhere", "key": "***aaaaaa",
|
||||
"ok": true, "prompt_tokens": 10, "completion_tokens": 5,
|
||||
"time": ms,
|
||||
})
|
||||
}
|
||||
st := stateOf(t, ps)
|
||||
byDay, ok := st["unpriced_by_day"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("state has no unpriced_by_day map: %T", st["unpriced_by_day"])
|
||||
}
|
||||
if len(byDay) != 2 {
|
||||
t.Fatalf("expected 2 day buckets, got %d: %v", len(byDay), byDay)
|
||||
}
|
||||
var sum float64
|
||||
for day, bucket := range byDay {
|
||||
b := bucket.(map[string]interface{})
|
||||
sum += b["reqs"].(float64)
|
||||
models := b["models"].(map[string]interface{})
|
||||
if models["mystery-model"].(float64) != 1 {
|
||||
t.Errorf("%s: model tally = %v, want 1", day, models["mystery-model"])
|
||||
}
|
||||
}
|
||||
if sum != 2 {
|
||||
t.Errorf("day buckets sum to %v, want 2 (the lifetime counter must not be double-counted)", sum)
|
||||
}
|
||||
// The lifetime counters still exist and still agree — this is additive, not
|
||||
// a replacement.
|
||||
if st["unpriced_reqs"].(float64) != 2 {
|
||||
t.Errorf("unpriced_reqs = %v, want 2", st["unpriced_reqs"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestReasoningBilledAtCompletionRateByDefault is the property that keeps the
|
||||
// standard formula matching a real invoice.
|
||||
//
|
||||
// reasoning_tokens is a subset of completion_tokens and every provider measured
|
||||
// (OpenAI, DeepSeek, Anthropic) charges it at the ordinary output rate. A rule
|
||||
// with no reasoning_price must therefore produce EXACTLY the number the
|
||||
// pre-reasoning formula produced — the field exists for a provider that prices
|
||||
// thinking differently, not as a new charge.
|
||||
func TestReasoningBilledAtCompletionRateByDefault(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
if err := ps.SetState("billing", map[string]interface{}{
|
||||
"prices": map[string]interface{}{
|
||||
"currency": "USD",
|
||||
"models": map[string]interface{}{
|
||||
// USD PER TOKEN (compile.go divides the per-million figure by 1e6).
|
||||
// prompt 0.15/M, completion 0.60/M. No reasoning_price anywhere.
|
||||
"m": map[string]interface{}{"prompt": 0.15e-6, "completion": 0.60e-6},
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "m", "source": "s", "key": "***aaaaaa", "ok": true,
|
||||
"prompt_tokens": 1000, "completion_tokens": 1000,
|
||||
"reasoning_tokens": 800, // all of it thinking; still billed as output
|
||||
"time": 1750000000000,
|
||||
})
|
||||
st := stateOf(t, ps)
|
||||
cost := st["total"].(map[string]interface{})["cost"].(float64)
|
||||
approx(t, "reasoning at completion rate", cost, 0.00075)
|
||||
}
|
||||
|
||||
// TestReasoningPriceOverridesOnlyWhenSet: with a rule that DOES price thinking
|
||||
// differently, only the difference is applied. Charging the reasoning portion a
|
||||
// second time in full is the obvious wrong implementation and it doubles the
|
||||
// completion line.
|
||||
func TestReasoningPriceOverridesOnlyWhenSet(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
if err := ps.SetState("billing", map[string]interface{}{
|
||||
"prices": map[string]interface{}{
|
||||
"currency": "USD",
|
||||
"models": map[string]interface{}{
|
||||
"m": map[string]interface{}{
|
||||
"prompt": 0.15e-6, "completion": 0.60e-6,
|
||||
// Thinking costs 3x ordinary output: 1.80/M.
|
||||
"reasoning_price": 1.80e-6,
|
||||
},
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "m", "source": "s", "key": "***aaaaaa", "ok": true,
|
||||
"prompt_tokens": 1000, "completion_tokens": 1000, "reasoning_tokens": 800,
|
||||
"time": 1750000000000,
|
||||
})
|
||||
st := stateOf(t, ps)
|
||||
cost := st["total"].(map[string]interface{})["cost"].(float64)
|
||||
// base = 1000*0.15e-6 + 1000*0.60e-6 = 0.00075
|
||||
// + 800 * (1.80-0.60)e-6 = 0.00096 → 0.00171
|
||||
approx(t, "reasoning repriced to 1.80/M", cost, 0.00171)
|
||||
}
|
||||
|
||||
// TestReasoningClampedToCompletion: a provider claiming MORE reasoning than
|
||||
// completion is malformed. Without a clamp the visible-output count goes
|
||||
// negative and the delta exceeds the whole request, so the bill silently
|
||||
// explodes on bad data instead of degrading.
|
||||
func TestReasoningClampedToCompletion(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
if err := ps.SetState("billing", map[string]interface{}{
|
||||
"prices": map[string]interface{}{
|
||||
"currency": "USD",
|
||||
"models": map[string]interface{}{
|
||||
"m": map[string]interface{}{
|
||||
"prompt": 0.15e-6, "completion": 0.60e-6, "reasoning_price": 1.80e-6,
|
||||
},
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "m", "source": "s", "key": "***aaaaaa", "ok": true,
|
||||
"prompt_tokens": 1000, "completion_tokens": 1000, "reasoning_tokens": 99999,
|
||||
"time": 1750000000000,
|
||||
})
|
||||
st := stateOf(t, ps)
|
||||
cost := st["total"].(map[string]interface{})["cost"].(float64)
|
||||
// reasoning is clamped from 99999 down to completion (1000):
|
||||
// base = 1000*0.15e-6 + 1000*0.60e-6 = 0.00075
|
||||
// delta = 1000 * (1.80-0.60)e-6 = 0.00120
|
||||
// total = 0.00195
|
||||
// Without the clamp this would be 0.18 — 92x the whole request, and a
|
||||
// number plausible enough to survive a glance at a dashboard.
|
||||
approx(t, "reasoning clamped to completion", cost, 0.00195)
|
||||
}
|
||||
|
||||
// TestLongContextStepIsMarginal: the threshold reprices only the tokens PAST
|
||||
// it, at a marginal rate.
|
||||
//
|
||||
// A flat prompt price under-bills exactly the traffic an agent workload
|
||||
// produces — on this deployment 88% of requests exceed 128k with a median of
|
||||
// 247k — and it under-bills silently, because the number stays plausible. The
|
||||
// step must apply to the excess only; charging the WHOLE prompt at the higher
|
||||
// rate is the other obvious implementation and it overcharges short requests by
|
||||
// the same factor it was meant to fix long ones.
|
||||
func TestLongContextStepIsMarginal(t *testing.T) {
|
||||
|
||||
// Short request: below the threshold, so the step must not touch it at all.
|
||||
ps, _ := billingVM(t)
|
||||
if err := ps.SetState("billing", map[string]interface{}{
|
||||
"prices": map[string]interface{}{
|
||||
"currency": "USD",
|
||||
"models": map[string]interface{}{
|
||||
"m": map[string]interface{}{
|
||||
"prompt": 1e-6, "completion": 0,
|
||||
"long_context": map[string]interface{}{"above": 128000, "prompt": 2e-6},
|
||||
},
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "m", "source": "s", "key": "***aaaaaa", "ok": true,
|
||||
"prompt_tokens": 100000, "completion_tokens": 0, "time": 1750000000000,
|
||||
})
|
||||
got := stateOf(t, ps)["total"].(map[string]interface{})["cost"].(float64)
|
||||
approx(t, "short request unaffected", got, 0.1)
|
||||
|
||||
// Long request: 200k prompt with a 128k threshold → 72k tokens at the step.
|
||||
ps2, _ := billingVM(t)
|
||||
if err := ps2.SetState("billing", map[string]interface{}{
|
||||
"prices": map[string]interface{}{
|
||||
"currency": "USD",
|
||||
"models": map[string]interface{}{
|
||||
"m": map[string]interface{}{
|
||||
"prompt": 1e-6, "completion": 0,
|
||||
"long_context": map[string]interface{}{"above": 128000, "prompt": 2e-6},
|
||||
},
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps2.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "m", "source": "s", "key": "***aaaaaa", "ok": true,
|
||||
"prompt_tokens": 200000, "completion_tokens": 0, "time": 1750000000000,
|
||||
})
|
||||
got2 := stateOf(t, ps2)["total"].(map[string]interface{})["cost"].(float64)
|
||||
// 128000*1e-6 + 72000*2e-6 = 0.128 + 0.144 = 0.272
|
||||
approx(t, "long request marginal step", got2, 0.272)
|
||||
}
|
||||
|
||||
// TestLongContextComposesWithCacheDiscount pins the interaction, which is the
|
||||
// whole reason for computing the step as a marginal rate.
|
||||
//
|
||||
// On this deployment the cache hit rate is 88% and long prompts dominate, so
|
||||
// "cached tokens beyond the threshold" is the common case, not an edge case. A
|
||||
// cache read already bills at prompt*discount; the long-context rate applies on
|
||||
// top of THAT, not on top of the full prompt price. Computing it any other way
|
||||
// silently changes the price of the majority of traffic.
|
||||
func TestLongContextComposesWithCacheDiscount(t *testing.T) {
|
||||
ps, _ := billingVM(t)
|
||||
if err := ps.SetState("billing", map[string]interface{}{
|
||||
"prices": map[string]interface{}{
|
||||
"currency": "USD", "cache_discount": 0.1,
|
||||
"models": map[string]interface{}{
|
||||
"m": map[string]interface{}{
|
||||
"prompt": 1e-6, "completion": 0,
|
||||
"long_context": map[string]interface{}{"above": 100000, "prompt": 2e-6},
|
||||
},
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 200k prompt, 150k of it cached, 50k fresh. Excess past 100k = 100k, split
|
||||
// 25% fresh / 75% cached.
|
||||
ps.Fire(StageRequestEnd, map[string]interface{}{
|
||||
"model": "m", "source": "s", "key": "***aaaaaa", "ok": true,
|
||||
"prompt_tokens": 200000, "completion_tokens": 0,
|
||||
"cache_hit_tokens": 150000, "cache_reported": true,
|
||||
"time": 1750000000000,
|
||||
})
|
||||
got := stateOf(t, ps)["total"].(map[string]interface{})["cost"].(float64)
|
||||
// base: fresh 50000*1e-6 = 0.05
|
||||
// cache 150000*1e-6*0.1 = 0.015
|
||||
// step: fresh excess 25000*(2-1)e-6 = 0.025
|
||||
// cache excess 75000*(2-0.1)e-6 = 0.1425
|
||||
// total = 0.2325
|
||||
approx(t, "step composes with cache discount", got, 0.2325)
|
||||
}
|
||||
|
||||
@ -414,3 +414,86 @@ func TestRuleTableLocksColumnWidthsInAColgroup(t *testing.T) {
|
||||
"control and must never be squeezed by the flexible inputs")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingUIShowsRecentUnpriced: the page must be able to distinguish a
|
||||
// settled pricing debt from a live leak. The lifetime unpriced counter answers
|
||||
// neither — it keeps every request from before a rule was added, so it reads the
|
||||
// same number in both situations. The day buckets exist to make the difference
|
||||
// visible, and a bucket nothing renders is a bucket nobody benefits from.
|
||||
func TestBillingUIShowsRecentUnpriced(t *testing.T) {
|
||||
raw, err := os.ReadFile("plugins/billing.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := string(raw)
|
||||
for _, needle := range []string{
|
||||
"unpriced_by_day", // the tally is written
|
||||
"recentUnpriced", // it is reduced to a window
|
||||
"unpriced_recent", // it reaches the view
|
||||
"unpriced7d", // the card labels it
|
||||
"unpricedClean", // and says so when the window is clear
|
||||
} {
|
||||
if !strings.Contains(src, needle) {
|
||||
t.Errorf("billing UI lacks %q", needle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingRulesUIExposesReasoningPrice: the thinking-token rate must be
|
||||
// editable in the rules editor, not only settable by hand-editing config.yaml.
|
||||
// A pricing capability that lives only in the config file is one the operator
|
||||
// will assume does not exist.
|
||||
func TestBillingRulesUIExposesReasoningPrice(t *testing.T) {
|
||||
raw, err := os.ReadFile("plugins/billing.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := string(raw)
|
||||
for _, needle := range []string{
|
||||
`class='m-r'`, // the input, in both the existing rows and the "+ model" row
|
||||
`models[n].reasoning`, // collected back out of the DOM on save
|
||||
`reasoningHint`, // and it says what leaving it blank means
|
||||
} {
|
||||
if !strings.Contains(src, needle) {
|
||||
t.Errorf("rules editor lacks %q", needle)
|
||||
}
|
||||
}
|
||||
// Two occurrences: an existing model's row and the template for a new one.
|
||||
// One would mean the "+ model" button produces a row that silently drops
|
||||
// the rate on the next save.
|
||||
if n := strings.Count(src, `class='m-r'`); n < 2 {
|
||||
t.Errorf("m-r appears %d time(s); both the row template and the editor need it", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingRulesUIExposesLongContextStep: the long-context step must be
|
||||
// editable in the rules editor.
|
||||
//
|
||||
// This is not a niche control. On an agent workload 88% of requests carry more
|
||||
// than 128k prompt tokens with a median of 247k, so a rule that omits the step
|
||||
// under-bills the MAJORITY of traffic while every number on the dashboard stays
|
||||
// plausible. Leaving the capability in config.yaml alone would mean the operator
|
||||
// never discovers it needs it.
|
||||
func TestBillingRulesUIExposesLongContextStep(t *testing.T) {
|
||||
raw, err := os.ReadFile("plugins/billing.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := string(raw)
|
||||
for _, needle := range []string{
|
||||
`class='m-lc-a'`, // threshold
|
||||
`class='m-lc-p'`, // absolute rate
|
||||
`class='m-lc-m'`, // multiplier
|
||||
`models[n].long_context`, // collected back out on save
|
||||
`lcHint`, // explains that blank = no step
|
||||
} {
|
||||
if !strings.Contains(src, needle) {
|
||||
t.Errorf("rules editor lacks %q", needle)
|
||||
}
|
||||
}
|
||||
// Both the existing-row template and the "+ model" template need the three
|
||||
// inputs; one would make a newly added model lose its step on the next save.
|
||||
if n := strings.Count(src, `class='m-lc-a'`); n < 2 {
|
||||
t.Errorf("long-context inputs appear %d time(s); both row templates need them", n)
|
||||
}
|
||||
}
|
||||
|
||||
115
internal/lua/plugin_access_persist.go
Normal file
115
internal/lua/plugin_access_persist.go
Normal file
@ -0,0 +1,115 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Access overrides are OPERATOR policy, not plugin data. They live in their own
|
||||
// file rather than in each plugin's state because:
|
||||
//
|
||||
// - they describe the deployment, not the plugin, so a plugin upgrade that
|
||||
// replaces its .state.json must not silently widen access again;
|
||||
// - they must survive a restart. An in-memory map made "restrict billing to
|
||||
// admin" look like it worked right up to the next deploy, at which point the
|
||||
// restriction evaporated with nothing in the logs to say why;
|
||||
// - the plugin_dir is the one place an operator already looks when asking
|
||||
// "what does this plugin actually get to see", so the answer lives beside it.
|
||||
//
|
||||
// The file is a flat map of "name" (whole plugin) or "name#path" (one endpoint)
|
||||
// to an access spec, which is the same shape the in-memory store uses, so load
|
||||
// and save cannot disagree about what a key means.
|
||||
|
||||
const accessFileName = ".access.json"
|
||||
|
||||
func (ps *Plugins) accessFile() string {
|
||||
if ps.dir == "" {
|
||||
return ""
|
||||
}
|
||||
return ps.dir + string(os.PathSeparator) + accessFileName
|
||||
}
|
||||
|
||||
// LoadAccessOverrides reads the persisted overrides into memory. A missing file
|
||||
// is not an error: no operator has restricted anything yet, which is the
|
||||
// default state of a fresh install.
|
||||
//
|
||||
// A corrupt file is also not fatal. Losing a restriction is bad, but refusing to
|
||||
// start the gateway is worse, and an empty map plus a logged error leaves the
|
||||
// operator with something to fix.
|
||||
func (ps *Plugins) LoadAccessOverrides() error {
|
||||
path := ps.accessFile()
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m := map[string]string{}
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
return err
|
||||
}
|
||||
ps.mu.Lock()
|
||||
defer ps.mu.Unlock()
|
||||
if ps.visibility == nil {
|
||||
ps.visibility = visibilityOverrides{}
|
||||
}
|
||||
for k, v := range m {
|
||||
ps.visibility[k] = v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveAccessOverridesLocked flushes the whole override map. It is called on
|
||||
// every change, and unlike plugin state the map is tiny and changes only when an
|
||||
// operator clicks Save — so a synchronous write costs nothing that matters, and
|
||||
// it means the change is durable the moment the API returns 200.
|
||||
func (ps *Plugins) saveAccessOverridesLocked() {
|
||||
path := ps.accessFile()
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
out := make(map[string]string, len(ps.visibility))
|
||||
for k, v := range ps.visibility {
|
||||
if v != "" {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
// Leave no empty file behind: its absence is the honest encoding of
|
||||
// "nothing is overridden".
|
||||
_ = os.Remove(path)
|
||||
return
|
||||
}
|
||||
writeJSONAtomic(path, out)
|
||||
}
|
||||
|
||||
// AccessOverrides returns a copy of every override in force, for the UI.
|
||||
func (ps *Plugins) accessOverridesSnapshot() map[string]string {
|
||||
ps.mu.RLock()
|
||||
defer ps.mu.RUnlock()
|
||||
if len(ps.visibility) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(ps.visibility))
|
||||
for k, v := range ps.visibility {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sortedOverrideKeys exists only so a debug dump is stable; map iteration order
|
||||
// would otherwise make two identical states look different.
|
||||
func (ps *Plugins) sortedOverrideKeys() []string {
|
||||
m := ps.accessOverridesSnapshot()
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
110
internal/lua/plugin_access_persist_test.go
Normal file
110
internal/lua/plugin_access_persist_test.go
Normal file
@ -0,0 +1,110 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAccessOverrideSurvivesReload is the test that matters for an access
|
||||
// restriction. An in-memory map makes "billing is admin-only" look like it
|
||||
// worked right up to the next restart, and then the restriction is simply gone
|
||||
// with nothing in the logs to explain it. Both the plugin-level audience and
|
||||
// the per-endpoint spec must come back.
|
||||
func TestAccessOverrideSurvivesReload(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := `local P = {}
|
||||
P.name = "api"
|
||||
P.version = "1.0.0"
|
||||
P.http = {
|
||||
{ path = "health", handler = "health", roles = { "admin" } },
|
||||
}
|
||||
function P.health(req) return { json = { ok = true } } end
|
||||
return P
|
||||
`
|
||||
first := NewPlugins(NewVM(dir), dir)
|
||||
if err := first.LoadSource("api", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first.SetPluginVisibility("api", "admin, user")
|
||||
if err := first.SetEndpointAccess("api", "health", "@homeagent"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, accessFileName)); err != nil {
|
||||
t.Fatalf("override was not written: %v", err)
|
||||
}
|
||||
|
||||
// A fresh Plugins over the same dir is what a restart looks like.
|
||||
second := NewPlugins(NewVM(dir), dir)
|
||||
// The plugin source is on disk too, so this second instance sees both the
|
||||
// plugin and the overrides — the same pair a restart sees. Loading only the
|
||||
// overrides would leave no endpoints to walk, and the assertion below would
|
||||
// pass vacuously.
|
||||
if err := second.LoadSource("api", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := second.LoadAccessOverrides(); err != nil {
|
||||
t.Fatalf("reload overrides: %v", err)
|
||||
}
|
||||
if got := second.VisibilityOverride("api"); got != "admin, user" {
|
||||
t.Errorf("plugin visibility lost across reload: %q", got)
|
||||
}
|
||||
found := false
|
||||
for _, e := range second.EndpointAccess() {
|
||||
if e.Path == "health" && len(e.OverrideKeys) == 1 && e.OverrideKeys[0] == "homeagent" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("endpoint override lost across reload")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccessOverrideClearedRemovesFile keeps the "nothing overridden" state
|
||||
// distinguishable from "overridden with something".
|
||||
func TestAccessOverrideClearedRemovesFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(NewVM(dir), dir)
|
||||
ps.SetPluginVisibility("api", "admin")
|
||||
path := filepath.Join(dir, accessFileName)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("expected an access file: %v", err)
|
||||
}
|
||||
ps.SetPluginVisibility("api", "")
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Errorf("clearing the last override should remove the file, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVisibilityOverridesExcludeEndpointKeys keeps endpoint policy out of the
|
||||
// plugin-audience listing. Both live in one map, and reporting "probe#hello"
|
||||
// under visibility is how the plugin page ends up offering to save an audience
|
||||
// of that name — which nothing reads as an audience.
|
||||
func TestVisibilityOverridesExcludeEndpointKeys(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := `local P = {}
|
||||
P.name = "api"
|
||||
P.version = "1.0.0"
|
||||
P.http = { { path = "health", handler = "health", roles = { "admin" } } }
|
||||
function P.health(req) return { json = { ok = true } } end
|
||||
return P
|
||||
`
|
||||
ps := NewPlugins(NewVM(dir), dir)
|
||||
if err := ps.LoadSource("api", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps.SetPluginVisibility("api", "admin, user")
|
||||
if err := ps.SetEndpointAccess("api", "health", "@homeagent"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := ps.VisibilityOverrides()
|
||||
if got["api"] != "admin, user" {
|
||||
t.Errorf("plugin audience missing: %+v", got)
|
||||
}
|
||||
if _, leaked := got["api#health"]; leaked {
|
||||
t.Errorf("endpoint override leaked into the audience map: %+v", got)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Errorf("expected exactly the plugin audience, got %+v", got)
|
||||
}
|
||||
}
|
||||
248
internal/lua/plugin_http.go
Normal file
248
internal/lua/plugin_http.go
Normal file
@ -0,0 +1,248 @@
|
||||
package lua
|
||||
|
||||
// Outbound HTTP for plugins.
|
||||
//
|
||||
// The billing plugin never needed this: everything it reports arrives on a hook
|
||||
// payload that the gateway produced while forwarding a request. A quota plugin
|
||||
// is the first thing that must REACH OUT on its own — Command Code exposes
|
||||
// /alpha/billing/credits and friends, and there is no way to learn a plan's
|
||||
// remaining balance without asking.
|
||||
//
|
||||
// That capability is also the sharpest edge in the plugin model. A plugin is a
|
||||
// single .lua file the WebUI installs from a textarea: untrusted, browser-facing
|
||||
// code that runs in the gateway process. Handing it a general-purpose HTTP
|
||||
// client would make every install a standing SSRF primitive against whatever
|
||||
// the gateway can reach — the cloud metadata endpoint, the cluster control
|
||||
// plane, whatever else sits on 127.0.0.1 in this deployment. Reading the
|
||||
// environment for credentials is a second door out.
|
||||
//
|
||||
// So the bridge is deliberately narrow:
|
||||
//
|
||||
// - Destination must match a host that a CONFIGURED SOURCE already talks to.
|
||||
// Those are operator-declared, not plugin-declared: a plugin cannot widen
|
||||
// its own reach by inventing a URL, and an existing source's host is
|
||||
// exactly the set of upstreams this deployment already trusts with API keys.
|
||||
// - http:// on a non-loopback host is refused. Credentials belong in HTTPS.
|
||||
// - Loopback needs no explicit opt-in: it is the only way a plugin can read
|
||||
// the gateway's own API (the billing rules UI does exactly that), and it
|
||||
// cannot leave the machine.
|
||||
// - Every request is bounded by a timeout and the response is size-capped, so
|
||||
// a plugin cannot wedge a worker goroutine or exhaust memory.
|
||||
// - Credentials are NEVER read from the environment here. A plugin that needs
|
||||
// a key must be given it (state, or the caller's own config), which keeps
|
||||
// the grant visible to the operator instead of silently ambient.
|
||||
//
|
||||
// Redirects are NOT followed: the allowlist is checked per hop, and a redirect
|
||||
// to an unlisted host would otherwise walk straight past it. A 3xx is returned
|
||||
// as-is so the plugin can see it.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
golua "github.com/aarzilli/golua/lua"
|
||||
)
|
||||
|
||||
// httpDefaultTimeout bounds a whole request; httpMaxResponseBytes caps the body
|
||||
// a plugin can pull into the gateway's heap.
|
||||
const (
|
||||
httpDefaultTimeout = 15 * time.Second
|
||||
httpMaxTimeout = 60 * time.Second
|
||||
httpMaxResponse = 4 << 20 // 4 MiB
|
||||
)
|
||||
|
||||
// httpClient is shared by every plugin request. It deliberately has no
|
||||
// ProxyFromEnvironment: an operator who proxies upstreams configures the proxy
|
||||
// per source, and a plugin request should not silently inherit one.
|
||||
var httpClient = &http.Client{
|
||||
Timeout: httpDefaultTimeout,
|
||||
Transport: &http.Transport{
|
||||
Proxy: nil,
|
||||
DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 16,
|
||||
IdleConnTimeout: 60 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: time.Second,
|
||||
},
|
||||
// See above: a redirect is a hop the allowlist has not seen.
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
|
||||
// httpGlobal is the Lua name of the outbound bridge.
|
||||
const httpGlobal = "__llmsproxy_http"
|
||||
|
||||
// httpAllowlist is the set of hosts plugins may contact: the host of every
|
||||
// configured source's base_url.
|
||||
type httpAllowlist struct {
|
||||
hosts map[string]bool
|
||||
}
|
||||
|
||||
func newHTTPAllowlist(hosts []string) *httpAllowlist {
|
||||
m := make(map[string]bool, len(hosts))
|
||||
for _, h := range hosts {
|
||||
if h != "" {
|
||||
m[strings.ToLower(h)] = true
|
||||
}
|
||||
}
|
||||
return &httpAllowlist{hosts: m}
|
||||
}
|
||||
|
||||
// allows reports whether host:port may be contacted. Ports 80/443 are implied
|
||||
// by the bare host so a source configured as https://api.example.com/v1 does not
|
||||
// have to spell the port.
|
||||
func (a *httpAllowlist) allows(host string, port string) bool {
|
||||
host = strings.ToLower(host)
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
if a.hosts[host] {
|
||||
return true
|
||||
}
|
||||
switch port {
|
||||
case "", "80", "443":
|
||||
return a.hosts[host]
|
||||
}
|
||||
return a.hosts[net.JoinHostPort(host, port)]
|
||||
}
|
||||
|
||||
// checkURL validates a plugin-supplied URL against the allowlist. It returns
|
||||
// the parsed URL plus a reason when it is refused.
|
||||
func (a *httpAllowlist) checkURL(raw string) (*url.URL, error) {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bad url: %v", err)
|
||||
}
|
||||
switch strings.ToLower(u.Scheme) {
|
||||
case "https":
|
||||
case "http":
|
||||
// Plain HTTP is only tolerated on loopback, where it stays on this
|
||||
// machine and is how the gateway's own plain-HTTP dev port is reached.
|
||||
if !isLoopbackHost(u.Hostname()) {
|
||||
return nil, fmt.Errorf("refusing plain http to non-loopback host %q: use https", u.Hostname())
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported scheme %q", u.Scheme)
|
||||
}
|
||||
if !a.allows(u.Hostname(), u.Port()) && !isLoopbackHost(u.Hostname()) {
|
||||
// Loopback needs no entry: it cannot leave the machine, and the billing
|
||||
// rules UI reads the gateway's own API over plain HTTP in dev setups.
|
||||
return nil, fmt.Errorf("host %q is not an allowed outbound destination "+
|
||||
"(allowed: the hosts of configured sources, plus loopback)", u.Hostname())
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func isLoopbackHost(h string) bool {
|
||||
if h == "localhost" {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(strings.Trim(h, "[]"))
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
// httpRequestSpec is the decoded argument table Lua passes to __llmsproxy_http.
|
||||
type httpRequestSpec struct {
|
||||
URL string
|
||||
Method string
|
||||
Headers map[string]string
|
||||
Body string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// luaHTTPFn builds the golua closure. It takes one Lua table argument and
|
||||
// returns (responseTable, nil) or (nil, errorMessage) — never raises, so a
|
||||
// plugin mistake cannot take the gateway with it.
|
||||
func luaHTTPFn(L *golua.State, allow *httpAllowlist) golua.LuaGoFunction {
|
||||
return func(L *golua.State) int {
|
||||
spec, err := readHTTPRequestSpec(L)
|
||||
if err != nil {
|
||||
L.PushNil()
|
||||
L.PushString(err.Error())
|
||||
return 2
|
||||
}
|
||||
u, err := allow.checkURL(spec.URL)
|
||||
if err != nil {
|
||||
L.PushNil()
|
||||
L.PushString(err.Error())
|
||||
return 2
|
||||
}
|
||||
resp, err := performHTTP(u, spec)
|
||||
if err != nil {
|
||||
L.PushNil()
|
||||
L.PushString(err.Error())
|
||||
return 2
|
||||
}
|
||||
pushHTTPResponse(L, resp)
|
||||
L.PushNil()
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
func performHTTP(u *url.URL, spec httpRequestSpec) (*httpResult, error) {
|
||||
method := strings.ToUpper(spec.Method)
|
||||
if method == "" {
|
||||
method = http.MethodGet
|
||||
}
|
||||
timeout := spec.Timeout
|
||||
if timeout <= 0 || timeout > httpMaxTimeout {
|
||||
timeout = httpDefaultTimeout
|
||||
}
|
||||
|
||||
var body io.Reader
|
||||
if spec.Body != "" {
|
||||
body = strings.NewReader(spec.Body)
|
||||
}
|
||||
req, err := http.NewRequest(method, u.String(), body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build request: %v", err)
|
||||
}
|
||||
for k, v := range spec.Headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "ModelRouter-plugin")
|
||||
}
|
||||
|
||||
client := *httpClient
|
||||
client.Timeout = timeout
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, httpMaxResponse+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read body: %v", err)
|
||||
}
|
||||
if len(data) > httpMaxResponse {
|
||||
return nil, fmt.Errorf("response exceeds %d byte cap", httpMaxResponse)
|
||||
}
|
||||
return &httpResult{
|
||||
Status: resp.StatusCode,
|
||||
Headers: flattenHeaders(resp.Header),
|
||||
Body: string(data),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type httpResult struct {
|
||||
Status int
|
||||
Headers map[string]string
|
||||
Body string
|
||||
}
|
||||
|
||||
func flattenHeaders(h http.Header) map[string]string {
|
||||
out := make(map[string]string, len(h))
|
||||
for k := range h {
|
||||
out[strings.ToLower(k)] = h.Get(k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
122
internal/lua/plugin_http_args.go
Normal file
122
internal/lua/plugin_http_args.go
Normal file
@ -0,0 +1,122 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
golua "github.com/aarzilli/golua/lua"
|
||||
)
|
||||
|
||||
// httpArgIdx is the stack slot holding the caller's table. Verified against
|
||||
// golua: a PushGoClosure function is invoked with its arguments already on top
|
||||
// and nothing of itself below them.
|
||||
const httpArgIdx = 1
|
||||
|
||||
// readHTTPRequestSpec decodes the Lua argument table:
|
||||
//
|
||||
// __llmsproxy_http({
|
||||
// url = "https://host/path",
|
||||
// method = "GET", -- optional, default GET
|
||||
// headers = { ["X-A"] = "1" }, -- optional
|
||||
// body = "...", -- optional
|
||||
// timeout_ms = 8000, -- optional, clamped to 60s
|
||||
// })
|
||||
//
|
||||
// The table is at index 1: measured, not assumed. A Go closure registered with
|
||||
// PushGoClosure is NOT on the stack when it runs — GetTop() is 1 and slot 1 is
|
||||
// the caller's table (type LUA_TTABLE). An earlier version assumed the closure
|
||||
// occupied slot 1 and read from slot 2, which then reported "expected a table
|
||||
// argument" for a perfectly good call.
|
||||
//
|
||||
// luaTableToGoValue already turns the table into a map/slice, so the whole
|
||||
// argument is decoded in one pass instead of walking the table field by field.
|
||||
func readHTTPRequestSpec(L *golua.State) (httpRequestSpec, error) {
|
||||
var spec httpRequestSpec
|
||||
if L.GetTop() < httpArgIdx {
|
||||
return spec, fmt.Errorf("__llmsproxy_http: expected a table argument")
|
||||
}
|
||||
if L.Type(httpArgIdx) != golua.LUA_TTABLE {
|
||||
return spec, fmt.Errorf("__llmsproxy_http: expected a table, got %s",
|
||||
normalizeString(L, httpArgIdx))
|
||||
}
|
||||
arg, ok := luaTableToGoValue(L, httpArgIdx).(map[string]interface{})
|
||||
if !ok {
|
||||
return spec, fmt.Errorf("__llmsproxy_http: argument must be a keyed table")
|
||||
}
|
||||
for k, v := range arg {
|
||||
switch strings.ToLower(k) {
|
||||
case "url", "uri", "endpoint":
|
||||
spec.URL = asString(v)
|
||||
case "method", "verb":
|
||||
spec.Method = asString(v)
|
||||
case "body", "data", "payload":
|
||||
spec.Body = asString(v)
|
||||
case "headers", "header":
|
||||
if m, ok := v.(map[string]interface{}); ok {
|
||||
spec.Headers = make(map[string]string, len(m))
|
||||
for hk, hv := range m {
|
||||
spec.Headers[hk] = asString(hv)
|
||||
}
|
||||
}
|
||||
case "timeout_ms", "timeoutms":
|
||||
if n, ok := asFloat(v); ok {
|
||||
spec.Timeout = time.Duration(n) * time.Millisecond
|
||||
}
|
||||
}
|
||||
}
|
||||
if spec.URL == "" {
|
||||
return spec, fmt.Errorf("__llmsproxy_http: missing url")
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// pushHTTPResponse converts the result into two Lua values: a table
|
||||
// {status, headers, body} and nil. The caller pushes the error slot itself.
|
||||
func pushHTTPResponse(L *golua.State, r *httpResult) {
|
||||
L.NewTable()
|
||||
|
||||
L.PushString("status")
|
||||
L.PushNumber(float64(r.Status))
|
||||
L.SetTable(-3)
|
||||
|
||||
L.PushString("headers")
|
||||
pushStringMap(L, r.Headers)
|
||||
L.SetTable(-3)
|
||||
|
||||
L.PushString("body")
|
||||
L.PushString(r.Body)
|
||||
L.SetTable(-3)
|
||||
}
|
||||
|
||||
func pushStringMap(L *golua.State, m map[string]string) {
|
||||
L.NewTable()
|
||||
for k, v := range m {
|
||||
L.PushString(v)
|
||||
L.SetField(-2, k)
|
||||
}
|
||||
}
|
||||
|
||||
func asString(v interface{}) string {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
return fmt.Sprintf("%v", t)
|
||||
}
|
||||
}
|
||||
|
||||
func asFloat(v interface{}) (float64, bool) {
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
return t, true
|
||||
case int:
|
||||
return float64(t), true
|
||||
case int64:
|
||||
return float64(t), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
530
internal/lua/plugin_http_handler.go
Normal file
530
internal/lua/plugin_http_handler.go
Normal file
@ -0,0 +1,530 @@
|
||||
package lua
|
||||
|
||||
// Plugin-registered HTTP endpoints.
|
||||
//
|
||||
// The gateway mounts what a plugin declares in plugin.http and calls back into
|
||||
// the plugin's Lua state to produce each response. This is the piece billing
|
||||
// never needed: it only receives hooks, so it has nothing to expose. A plugin
|
||||
// that computes something an operator wants to query from outside the browser —
|
||||
// a health probe, a CI check, another tool — needs a route, and hard-coding one
|
||||
// per plugin would mean a gateway change for every such plugin.
|
||||
//
|
||||
// The request and response are both plain tables crossing the Go/Lua boundary:
|
||||
//
|
||||
// request { method, path, query{}, params{}, headers{}, body, role, key }
|
||||
// response { status, headers{}, body } or { status, json = <any table> }
|
||||
//
|
||||
// A handler returns (response, nil) or (nil, errorMessage). Errors never raise:
|
||||
// an escaping Lua error inside a hook has taken the whole gateway down before
|
||||
// (see hookGuardSrc), so the same discipline applies here.
|
||||
//
|
||||
// Two properties are enforced on the Go side rather than trusted to the plugin:
|
||||
//
|
||||
// - Method and status are checked against an allowlist before the response is
|
||||
// written. A plugin returning status 0 or a redirect with an injected
|
||||
// Location must not be able to steer the gateway's own routing.
|
||||
// - Path traversal cannot escape the mount: params are substituted into a
|
||||
// fixed segment count, and the handler's own Path is rejected at
|
||||
// registration if it contains "..".
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// allowedMethods is deliberately short. An arbitrary verb would need a case in
|
||||
// the router for no benefit, and a plugin that wants every verb can register the
|
||||
// five that exist.
|
||||
var allowedMethods = map[string]bool{
|
||||
http.MethodGet: true, http.MethodPost: true, http.MethodPut: true,
|
||||
http.MethodDelete: true, http.MethodPatch: true,
|
||||
}
|
||||
|
||||
// normalizeHandlerMethod validates and defaults a declared method.
|
||||
func normalizeHandlerMethod(m string) (string, error) {
|
||||
m = strings.ToUpper(strings.TrimSpace(m))
|
||||
if m == "" {
|
||||
return http.MethodGet, nil
|
||||
}
|
||||
if !allowedMethods[m] {
|
||||
return "", fmt.Errorf("unsupported method %q (allowed: GET POST PUT DELETE PATCH)", m)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// validateHandlerPath rejects paths that could escape the mount or collide with
|
||||
// the plugin state/call routes.
|
||||
func validateHandlerPath(p string) error {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
return fmt.Errorf("empty path")
|
||||
}
|
||||
if strings.Contains(p, "..") {
|
||||
return fmt.Errorf("path %q contains .. ", p)
|
||||
}
|
||||
if strings.Contains(p, "//") {
|
||||
return fmt.Errorf("path %q contains an empty segment", p)
|
||||
}
|
||||
for _, seg := range strings.Split(strings.Trim(p, "/"), "/") {
|
||||
if seg == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(seg, ":") {
|
||||
if len(seg) == 1 {
|
||||
return fmt.Errorf("path %q has a nameless param segment", p)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// A literal segment must not look like a wildcard, or the route table
|
||||
// gets two patterns that match the same URL.
|
||||
if strings.ContainsAny(seg, "*?") {
|
||||
return fmt.Errorf("path %q uses a wildcard; use :name for a param", p)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handlerRequest is what a Lua handler sees.
|
||||
type handlerRequest struct {
|
||||
Method string
|
||||
Path string
|
||||
Query map[string]string
|
||||
Params map[string]string
|
||||
Headers map[string]string
|
||||
Body string
|
||||
Role string
|
||||
Key string
|
||||
}
|
||||
|
||||
// handlerResponse is what a Lua handler returns.
|
||||
type handlerResponse struct {
|
||||
Status int
|
||||
Headers map[string]string
|
||||
Body string
|
||||
// JSON is set when the handler returned {json = <table>}; it is encoded by
|
||||
// the kernel so a plugin does not hand-roll escaping.
|
||||
JSON interface{}
|
||||
}
|
||||
|
||||
// writeHTTPHandlerResponse renders a handler's reply onto the gateway's writer.
|
||||
//
|
||||
// The status is validated even though it came from a plugin: a handler
|
||||
// returning 0 or 999 would make net/http panic, and one returning 3xx with a
|
||||
// Location the plugin chose could be used to bounce a caller at a URL of the
|
||||
// plugin's picking. Anything outside 100..599 that is not a redirect becomes 500
|
||||
// with an error body rather than a panic.
|
||||
func writeHTTPHandlerResponse(w http.ResponseWriter, r *handlerResponse) {
|
||||
status := r.Status
|
||||
if status == 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
if status < 100 || status > 599 {
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
for k, v := range r.Headers {
|
||||
// A plugin cannot set Content-Length (the body is written by the kernel)
|
||||
// or hop-by-hop headers (Connection, Transfer-Encoding) which would
|
||||
// corrupt the gateway's own response framing.
|
||||
switch http.CanonicalHeaderKey(k) {
|
||||
case "Content-Length", "Connection", "Transfer-Encoding", "Upgrade":
|
||||
continue
|
||||
}
|
||||
w.Header().Set(k, v)
|
||||
}
|
||||
if r.JSON != nil {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
// Encoded here rather than by the gateway layer: this file is in the lua
|
||||
// package and importing the gateway would be a cycle.
|
||||
if enc, err := json.Marshal(r.JSON); err == nil {
|
||||
_, _ = w.Write(enc)
|
||||
} else {
|
||||
_, _ = w.Write([]byte(`{"error":"handler json could not be encoded"}`))
|
||||
}
|
||||
return
|
||||
}
|
||||
if w.Header().Get("Content-Type") == "" {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
if r.Body != "" {
|
||||
_, _ = w.Write([]byte(r.Body))
|
||||
}
|
||||
}
|
||||
|
||||
// parseHandlerResponse reads a Lua handler's return value.
|
||||
//
|
||||
// It accepts three shapes, because a plugin should not have to think about it:
|
||||
//
|
||||
// { status = 200, body = "text" }
|
||||
// { status = 201, json = { any = "table" } }
|
||||
// { status = 204 } -- no body
|
||||
//
|
||||
// Anything else — a string, a number, a table without status — is a handler bug
|
||||
// and becomes an error rather than an empty 200, so the plugin author sees it.
|
||||
func parseHandlerResponse(v interface{}) (*handlerResponse, error) {
|
||||
m, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("handler must return a table, got %T", v)
|
||||
}
|
||||
out := &handlerResponse{Status: http.StatusOK}
|
||||
if s, ok := m["status"]; ok {
|
||||
switch n := s.(type) {
|
||||
case float64:
|
||||
out.Status = int(n)
|
||||
case int:
|
||||
out.Status = n
|
||||
default:
|
||||
return nil, fmt.Errorf("status must be a number, got %T", s)
|
||||
}
|
||||
}
|
||||
if b, ok := m["body"].(string); ok {
|
||||
out.Body = b
|
||||
}
|
||||
if h, ok := m["headers"].(map[string]interface{}); ok {
|
||||
out.Headers = make(map[string]string, len(h))
|
||||
for k, hv := range h {
|
||||
out.Headers[k] = asString(hv)
|
||||
}
|
||||
}
|
||||
if j, ok := m["json"]; ok && j != nil {
|
||||
out.JSON = j
|
||||
}
|
||||
// An empty table is a handler bug — it means the author forgot to say what to
|
||||
// return. A table carrying ONLY a status is not: `{status=204}` is the normal
|
||||
// way to answer with no body, and treating it as an error would make the
|
||||
// correct spelling unusable.
|
||||
if out.Body == "" && out.JSON == nil && out.Headers == nil && len(m) == 0 {
|
||||
return nil, fmt.Errorf("handler returned an empty table; return " +
|
||||
"{status=…, body=…}, {status=…, json=…}, or {status=…} for no body")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// InvokeHandler calls a plugin's HTTP handler function with a request table and
|
||||
// returns the decoded response.
|
||||
//
|
||||
// It reuses the same guard-in-Lua contract as the hooks: the function is called
|
||||
// through __llmsproxy_call_hook, so a plugin error comes back as (nil, message)
|
||||
// and never as a C-level abort.
|
||||
func (ps *Plugins) InvokeHandler(plugin, fnName string, req *handlerRequest) (*handlerResponse, error) {
|
||||
p := ps.find(plugin)
|
||||
if p == nil {
|
||||
return nil, fmt.Errorf("plugin %s not found", plugin)
|
||||
}
|
||||
p.mu.Lock()
|
||||
w := p.state
|
||||
p.mu.Unlock()
|
||||
if w == nil {
|
||||
return nil, fmt.Errorf("plugin %s has no state", plugin)
|
||||
}
|
||||
|
||||
// The whole call happens under the plugin lock, network waits included —
|
||||
// same property as Call, and for the same reason: one Lua state.
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
L := w.L
|
||||
L.SetTop(0)
|
||||
defer L.SetTop(0)
|
||||
|
||||
L.GetGlobal(pluginGlobal)
|
||||
if L.IsNil(-1) {
|
||||
return nil, fmt.Errorf("plugin table missing")
|
||||
}
|
||||
plug := L.GetTop()
|
||||
L.GetGlobal(hookGuardName)
|
||||
if L.IsNil(-1) {
|
||||
L.Pop(1)
|
||||
}
|
||||
L.GetField(plug, fnName)
|
||||
if !L.IsFunction(-1) {
|
||||
return nil, fmt.Errorf("handler %s not found in plugin %s", fnName, plugin)
|
||||
}
|
||||
|
||||
pushGoValue(L, plainForLua(map[string]interface{}{
|
||||
"method": req.Method,
|
||||
"path": req.Path,
|
||||
"query": stringMapToAny(req.Query),
|
||||
"params": stringMapToAny(req.Params),
|
||||
"headers": stringMapToAny(req.Headers),
|
||||
"body": req.Body,
|
||||
"role": req.Role,
|
||||
"key": req.Key,
|
||||
}))
|
||||
if err := L.Call(2, 2); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
errMsg := ""
|
||||
if L.GetTop() >= 2 {
|
||||
if !L.IsNil(-1) {
|
||||
errMsg = L.ToString(-1)
|
||||
}
|
||||
L.Pop(1)
|
||||
}
|
||||
if errMsg != "" {
|
||||
return nil, fmt.Errorf("plugin %s: %s", plugin, errMsg)
|
||||
}
|
||||
if L.GetTop() < 1 || L.IsNil(-1) {
|
||||
return nil, fmt.Errorf("handler %s returned nothing", fnName)
|
||||
}
|
||||
var raw interface{}
|
||||
if err := luaToJSON(L, -1, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseHandlerResponse(raw)
|
||||
}
|
||||
|
||||
func stringMapToAny(m map[string]string) map[string]interface{} {
|
||||
if len(m) == 0 {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
out := make(map[string]interface{}, len(m))
|
||||
for k, v := range m {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// HandlerRequest is the request table handed to a plugin HTTP handler.
|
||||
// Exported because the gateway package constructs it.
|
||||
type HandlerRequest = handlerRequest
|
||||
|
||||
// HandlerResponse is what a handler returns, after decoding.
|
||||
type HandlerResponse = handlerResponse
|
||||
|
||||
// WriteHandlerResponse renders a handler response onto the gateway's writer.
|
||||
func WriteHandlerResponse(w http.ResponseWriter, r *HandlerResponse) {
|
||||
writeHTTPHandlerResponse(w, r)
|
||||
}
|
||||
|
||||
// HTTPHandlers returns the registered endpoints across all plugins, each tagged
|
||||
// with its owner so the dispatcher knows which Lua state to enter.
|
||||
func (ps *Plugins) HTTPHandlers() []HTTPHandler {
|
||||
ps.mu.RLock()
|
||||
defer ps.mu.RUnlock()
|
||||
var out []HTTPHandler
|
||||
for _, p := range ps.plugins {
|
||||
if p.http == nil || p.LoadError != "" {
|
||||
continue
|
||||
}
|
||||
for _, declared := range p.http {
|
||||
// effectiveAccess applies the operator's per-endpoint override, so the
|
||||
// dispatcher enforces what the WebUI shows rather than what the .lua
|
||||
// declared.
|
||||
h := ps.effectiveAccess(p, declared)
|
||||
h.Plugin = p.Info.Name
|
||||
out = append(out, h)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Allows is the exported form of the access rule, so the gateway dispatcher does
|
||||
// not need a duplicate copy of it in its own package.
|
||||
func (h HTTPHandler) Allows(role, keyName string) bool { return h.allows(role, keyName) }
|
||||
|
||||
// HTTPEndpointAccess is the operator's view of one endpoint's permissions,
|
||||
// returned by /api/plugins so the WebUI can render what is exposed without
|
||||
// parsing .lua files.
|
||||
type HTTPEndpointAccess struct {
|
||||
Plugin string `json:"plugin"`
|
||||
Path string `json:"path"`
|
||||
FullPath string `json:"full_path"`
|
||||
Method string `json:"method"`
|
||||
Handler string `json:"handler"`
|
||||
Roles []string `json:"roles,omitempty"`
|
||||
Keys []string `json:"keys,omitempty"`
|
||||
Public bool `json:"public"`
|
||||
Description string `json:"description,omitempty"`
|
||||
// OverrideRoles / OverrideKeys carry the operator's own restriction, which
|
||||
// REPLACES whatever the plugin declared. Empty means "use the plugin's".
|
||||
//
|
||||
// This is the mechanism the admin uses to narrow (or widen) an endpoint's
|
||||
// reachable keys without editing the plugin: a bundled .lua is overwritten on
|
||||
// the next upgrade, and a hand-installed one is not something the operator
|
||||
// should have to maintain.
|
||||
OverrideRoles []string `json:"override_roles,omitempty"`
|
||||
OverrideKeys []string `json:"override_keys,omitempty"`
|
||||
}
|
||||
|
||||
// endpointAccess returns the access table for every registered endpoint, with
|
||||
// operator overrides folded in.
|
||||
func (ps *Plugins) endpointAccess() []HTTPEndpointAccess {
|
||||
ps.mu.RLock()
|
||||
defer ps.mu.RUnlock()
|
||||
return ps.endpointAccessLocked()
|
||||
}
|
||||
|
||||
// endpointAccessLocked is the body of endpointAccess for callers that already
|
||||
// hold ps.mu — VisibilityOverrides walks the same map and must not re-lock.
|
||||
func (ps *Plugins) endpointAccessLocked() []HTTPEndpointAccess {
|
||||
var out []HTTPEndpointAccess
|
||||
for _, p := range ps.plugins {
|
||||
if p.http == nil || p.LoadError != "" {
|
||||
continue
|
||||
}
|
||||
for _, h := range p.http {
|
||||
// The override key is per ENDPOINT (plugin#path), not per plugin.
|
||||
// Reading the plugin-level key here reported the plugin's own
|
||||
// audience override as if it governed every endpoint, and reported
|
||||
// no endpoint override at all — so the UI drew the DECLARED roles
|
||||
// after an operator had saved a change, and re-saving from that
|
||||
// display silently reverted the restriction.
|
||||
ovRoles, ovKeys := splitAccessSpec(ps.visibility[endpointOverrideKey(p.Info.Name, h.Path)])
|
||||
out = append(out, HTTPEndpointAccess{
|
||||
Plugin: p.Info.Name, Path: h.Path, FullPath: h.routePath(),
|
||||
Method: h.Method, Handler: h.Handler,
|
||||
Roles: h.Roles, Keys: h.Keys, Public: h.Public,
|
||||
Description: h.Description,
|
||||
OverrideRoles: ovRoles, OverrideKeys: ovKeys,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// EndpointAccess exposes the table to the gateway package.
|
||||
func (ps *Plugins) EndpointAccess() []HTTPEndpointAccess { return ps.endpointAccess() }
|
||||
|
||||
// SetEndpointAccess narrows or widens one endpoint's reachable keys/roles.
|
||||
//
|
||||
// It is an OVERRIDE, applied on top of what the plugin declared, and stored in
|
||||
// the same per-plugin visibility map the UI audience uses. An empty value clears
|
||||
// it. Overriding per endpoint rather than per plugin is what the operator
|
||||
// actually wants: /health is fine for every key while /balance is not, and
|
||||
// those two live in the same plugin.
|
||||
func (ps *Plugins) SetEndpointAccess(plugin, path, spec string) error {
|
||||
p := ps.find(plugin)
|
||||
if p == nil {
|
||||
return fmt.Errorf("plugin %s not found", plugin)
|
||||
}
|
||||
found := false
|
||||
for _, h := range p.http {
|
||||
if h.Path == path {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("plugin %s has no endpoint %q", plugin, path)
|
||||
}
|
||||
ps.mu.Lock()
|
||||
defer ps.mu.Unlock()
|
||||
if ps.visibility == nil {
|
||||
ps.visibility = visibilityOverrides{}
|
||||
}
|
||||
key := endpointOverrideKey(plugin, path)
|
||||
if strings.TrimSpace(spec) == "" {
|
||||
delete(ps.visibility, key)
|
||||
} else {
|
||||
ps.visibility[key] = spec
|
||||
}
|
||||
ps.saveAccessOverridesLocked()
|
||||
return nil
|
||||
}
|
||||
|
||||
// endpointOverrideKey namespaces a per-endpoint override so it cannot collide
|
||||
// with a whole-plugin audience override of the same plugin name.
|
||||
func endpointOverrideKey(plugin, path string) string {
|
||||
return plugin + "#" + path
|
||||
}
|
||||
|
||||
// effectiveAccess applies an endpoint override to the declared handler.
|
||||
func (ps *Plugins) effectiveAccess(p *Plugin, h HTTPHandler) HTTPHandler {
|
||||
ps.mu.RLock()
|
||||
ov := ps.visibility[endpointOverrideKey(p.Info.Name, h.Path)]
|
||||
ps.mu.RUnlock()
|
||||
if ov == "" {
|
||||
return h
|
||||
}
|
||||
out := h
|
||||
out.Roles, out.Keys = nil, nil
|
||||
out.Public = false
|
||||
for _, part := range strings.Split(ov, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
switch {
|
||||
case part == "":
|
||||
case part == "public":
|
||||
out.Public = true
|
||||
case strings.HasPrefix(part, "@"):
|
||||
out.Keys = append(out.Keys, strings.TrimPrefix(part, "@"))
|
||||
default:
|
||||
out.Roles = append(out.Roles, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// knownKeyNames is set by the gateway during wiring so ValidateAccessSpec can
|
||||
// refuse an allowlist naming a key that does not exist.
|
||||
//
|
||||
// Without the check, an allowlist naming a typo ("homeagent" vs "home_agent")
|
||||
// is stored, saved, shown in the WebUI as configured — and refuses every caller,
|
||||
// including the one the operator meant. The failure looks like the endpoint being
|
||||
// broken rather than the name being wrong.
|
||||
var knownKeyNames func() []string
|
||||
|
||||
// SetKeyNameLookup registers the source of configured gateway key names.
|
||||
func SetKeyNameLookup(fn func() []string) { knownKeyNames = fn }
|
||||
|
||||
// ValidateAccessSpec checks an access spec against the configured keys and roles.
|
||||
//
|
||||
// Roles are checked against the two that exist (admin, user) rather than left
|
||||
// free-form: a role that does not exist produces an endpoint nobody can reach,
|
||||
// which is the same silent failure as a bad key name.
|
||||
func (ps *Plugins) ValidateAccessSpec(spec string) error {
|
||||
spec = strings.TrimSpace(spec)
|
||||
if spec == "" {
|
||||
return nil
|
||||
}
|
||||
known := map[string]bool{"public": true, "admin": true, "user": true}
|
||||
if knownKeyNames != nil {
|
||||
for _, n := range knownKeyNames() {
|
||||
if n != "" {
|
||||
known[strings.ToLower(n)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, part := range strings.Split(spec, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
name := part
|
||||
if strings.HasPrefix(name, "@") {
|
||||
name = strings.TrimPrefix(name, "@")
|
||||
}
|
||||
if name == "" {
|
||||
return fmt.Errorf("access entry %q names no key", part)
|
||||
}
|
||||
if !known[strings.ToLower(name)] {
|
||||
return fmt.Errorf("access entry %q is neither a role (admin/user/public) "+
|
||||
"nor a configured key name", part)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// splitAccessSpec turns an override string into role and key lists. "@name" is a
|
||||
// key; a bare word is a role. Shared by the reporting path and the enforcement
|
||||
// path so the two can never disagree about what an override means.
|
||||
func splitAccessSpec(spec string) (roles, keys []string) {
|
||||
if strings.TrimSpace(spec) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
for _, part := range strings.Split(spec, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
switch {
|
||||
case part == "", part == "public":
|
||||
// "public" is an enforcement concern, not a listing; it is reported
|
||||
// via the Public field.
|
||||
case strings.HasPrefix(part, "@"):
|
||||
keys = append(keys, strings.TrimPrefix(part, "@"))
|
||||
default:
|
||||
roles = append(roles, part)
|
||||
}
|
||||
}
|
||||
return roles, keys
|
||||
}
|
||||
430
internal/lua/plugin_http_handler_test.go
Normal file
430
internal/lua/plugin_http_handler_test.go
Normal file
@ -0,0 +1,430 @@
|
||||
package lua
|
||||
|
||||
// Plugin-registered HTTP endpoints.
|
||||
//
|
||||
// Three properties have to hold, and none of them is visible from the plugin's
|
||||
// own point of view:
|
||||
//
|
||||
// 1. Auth and roles are enforced by the GATEWAY. A plugin that forgets to
|
||||
// declare roles gets admin-only, not open — the failure mode of "public by
|
||||
// omission" is an internet-scannable endpoint inside the gateway process.
|
||||
// 2. A handler error is a value, never a panic. The pcall guard is the same one
|
||||
// the hooks use, for the same reason.
|
||||
// 3. The plugin cannot steer the gateway's own response framing: a nonsense
|
||||
// status becomes a 500, and Content-Length / hop-by-hop headers are dropped.
|
||||
//
|
||||
// These tests drive the declaration layer and the response writer directly; the
|
||||
// router-level dispatch is covered in the gateway package.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const httpPlugin = `
|
||||
local P = {}
|
||||
P.name = "api"
|
||||
P.version = "1.0.0"
|
||||
P.exports = {}
|
||||
P.http = {
|
||||
{
|
||||
path = "health",
|
||||
handler = "health",
|
||||
roles = { "admin" },
|
||||
description = "liveness",
|
||||
},
|
||||
{
|
||||
path = "echo/:what",
|
||||
method = "POST",
|
||||
handler = "echo",
|
||||
roles = { "admin", "user" },
|
||||
},
|
||||
}
|
||||
function P.health(req)
|
||||
return { status = 200, json = { ok = true, role = req.role, key = req.key } }
|
||||
end
|
||||
function P.echo(req)
|
||||
return { status = 201, json = { what = req.params.what, body = req.body } }
|
||||
end
|
||||
return P
|
||||
`
|
||||
|
||||
func TestPluginRegistersHTTPHandlers(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(NewVM(dir), dir)
|
||||
if err := ps.LoadSource("api", httpPlugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hs := ps.HTTPHandlers()
|
||||
if len(hs) != 2 {
|
||||
t.Fatalf("expected 2 registered endpoints, got %d: %+v", len(hs), hs)
|
||||
}
|
||||
for _, h := range hs {
|
||||
if h.Plugin != "api" {
|
||||
t.Errorf("handler %q has no owner; the dispatcher could not route it", h.Path)
|
||||
}
|
||||
if h.Method == "" {
|
||||
t.Errorf("handler %q has no method; the gateway would answer 405 forever", h.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerRoleRules(t *testing.T) {
|
||||
// A handler with no roles and no public opt-out is ADMIN-ONLY. This is the
|
||||
// decision the whole feature turns on: "I forgot to declare roles" must not
|
||||
// mean "anyone on the internet".
|
||||
open := HTTPHandler{Path: "x", Handler: "h"}
|
||||
if open.allows("user", "") {
|
||||
t.Error("an undeclared handler must default to admin-only")
|
||||
}
|
||||
if !open.allows("admin", "") {
|
||||
t.Error("an undeclared handler must still serve admin")
|
||||
}
|
||||
// The explicit opt-out is how a plugin says "this one really is public".
|
||||
pub := HTTPHandler{Path: "x", Handler: "h", Public: true}
|
||||
if !pub.allows("user", "") || !pub.allows("", "") {
|
||||
t.Error("public=true must admit any role")
|
||||
}
|
||||
// Declared roles.
|
||||
scoped := HTTPHandler{Path: "x", Roles: []string{"admin", "user"}}
|
||||
if !scoped.allows("user", "homeagent") || !scoped.allows("admin", "admin") {
|
||||
t.Error("a listed role must be admitted")
|
||||
}
|
||||
if (HTTPHandler{Path: "x", Roles: []string{"admin"}}).allows("user", "homeagent") {
|
||||
t.Error("an unlisted role must be refused")
|
||||
}
|
||||
}
|
||||
|
||||
// Naming keys is how the operator grants one collaborator access without opening
|
||||
// the endpoint to every user key. Two properties matter and neither is obvious:
|
||||
// a named key wins even when the role rule would have refused it, and the named
|
||||
// list REPLACES the role rule rather than adding to it.
|
||||
func TestHandlerKeyRules(t *testing.T) {
|
||||
h := HTTPHandler{Path: "x", Roles: []string{"admin"}, Keys: []string{"homeagent"}}
|
||||
if !h.allows("user", "homeagent") {
|
||||
t.Error("a named key must be admitted even though its role is not listed")
|
||||
}
|
||||
// The replacement semantics: listing one key must NOT also admit every user
|
||||
// key through the role rule, which is what an OR would have done.
|
||||
if h.allows("user", "club") {
|
||||
t.Error("naming keys must replace the role rule, not add to it")
|
||||
}
|
||||
if h.allows("user", "") {
|
||||
t.Error("an unnamed key must not match a named-key allowlist")
|
||||
}
|
||||
// Admin is always admitted, so a typo in the list never locks the operator
|
||||
// out of the endpoint they are trying to tighten.
|
||||
if !h.allows("admin", "admin") {
|
||||
t.Error("admin must always reach an endpoint")
|
||||
}
|
||||
// Name matching is case-insensitive; a config written by hand varies.
|
||||
if !(HTTPHandler{Path: "x", Keys: []string{"HomeAgent"}}).allows("user", "homeagent") {
|
||||
t.Error("key names should match case-insensitively")
|
||||
}
|
||||
}
|
||||
|
||||
// The stored override replaces whatever the plugin declared — this is the admin
|
||||
// editing access without touching a .lua that an upgrade would overwrite.
|
||||
func TestEndpointAccessOverrideReplacesDeclaration(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(NewVM(dir), dir)
|
||||
if err := ps.LoadSource("api", httpPlugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// health is declared admin-only; widen it to one named key.
|
||||
if err := ps.SetEndpointAccess("api", "health", "@homeagent"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var found *HTTPHandler
|
||||
for _, h := range ps.HTTPHandlers() {
|
||||
if h.Path == "health" {
|
||||
hh := h
|
||||
found = &hh
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatal("health endpoint disappeared after an access override")
|
||||
}
|
||||
if len(found.Roles) != 0 {
|
||||
t.Errorf("the declared roles should be replaced, got %v", found.Roles)
|
||||
}
|
||||
if len(found.Keys) != 1 || found.Keys[0] != "homeagent" {
|
||||
t.Errorf("override keys = %v, want [homeagent]", found.Keys)
|
||||
}
|
||||
if !found.allows("user", "homeagent") || found.allows("user", "club") {
|
||||
t.Error("the override is not in force")
|
||||
}
|
||||
// Clearing restores the plugin's own declaration.
|
||||
if err := ps.SetEndpointAccess("api", "health", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, h := range ps.HTTPHandlers() {
|
||||
if h.Path == "health" {
|
||||
if len(h.Keys) != 0 {
|
||||
t.Errorf("clearing the override should restore the declaration, got %v", h.Keys)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Error("health endpoint vanished")
|
||||
}
|
||||
|
||||
func TestSetEndpointAccessRejectsUnknownTarget(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(NewVM(dir), dir)
|
||||
if err := ps.LoadSource("api", httpPlugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.SetEndpointAccess("api", "nope", "@homeagent"); err == nil {
|
||||
t.Error("setting access on a path the plugin does not expose must fail loudly")
|
||||
}
|
||||
if err := ps.SetEndpointAccess("nosuchplugin", "health", "@homeagent"); err == nil {
|
||||
t.Error("setting access for an unknown plugin must fail loudly")
|
||||
}
|
||||
}
|
||||
|
||||
// A stored allowlist naming a key that does not exist refuses every caller while
|
||||
// looking perfectly configured. ValidateAccessSpec exists to catch that at write
|
||||
// time instead.
|
||||
func TestValidateAccessSpec(t *testing.T) {
|
||||
SetKeyNameLookup(func() []string { return []string{"homeagent", "club"} })
|
||||
t.Cleanup(func() { SetKeyNameLookup(nil) })
|
||||
|
||||
for _, ok := range []string{"admin", "user", "public", "@homeagent",
|
||||
"admin,@homeagent,@club", " admin , @club "} {
|
||||
if err := (&Plugins{}).ValidateAccessSpec(ok); err != nil {
|
||||
t.Errorf("spec %q should be accepted: %v", ok, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"@nosuchkey", "superuser", "@", "admin,@typo_key"} {
|
||||
if err := (&Plugins{}).ValidateAccessSpec(bad); err == nil {
|
||||
t.Errorf("spec %q must be refused: it is neither a role nor a configured key", bad)
|
||||
}
|
||||
}
|
||||
if err := (&Plugins{}).ValidateAccessSpec(""); err != nil {
|
||||
t.Errorf("an empty spec clears the override and must be accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerPathValidationRejectsEscapes(t *testing.T) {
|
||||
// ":name" with empty segments around it is legal — the trims below reduce it
|
||||
// to ":name". What must be refused is an empty PARAM NAME, which cannot be
|
||||
// captured into request.params.
|
||||
for _, bad := range []string{"", " ", "a/../b", "a//b", "wild*card", "q?x", "/:/"} {
|
||||
if err := validateHandlerPath(bad); err == nil {
|
||||
t.Errorf("path %q must be rejected: it can escape the mount or make the "+
|
||||
"route table ambiguous", bad)
|
||||
}
|
||||
}
|
||||
for _, ok := range []string{"health", "a/b", "echo/:what", "v1/things/:id", "/:name/"} {
|
||||
if err := validateHandlerPath(ok); err != nil {
|
||||
t.Errorf("path %q should be accepted: %v", ok, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerMethodValidation(t *testing.T) {
|
||||
if m, err := normalizeHandlerMethod(""); err != nil || m != http.MethodGet {
|
||||
t.Errorf("an empty method should default to GET, got %q %v", m, err)
|
||||
}
|
||||
if _, err := normalizeHandlerMethod("TRACE"); err == nil {
|
||||
t.Error("TRACE must be refused")
|
||||
}
|
||||
if m, err := normalizeHandlerMethod("post"); err != nil || m != http.MethodPost {
|
||||
t.Errorf("method should normalise to upper case, got %q %v", m, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvokeHandlerReturnsStructuredData(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(NewVM(dir), dir)
|
||||
if err := ps.LoadSource("api", httpPlugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := ps.InvokeHandler("api", "health", &HandlerRequest{
|
||||
Method: http.MethodGet, Path: "health", Role: "admin", Key: "sk-test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("invoke: %v", err)
|
||||
}
|
||||
if resp.Status != 200 {
|
||||
t.Errorf("status = %d, want 200", resp.Status)
|
||||
}
|
||||
j, ok := resp.JSON.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("json = %T, want a table (the handler's nested value must survive)", resp.JSON)
|
||||
}
|
||||
if j["ok"] != true || j["role"] != "admin" {
|
||||
t.Errorf("handler data crossed wrong: %v", j)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvokeHandlerParamsAndBodyReachLua(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(NewVM(dir), dir)
|
||||
if err := ps.LoadSource("api", httpPlugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := ps.InvokeHandler("api", "echo", &HandlerRequest{
|
||||
Method: http.MethodPost, Path: "echo/thing",
|
||||
Params: map[string]string{"what": "thing"},
|
||||
Body: `{"n":1}`, Role: "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("invoke: %v", err)
|
||||
}
|
||||
j, _ := resp.JSON.(map[string]interface{})
|
||||
if resp.Status != 201 {
|
||||
t.Errorf("status = %d, want 201 (the handler chose it)", resp.Status)
|
||||
}
|
||||
if j["what"] != "thing" {
|
||||
t.Errorf("path param did not reach the handler: %v", j)
|
||||
}
|
||||
if j["body"] != `{"n":1}` {
|
||||
t.Errorf("body did not reach the handler: %v", j)
|
||||
}
|
||||
}
|
||||
|
||||
// A handler that raises must produce an error, never a process abort — the same
|
||||
// SIGSEGV class of bug the hook guard exists for.
|
||||
func TestInvokeHandlerErrorIsValueNotPanic(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(NewVM(dir), dir)
|
||||
const boom = `
|
||||
local P = {}
|
||||
P.name = "boom"
|
||||
P.version = "1.0.0"
|
||||
P.exports = {}
|
||||
P.http = { { path = "blow", handler = "blow", roles = { "admin" } } }
|
||||
function P.blow(req)
|
||||
error("handler exploded")
|
||||
end
|
||||
return P
|
||||
`
|
||||
if err := ps.LoadSource("boom", boom); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ps.InvokeHandler("boom", "blow", &HandlerRequest{Role: "admin"}); err == nil {
|
||||
t.Fatal("a raising handler must surface as an error")
|
||||
}
|
||||
}
|
||||
|
||||
// The plugin does not get to decide how the gateway frames its response.
|
||||
func TestHandlerResponseFramingIsKernelOwned(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in *HandlerResponse
|
||||
want int
|
||||
}{
|
||||
{"status 0 becomes 200", &HandlerResponse{Status: 0, Body: "x"}, 200},
|
||||
{"status 9999 becomes 500", &HandlerResponse{Status: 9999, Body: "x"}, 500},
|
||||
{"status -1 becomes 500", &HandlerResponse{Status: -1, Body: "x"}, 500},
|
||||
{"explicit 404 is kept", &HandlerResponse{Status: 404, Body: "x"}, 404},
|
||||
}
|
||||
for _, c := range cases {
|
||||
w := httptest.NewRecorder()
|
||||
writeHTTPHandlerResponse(w, c.in)
|
||||
if w.Code != c.want {
|
||||
t.Errorf("%s: status = %d, want %d", c.name, w.Code, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
// A handler must not be able to desync the gateway's own framing.
|
||||
w := httptest.NewRecorder()
|
||||
writeHTTPHandlerResponse(w, &HandlerResponse{
|
||||
Status: 200, Body: "hello",
|
||||
Headers: map[string]string{
|
||||
"Content-Length": "99999",
|
||||
"Connection": "close",
|
||||
"Transfer-Encoding": "chunked",
|
||||
"X-Custom": "kept",
|
||||
},
|
||||
})
|
||||
if got := w.Header().Get("Content-Length"); got != "" {
|
||||
t.Errorf("a plugin set Content-Length (%s); the body length is the kernel's", got)
|
||||
}
|
||||
if got := w.Header().Get("X-Custom"); got != "kept" {
|
||||
t.Errorf("a normal header should survive, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A malformed declaration must not take the whole plugin down.
|
||||
func TestBadHTTPRouteDoesNotBreakPluginLoad(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(NewVM(dir), dir)
|
||||
const mixed = `
|
||||
local P = {}
|
||||
P.name = "mixed"
|
||||
P.version = "1.0.0"
|
||||
P.exports = {}
|
||||
P.state = { seen = true }
|
||||
P.ui = { page = { page_id = "m", title = "M", mount = "<div/>" } }
|
||||
P.http = {
|
||||
{ path = "../escape", handler = "h", roles = { "admin" } },
|
||||
{ path = "fine", handler = "h", roles = { "admin" } },
|
||||
}
|
||||
function P.h(req) return { status = 200, body = "ok" } end
|
||||
return P
|
||||
`
|
||||
if err := ps.LoadSource("mixed", mixed); err != nil {
|
||||
t.Fatalf("a bad route must not fail the plugin load: %v", err)
|
||||
}
|
||||
// The good route survives; the bad one is simply absent.
|
||||
hs := ps.HTTPHandlers()
|
||||
if len(hs) != 1 || hs[0].Path != "fine" {
|
||||
t.Fatalf("expected only the valid route, got %+v", hs)
|
||||
}
|
||||
// And the plugin's own page/state still work.
|
||||
if ps.State("mixed") == nil {
|
||||
t.Error("the plugin's state was lost because of a bad route")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHandlerResponseShapes(t *testing.T) {
|
||||
if _, err := parseHandlerResponse("just a string"); err == nil {
|
||||
t.Error("a non-table return must be an error, not an empty 200")
|
||||
}
|
||||
if _, err := parseHandlerResponse(map[string]interface{}{}); err == nil {
|
||||
t.Error("an empty table should say so rather than returning a blank 200")
|
||||
}
|
||||
r, err := parseHandlerResponse(map[string]interface{}{
|
||||
"status": float64(204), "headers": map[string]interface{}{"X-A": "b"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.Status != 204 || r.Headers["X-A"] != "b" {
|
||||
t.Errorf("decoded %+v", r)
|
||||
}
|
||||
}
|
||||
// TestEndpointAccessReportsPerEndpointOverride guards the reporting side of the
|
||||
// override store.
|
||||
//
|
||||
// SetEndpointAccess already keyed overrides by plugin#path, but endpointAccess
|
||||
// read the plugin-level key. After an operator saved a restriction on ONE
|
||||
// endpoint, /api/plugins kept reporting the plugin's DECLARED roles, so the
|
||||
// plugin page drew the pre-change value and a later save from that screen
|
||||
// reverted the restriction. Authorization kept working — only the display and
|
||||
// the round trip through the UI were broken, which is why it went unnoticed.
|
||||
func TestEndpointAccessReportsPerEndpointOverride(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(NewVM(dir), dir)
|
||||
if err := ps.LoadSource("api", httpPlugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// httpPlugin registers "health" and "echo/:what".
|
||||
if err := ps.SetEndpointAccess("api", "health", "user"); err != nil {
|
||||
t.Fatalf("SetEndpointAccess: %v", err)
|
||||
}
|
||||
got := map[string][]string{}
|
||||
for _, e := range ps.EndpointAccess() {
|
||||
got[e.Path] = e.OverrideRoles
|
||||
}
|
||||
if len(got["health"]) != 1 || got["health"][0] != "user" {
|
||||
t.Errorf("health override not reported to the UI: %+v", got["health"])
|
||||
}
|
||||
if len(got["echo/:what"]) != 0 {
|
||||
t.Errorf("echo inherited an override it never had: %+v", got["echo/:what"])
|
||||
}
|
||||
}
|
||||
217
internal/lua/plugin_http_test.go
Normal file
217
internal/lua/plugin_http_test.go
Normal file
@ -0,0 +1,217 @@
|
||||
package lua
|
||||
|
||||
// The plugin HTTP bridge is the one capability that lets a .lua file — installed
|
||||
// from a textarea in the WebUI — reach out on the gateway's own network. That
|
||||
// makes its DENY rules the security boundary, and these tests exist to pin the
|
||||
// refusals rather than the happy path.
|
||||
//
|
||||
// The failure this guards against is concrete: an operator installs a quota
|
||||
// plugin that reads a vendor's balance, and a later plugin (or a compromised
|
||||
// one) posts that gateway key material to an attacker, or reads the cloud
|
||||
// metadata endpoint, because __llmsproxy_http happened to be a general HTTP
|
||||
// client.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
golua "github.com/aarzilli/golua/lua"
|
||||
)
|
||||
|
||||
// newTestState returns a bare Lua state for the argument-decoding tests.
|
||||
func newTestState(t *testing.T) *golua.State {
|
||||
t.Helper()
|
||||
L := golua.NewState()
|
||||
t.Cleanup(L.Close)
|
||||
L.OpenLibs()
|
||||
return L
|
||||
}
|
||||
|
||||
func TestPluginHTTPAllowlistRejectsUnlistedHost(t *testing.T) {
|
||||
allow := newHTTPAllowlist([]string{"api.example.com"})
|
||||
for _, raw := range []string{
|
||||
"https://evil.test/steal",
|
||||
"https://169.254.169.254/latest/meta-data/",
|
||||
"https://api.example.com.evil.test/x", // suffix trick
|
||||
"https://user@evil.test/",
|
||||
} {
|
||||
if _, err := allow.checkURL(raw); err == nil {
|
||||
t.Fatalf("allowlist permitted %q — a plugin must not reach hosts "+
|
||||
"the operator never configured as a source", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginHTTPAllowlistAcceptsConfiguredHost(t *testing.T) {
|
||||
allow := newHTTPAllowlist([]string{"api.example.com"})
|
||||
for _, raw := range []string{
|
||||
"https://api.example.com/alpha/credits",
|
||||
"https://API.EXAMPLE.COM/x", // host compare must be case-insensitive
|
||||
"https://api.example.com:443/x",
|
||||
} {
|
||||
if _, err := allow.checkURL(raw); err != nil {
|
||||
t.Fatalf("allowlist refused configured host %q: %v", raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginHTTPRefusesPlainHTTPOffLoopback(t *testing.T) {
|
||||
allow := newHTTPAllowlist([]string{"api.example.com"})
|
||||
// Allowed host, but plain HTTP would put the API key on the wire in clear.
|
||||
if _, err := allow.checkURL("http://api.example.com/alpha"); err == nil {
|
||||
t.Fatal("plain http to an allowed non-loopback host must be refused")
|
||||
}
|
||||
// Loopback stays allowed: it cannot leave the machine, and the billing rules
|
||||
// UI reads the gateway's own API over plain HTTP in dev setups.
|
||||
for _, raw := range []string{"http://127.0.0.1:8081/api/x", "http://localhost:8081/x"} {
|
||||
if _, err := allow.checkURL(raw); err != nil {
|
||||
t.Fatalf("loopback http must stay usable, got %v for %q", err, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginHTTPRejectsBadSchemesAndMissingURL(t *testing.T) {
|
||||
allow := newHTTPAllowlist([]string{"api.example.com"})
|
||||
for _, raw := range []string{"file:///etc/passwd", "ftp://api.example.com/x", "gopher://x"} {
|
||||
if _, err := allow.checkURL(raw); err == nil {
|
||||
t.Fatalf("scheme in %q must be refused", raw)
|
||||
}
|
||||
}
|
||||
// An empty allowlist must deny, not fall open.
|
||||
if _, err := newHTTPAllowlist(nil).checkURL("https://api.example.com/x"); err == nil {
|
||||
t.Fatal("an unconfigured gateway must deny outbound HTTP entirely")
|
||||
}
|
||||
}
|
||||
|
||||
// A redirect is a hop the allowlist never saw. Following it would let an
|
||||
// allowed host bounce a plugin's request (and its headers) anywhere.
|
||||
func TestPluginHTTPDoesNotFollowRedirects(t *testing.T) {
|
||||
if httpClient.CheckRedirect == nil {
|
||||
t.Fatal("CheckRedirect must be set: following a redirect escapes the allowlist")
|
||||
}
|
||||
req, _ := http.NewRequest(http.MethodGet, "https://api.example.com/x", nil)
|
||||
err := httpClient.CheckRedirect(req, []*http.Request{req})
|
||||
if err != http.ErrUseLastResponse {
|
||||
t.Fatalf("CheckRedirect must return ErrUseLastResponse so the 3xx is "+
|
||||
"handed back unfollowed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginHTTPTimeoutAndBodyAreClamped(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
allow := newHTTPAllowlist([]string{"127.0.0.1"})
|
||||
u, err := allow.checkURL(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("loopback test server must be allowed: %v", err)
|
||||
}
|
||||
// An absurd timeout is clamped instead of honoured.
|
||||
res, err := performHTTP(u, httpRequestSpec{Timeout: 24 * time.Hour})
|
||||
if err != nil {
|
||||
t.Fatalf("clamped request failed: %v", err)
|
||||
}
|
||||
if res.Status != 200 || !strings.Contains(res.Body, `"ok"`) {
|
||||
t.Fatalf("unexpected result: status=%d body=%q", res.Status, res.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginHTTPOversizedBodyIsRefused(t *testing.T) {
|
||||
big := strings.Repeat("x", httpMaxResponse+64)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(big))
|
||||
}))
|
||||
defer srv.Close()
|
||||
allow := newHTTPAllowlist([]string{"127.0.0.1"})
|
||||
u, _ := allow.checkURL(srv.URL)
|
||||
// Refusing beats truncating: a truncated body parses as corrupt JSON and the
|
||||
// plugin would report a parse error instead of the real cause.
|
||||
if _, err := performHTTP(u, httpRequestSpec{}); err == nil {
|
||||
t.Fatal("an oversized response must be refused, not silently truncated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginHTTPErrorPathIsPairNotRaise(t *testing.T) {
|
||||
// Lua-side contract: the bridge returns (nil, message) on every failure so a
|
||||
// plugin mistake never raises through golua (see the SIGSEGV note on
|
||||
// hookGuardSrc — an escaping error can take the gateway down).
|
||||
allow := newHTTPAllowlist([]string{"api.example.com"})
|
||||
if _, err := allow.checkURL("https://evil.test/"); err == nil {
|
||||
t.Fatal("expected refusal")
|
||||
}
|
||||
// readHTTPRequestSpec must reject a non-table without touching the stack.
|
||||
L := newTestState(t)
|
||||
if _, err := readHTTPRequestSpec(L); err == nil {
|
||||
t.Fatal("a missing argument table must be an error, not a panic")
|
||||
}
|
||||
}
|
||||
// The bridge must be reachable under the name plugins actually call.
|
||||
//
|
||||
// This exists because the registration once used the CONSTANT'S IDENTIFIER as
|
||||
// the Lua global name (`registerFn(L, "httpGlobal", …)`) rather than its VALUE,
|
||||
// so the bridge was published as `httpGlobal` while every plugin called
|
||||
// `__llmsproxy_http`. The failure only appeared at runtime, as
|
||||
// "attempt to call global '__llmsproxy_http' (a nil value)" on the first real
|
||||
// request — after the unit tests were green, because they drove the Go-side
|
||||
// helpers directly and never went through Lua name resolution.
|
||||
//
|
||||
// So this test boots a real plugin and calls the bridge the way a plugin does.
|
||||
func TestPluginHTTPBridgeIsReachableUnderItsLuaName(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(NewVM(dir), dir)
|
||||
ps.SetOutboundHosts([]string{"127.0.0.1"})
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"credits":{"monthlyCredits":1.5}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// The plugin asks for a loopback URL, which is allowed without being
|
||||
// configured as a source. It type-checks both the table and arg.url, so a
|
||||
// malformed call is reported by the plugin rather than as a bewildering URL
|
||||
// parse error from the bridge.
|
||||
src := `
|
||||
local P = {}
|
||||
P.name = "httpprobe"
|
||||
P.version = "1.0.0"
|
||||
P.exports = { "probe" }
|
||||
-- Call passes the WHOLE argument table as a single Lua argument, which is the
|
||||
-- same convention the hooks use and what a plugin page's pluginAPI.call ends up
|
||||
-- producing. Taking the table (not a bare string) is the correct shape.
|
||||
function P.probe(arg)
|
||||
if type(arg) ~= "table" then return { error = "probe takes the args table, got " .. type(arg) } end
|
||||
if type(arg.url) ~= "string" then return { error = "arg.url must be a string, got " .. type(arg.url) } end
|
||||
local res, err = ` + httpGlobal + `({ url = arg.url, method = "GET" })
|
||||
if res == nil then return { error = tostring(err) } end
|
||||
return { status = res.status, body = res.body }
|
||||
end
|
||||
return P
|
||||
`
|
||||
if err := ps.LoadSource("httpprobe", src); err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
got, err := ps.Call("httpprobe", "probe", map[string]interface{}{"url": srv.URL})
|
||||
if err != nil {
|
||||
t.Fatalf("call: %v", err)
|
||||
}
|
||||
m, _ := got.(map[string]interface{})
|
||||
if m == nil {
|
||||
t.Fatalf("probe returned %T, want a table", got)
|
||||
}
|
||||
// A nil global surfaces as {error = "attempt to call global ... (a nil value)"} —
|
||||
// which is a table too, so the error field is what distinguishes it.
|
||||
if e, ok := m["error"]; ok {
|
||||
t.Fatalf("the bridge is not reachable as %s: %v", httpGlobal, e)
|
||||
}
|
||||
if s, _ := m["status"].(float64); int(s) != 200 {
|
||||
t.Fatalf("status=%v body=%v, want 200 from the loopback test server", m["status"], m["body"])
|
||||
}
|
||||
if body, _ := m["body"].(string); !strings.Contains(body, "monthlyCredits") {
|
||||
t.Fatalf("body=%q, want the server's JSON", body)
|
||||
}
|
||||
}
|
||||
118
internal/lua/plugin_visibility.go
Normal file
118
internal/lua/plugin_visibility.go
Normal file
@ -0,0 +1,118 @@
|
||||
package lua
|
||||
|
||||
// Per-caller state scoping.
|
||||
//
|
||||
// Role visibility answers "may this role see the plugin at all". It does not
|
||||
// answer "which PART of its data", and for the billing plugin those are
|
||||
// different questions: the global figures (total spend, every source, every
|
||||
// model) are deployment data, while the per-key buckets describe one caller.
|
||||
//
|
||||
// So a plugin may declare a split, and the gateway then hands a non-admin
|
||||
// caller only the buckets that are theirs. The plugin declares it because only
|
||||
// the plugin author knows which of its buckets are global.
|
||||
|
||||
import "strings"
|
||||
|
||||
// StateScope is a plugin's declaration of which state buckets are global.
|
||||
type StateScope struct {
|
||||
// Global names the buckets that describe the whole deployment. Anything not
|
||||
// listed is treated as per-caller and filtered.
|
||||
Global []string `json:"global,omitempty"`
|
||||
// Depth gives the nesting level of each PER-CALLER bucket: 1 for
|
||||
// {maskedKey: bucket}, 2 for {day: {maskedKey: bucket}}. It is declared
|
||||
// because both shapes are maps and sniffing would confuse an empty bucket
|
||||
// with a nested one.
|
||||
Depth map[string]int `json:"depth,omitempty"`
|
||||
}
|
||||
|
||||
// IsGlobal reports whether a top-level state key is deployment-wide.
|
||||
func (s *StateScope) IsGlobal(key string) bool {
|
||||
if s == nil {
|
||||
return true
|
||||
}
|
||||
for _, g := range s.Global {
|
||||
if g == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// StateScopeFor returns a plugin's declared split, or nil when it declares none.
|
||||
func (ps *Plugins) StateScopeFor(name string) *StateScope {
|
||||
ps.mu.RLock()
|
||||
defer ps.mu.RUnlock()
|
||||
for _, p := range ps.plugins {
|
||||
if p.Info.Name != name || p.UI == nil {
|
||||
continue
|
||||
}
|
||||
return p.UI.StateScope
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// compileDepthMap normalizes the declared depth table, defaulting to 1 for a
|
||||
// per-caller bucket the author did not annotate.
|
||||
func compileDepthMap(in map[string]int) map[string]int {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]int, len(in))
|
||||
for k, v := range in {
|
||||
if v < 1 {
|
||||
v = 1
|
||||
}
|
||||
out[strings.TrimSpace(k)] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DepthOf returns the declared nesting level for a per-caller bucket, 1 by
|
||||
// default. A bucket the author did not annotate is assumed flat, which is the
|
||||
// common shape ({maskedKey: row}).
|
||||
func (s *StateScope) DepthOf(key string) int {
|
||||
if s == nil || s.Depth == nil {
|
||||
return 1
|
||||
}
|
||||
if d, ok := s.Depth[key]; ok {
|
||||
return d
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// VisibilityOverrides returns every operator override in force, keyed by plugin
|
||||
// name. An absent name means the plugin's own declaration applies.
|
||||
//
|
||||
// The WebUI needs this to show which visibility is IMPOSED, not merely which is
|
||||
// in effect: an operator comparing two deployments should not have to open a
|
||||
// .lua to learn whether a page is admin-only by the plugin's choice or by their
|
||||
// own override.
|
||||
func (ps *Plugins) VisibilityOverrides() map[string]string {
|
||||
ps.mu.RLock()
|
||||
defer ps.mu.RUnlock()
|
||||
if len(ps.visibility) == 0 {
|
||||
return nil
|
||||
}
|
||||
epKeys := make(map[string]struct{})
|
||||
for _, e := range ps.endpointAccessLocked() {
|
||||
epKeys[endpointOverrideKey(e.Plugin, e.Path)] = struct{}{}
|
||||
}
|
||||
out := make(map[string]string, len(ps.visibility))
|
||||
for k, v := range ps.visibility {
|
||||
// Per-endpoint overrides live in the SAME map under a "name#path" key.
|
||||
// They are endpoint policy, not a plugin audience, so leaking them here
|
||||
// both mislabels them and lets the plugin page write an audience of the
|
||||
// form "probe#hello" that no code ever reads as one.
|
||||
// Per-endpoint overrides share this map under a "name#path" key. They
|
||||
// are endpoint policy, not a plugin audience: reporting them here both
|
||||
// mislabels them and invites the plugin page to save an audience of the
|
||||
// form "probe#hello" that nothing reads as an audience. The set of
|
||||
// endpoint keys is already computed, so ask it rather than re-deriving
|
||||
// the shape from the key.
|
||||
if _, isEndpoint := epKeys[k]; isEndpoint {
|
||||
continue
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
193
internal/lua/plugin_visibility_test.go
Normal file
193
internal/lua/plugin_visibility_test.go
Normal file
@ -0,0 +1,193 @@
|
||||
package lua
|
||||
|
||||
// Plugin visibility: a plugin page is not automatically public data.
|
||||
//
|
||||
// The billing page renders GLOBAL cost across every source and the quota page
|
||||
// renders upstream balances. Both were served to any authenticated key because
|
||||
// /api/ui-inject applied no role filter — measured, not theorised: a user-role
|
||||
// key pulled the 18KB billing state and read the source names and the $8.86
|
||||
// total straight out of it.
|
||||
//
|
||||
// These tests pin the DENIALS. A visibility feature whose tests only assert that
|
||||
// an admin still sees everything passes while the leak is wide open.
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAudienceAllows(t *testing.T) {
|
||||
cases := []struct {
|
||||
want, role string
|
||||
ok bool
|
||||
why string
|
||||
}{
|
||||
{"", "user", true, "unset means everyone"},
|
||||
{"all", "user", true, "explicit all"},
|
||||
{"admin", "admin", true, "exact match"},
|
||||
{"admin", "user", false, "admin-only excludes user"},
|
||||
{"user", "user", true, "user-only admits user"},
|
||||
{"user", "admin", false, "user-only excludes admin"},
|
||||
{"admin,user", "user", true, "list admits a listed role"},
|
||||
{" admin , user ", "user", true, "whitespace is not significant"},
|
||||
{"ADMIN", "user", false, "case is normalised"},
|
||||
{"*", "user", false, `"*" must NOT be a synonym for all`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := audienceAllows(c.want, c.role); got != c.ok {
|
||||
t.Errorf("audienceAllows(%q, %q) = %v, want %v (%s)",
|
||||
c.want, c.role, got, c.ok, c.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginVisibilityHidesPageFromUserRole(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(NewVM(dir), dir)
|
||||
|
||||
// One admin-only plugin, one open plugin: the filter must drop exactly one
|
||||
// and leave the other intact, or a plugin author has no way to ship a mixed
|
||||
// set.
|
||||
const adminOnly = `
|
||||
local P = {}
|
||||
P.name = "adminonly"
|
||||
P.version = "1.0.0"
|
||||
P.exports = {}
|
||||
P.ui = { visibility = "admin", page = { page_id = "secret", title = "Secret", mount = "<div>secret</div>" } }
|
||||
return P
|
||||
`
|
||||
const open = `
|
||||
local P = {}
|
||||
P.name = "openpage"
|
||||
P.version = "1.0.0"
|
||||
P.exports = {}
|
||||
P.ui = { page = { page_id = "open", title = "Open", mount = "<div>open</div>" } }
|
||||
return P
|
||||
`
|
||||
for name, src := range map[string]string{"adminonly": adminOnly, "openpage": open} {
|
||||
if err := ps.LoadSource(name, src); err != nil {
|
||||
t.Fatalf("load %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
admin := ps.UIForRole("admin")
|
||||
if len(admin.Pages) != 2 {
|
||||
t.Errorf("admin should see both pages, got %d", len(admin.Pages))
|
||||
}
|
||||
|
||||
user := ps.UIForRole("user")
|
||||
for _, pg := range user.Pages {
|
||||
if pg.PageID == "secret" {
|
||||
t.Fatal("a user key can see the admin-only page — this is the leak")
|
||||
}
|
||||
}
|
||||
if len(user.Pages) != 1 || user.Pages[0].PageID != "open" {
|
||||
t.Fatalf("user should see exactly the open page, got %v", pageIDs(user.Pages))
|
||||
}
|
||||
}
|
||||
|
||||
// The operator must be able to impose visibility the plugin did not choose —
|
||||
// that is the whole point of the override, since a bundled .lua cannot be edited
|
||||
// without being overwritten on the next upgrade.
|
||||
func TestPluginVisibilityOverrideBeatsDeclaration(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(NewVM(dir), dir)
|
||||
const open = `
|
||||
local P = {}
|
||||
P.name = "openpage"
|
||||
P.version = "1.0.0"
|
||||
P.exports = {}
|
||||
P.ui = { page = { page_id = "open", title = "Open", mount = "<div>open</div>" } }
|
||||
return P
|
||||
`
|
||||
if err := ps.LoadSource("openpage", open); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ps.UIForRole("user").Pages) != 1 {
|
||||
t.Fatal("precondition: the page should start visible to user")
|
||||
}
|
||||
|
||||
ps.SetPluginVisibility("openpage", "admin")
|
||||
if got := ps.UIForRole("user").Pages; len(got) != 0 {
|
||||
t.Fatalf("the operator override was ignored: user still sees %v", pageIDs(got))
|
||||
}
|
||||
if ps.VisibilityOverride("openpage") != "admin" {
|
||||
t.Error("the override should be reportable so /api/plugins can show it")
|
||||
}
|
||||
|
||||
// Clearing restores the plugin's own declaration.
|
||||
ps.SetPluginVisibility("openpage", "")
|
||||
if got := ps.UIForRole("user").Pages; len(got) != 1 {
|
||||
t.Fatalf("clearing the override should restore visibility, got %v", pageIDs(got))
|
||||
}
|
||||
}
|
||||
|
||||
// A page may narrow visibility below the plugin default, so one plugin can ship
|
||||
// a public summary beside an admin-only detail page.
|
||||
func TestPluginPageCanNarrowBelowPluginDefault(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(NewVM(dir), dir)
|
||||
const mixed = `
|
||||
local P = {}
|
||||
P.name = "mixed"
|
||||
P.version = "1.0.0"
|
||||
P.exports = {}
|
||||
P.ui = {
|
||||
pages = {
|
||||
{ page_id = "summary", title = "Summary", mount = "<div>s</div>" },
|
||||
{ page_id = "details", title = "Details", mount = "<div>d</div>", audience = "admin" },
|
||||
},
|
||||
}
|
||||
return P
|
||||
`
|
||||
if err := ps.LoadSource("mixed", mixed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user := ps.UIForRole("user")
|
||||
if len(user.Pages) != 1 || user.Pages[0].PageID != "summary" {
|
||||
t.Fatalf("user should see only the summary, got %v", pageIDs(user.Pages))
|
||||
}
|
||||
if len(ps.UIForRole("admin").Pages) != 2 {
|
||||
t.Error("admin should see both pages")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginStateVisibleTo(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(NewVM(dir), dir)
|
||||
const adminOnly = `
|
||||
local P = {}
|
||||
P.name = "adminonly"
|
||||
P.version = "1.0.0"
|
||||
P.exports = {}
|
||||
P.state = { secret = "x" }
|
||||
P.ui = { visibility = "admin", page = { page_id = "s", title = "S", mount = "<div/>" } }
|
||||
return P
|
||||
`
|
||||
if err := ps.LoadSource("adminonly", adminOnly); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The data has to follow the page: a hidden UI with an open state endpoint is
|
||||
// the same leak with one more step.
|
||||
if ps.StateVisibleTo("adminonly", "user") {
|
||||
t.Error("user must not read an admin-only plugin's state")
|
||||
}
|
||||
if !ps.StateVisibleTo("adminonly", "admin") {
|
||||
t.Error("admin must still read it")
|
||||
}
|
||||
// Unknown plugin: unchanged behaviour, so a widget that is not installed does
|
||||
// not start answering 403.
|
||||
if !ps.StateVisibleTo("nosuchplugin", "user") {
|
||||
t.Error("an unknown plugin name must not become forbidden; that would change " +
|
||||
"behaviour for plugins that simply are not installed")
|
||||
}
|
||||
}
|
||||
|
||||
func pageIDs(pages []*UIPage) []string {
|
||||
out := make([]string, 0, len(pages))
|
||||
for _, p := range pages {
|
||||
if p != nil {
|
||||
out = append(out, p.PageID)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@ -32,6 +32,7 @@ package lua
|
||||
// less featureful, so the split is explicit and documented.
|
||||
|
||||
import (
|
||||
"log"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
@ -126,6 +127,13 @@ type UIExtension struct {
|
||||
// Elements are snippets injected into EXISTING pages, keyed by target page
|
||||
// id (e.g. "status", "keys"). Order within a target is plugin load order.
|
||||
Elements []UIElement `json:"elements,omitempty"`
|
||||
// Visibility is the plugin-wide default audience, applied to every page and
|
||||
// element this plugin contributes. A per-page Audience narrows it further.
|
||||
Visibility string `json:"visibility,omitempty"`
|
||||
// StateScope splits the plugin's published state into global and per-caller
|
||||
// buckets, for a plugin that is readable by more than one role but must not
|
||||
// hand one caller another's data.
|
||||
StateScope *StateScope `json:"state_scope,omitempty"`
|
||||
}
|
||||
|
||||
// UIPage is a plugin-provided page.
|
||||
@ -134,6 +142,20 @@ type UIPage struct {
|
||||
Title string `json:"title"` // sidebar label
|
||||
Icon string `json:"icon"` // optional inline SVG or short glyph
|
||||
Order int `json:"order"` // sidebar sort key (default 100)
|
||||
// Audience limits which API-key roles may see this page. "" and "all" mean
|
||||
// every authenticated role; "admin" hides it from user keys; a comma list
|
||||
// ("admin,user") names the roles allowed.
|
||||
//
|
||||
// This exists because a plugin page is not automatically public data. The
|
||||
// billing page shows GLOBAL cost across every source, and the quota page
|
||||
// shows upstream balances — neither belongs to whoever happens to hold a
|
||||
// user key, yet both were served to them because /api/ui-inject applied no
|
||||
// role filter at all.
|
||||
//
|
||||
// The plugin declares its own default and the gateway may override it (see
|
||||
// SetPluginVisibility), because the plugin author cannot know the operator's
|
||||
// policy but the operator must be able to impose one.
|
||||
Audience string `json:"audience,omitempty"`
|
||||
// Mount is the page body. It may contain <script> and <style>; the kernel
|
||||
// executes scripts AFTER injecting the HTML so the DOM exists, and exposes
|
||||
// `pluginAPI` to them (see docs/plugins.md).
|
||||
@ -145,6 +167,10 @@ type UIElement struct {
|
||||
// Target is the id of the pane to inject into: "status", "chat", "keys",
|
||||
// "sort", "sources" or "adapters".
|
||||
Target string `json:"target"`
|
||||
// Audience is the same role filter as UIPage.Audience. An injected tile can
|
||||
// leak exactly what a whole page would — the billing status tile shows total
|
||||
// cost — so the field exists here too rather than only on pages.
|
||||
Audience string `json:"audience,omitempty"`
|
||||
// Anchor is where in the target pane: "top", "bottom" or "before:<sel>" /
|
||||
// "after:<sel>" for a CSS selector. Empty = "bottom".
|
||||
Anchor string `json:"anchor,omitempty"`
|
||||
@ -179,6 +205,14 @@ type Plugin struct {
|
||||
// reasoning as a systemd unit being masked rather than removed.
|
||||
Disabled bool
|
||||
dir string
|
||||
// http lists endpoints this plugin mounts under /api/plugins/http/. Read at
|
||||
// load time, never mutated afterwards.
|
||||
http []HTTPHandler
|
||||
// exports lists the functions this plugin allows the WebUI to call through
|
||||
// POST /api/plugins/{name}/call/{fn}. It is read once at load from
|
||||
// plugin.exports and never changes, so Call can check it without taking a
|
||||
// Lua read on the hot path.
|
||||
exports []string
|
||||
// script is the plugin's source, kept so a reload can rebuild its state.
|
||||
script string
|
||||
// Builtin marks a plugin that shipped with the gateway. The UI shows it as
|
||||
@ -228,9 +262,16 @@ type Plugins struct {
|
||||
// direct log call so the plugin package stays free of a logging dependency
|
||||
// and tests can capture the warnings.
|
||||
logf func(format string, args ...interface{})
|
||||
// visibility holds the operator's per-plugin audience overrides, keyed by
|
||||
// plugin name. Consulted by UIForRole and the call endpoint's authorisation.
|
||||
visibility visibilityOverrides
|
||||
// stateDirDisabled turns persistence off. Used by tests that assert state
|
||||
// starts empty, and by an embedder that has no writable plugin dir.
|
||||
stateDirDisabled bool
|
||||
// allowHTTP bounds plugin outbound requests to operator-declared hosts.
|
||||
// nil means deny-all: an unconfigured gateway must not silently grant a
|
||||
// plugin unrestricted network access.
|
||||
allowHTTP *httpAllowlist
|
||||
// save coalesces state writes into one background goroutine.
|
||||
//
|
||||
// A hook must NOT write synchronously: the billing plugin mutates state on
|
||||
@ -412,6 +453,10 @@ func NewPlugins(vm *VM, dir string) *Plugins {
|
||||
stageFuncs: map[Stage][]hookCall{},
|
||||
hookErr: newHookErrors(),
|
||||
dir: dir,
|
||||
// Deny-by-default. NewPlugins is called during wiring, before any
|
||||
// source list is known; SetOutboundHosts replaces this with the real
|
||||
// set. A plugin loaded in that window simply cannot make requests.
|
||||
allowHTTP: newHTTPAllowlist(nil),
|
||||
}
|
||||
// The saver is started even with no dir: a gateway can be given a plugin dir
|
||||
// later, and a saver that only exists when dir != "" would silently never
|
||||
@ -429,6 +474,27 @@ func NewPlugins(vm *VM, dir string) *Plugins {
|
||||
// DisableStatePersistence turns persistence off (tests/benchmarks only).
|
||||
func (ps *Plugins) DisableStatePersistence() { ps.stateDirDisabled = true }
|
||||
|
||||
// SetOutboundHosts declares which hosts plugins may contact via
|
||||
// __llmsproxy_http. The gateway passes the host of every configured source:
|
||||
// those are operator-declared upstreams, and reusing that set keeps a plugin's
|
||||
// reach no wider than the deployment already trusts. An empty list means no
|
||||
// outbound host is allowed at all — plugins then still load, they just cannot
|
||||
// make requests.
|
||||
func (ps *Plugins) SetOutboundHosts(hosts []string) {
|
||||
ps.mu.Lock()
|
||||
defer ps.mu.Unlock()
|
||||
ps.allowHTTP = newHTTPAllowlist(hosts)
|
||||
}
|
||||
|
||||
// outboundAllowlist returns the current allowlist. bootPluginState reads it
|
||||
// before any concurrent reload can swap it, and a nil result is treated as
|
||||
// "deny everything" rather than "allow everything".
|
||||
func (ps *Plugins) outboundAllowlist() *httpAllowlist {
|
||||
ps.mu.RLock()
|
||||
defer ps.mu.RUnlock()
|
||||
return ps.allowHTTP
|
||||
}
|
||||
|
||||
func (ps *Plugins) Close() {
|
||||
if ps == nil {
|
||||
return
|
||||
@ -446,6 +512,12 @@ func (ps *Plugins) LoadDir() error {
|
||||
if ps.dir == "" {
|
||||
return nil
|
||||
}
|
||||
// Overrides first: they are keyed by plugin name and must be in place before
|
||||
// anything reads them, and a reload must not blank a restriction the
|
||||
// operator set earlier in this same process.
|
||||
if err := ps.LoadAccessOverrides(); err != nil {
|
||||
log.Printf("[core] plugin access overrides not loaded: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(ps.dir); os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
@ -496,10 +568,17 @@ func (ps *Plugins) SeedBundled() error {
|
||||
// bootPluginState compiles one plugin into a fresh Lua state and stores its
|
||||
// returned table under pluginGlobal. It is the plugin counterpart of
|
||||
// adapterPool.boot, minus the pooling: a plugin keeps exactly one state.
|
||||
func bootPluginState(code, name string) (*worker, error) {
|
||||
func bootPluginState(code, name string, allow *httpAllowlist) (*worker, error) {
|
||||
L := golua.NewState()
|
||||
L.OpenLibs()
|
||||
setupGlobals(L)
|
||||
// Only plugins get __llmsproxy_http. Adapters are operator-supplied config
|
||||
// and go through setupGlobals too, so installing the bridge here — not in
|
||||
// setupGlobals — is what keeps the capability scoped to installed .lua
|
||||
// files.
|
||||
if allow != nil {
|
||||
registerFn(L, httpGlobal, luaHTTPFn(L, allow))
|
||||
}
|
||||
if err := L.DoString(code); err != nil {
|
||||
L.Close()
|
||||
return nil, fmt.Errorf("compile plugin %s: %w", name, err)
|
||||
@ -537,7 +616,7 @@ func (ps *Plugins) LoadSource(name, code string) error {
|
||||
// One state, held for the plugin's lifetime (see Plugin.state): a hook that
|
||||
// accumulates into plugin.state would otherwise split its totals across
|
||||
// whichever worker it happened to run on.
|
||||
w, err := bootPluginState(code, name)
|
||||
w, err := bootPluginState(code, name, ps.outboundAllowlist())
|
||||
if err != nil {
|
||||
p.LoadError = err.Error()
|
||||
ps.append(p)
|
||||
@ -592,6 +671,22 @@ func (ps *Plugins) LoadSource(name, code string) error {
|
||||
p.UI = ui
|
||||
}
|
||||
|
||||
// ---- http endpoints ----
|
||||
p.http = readPluginHTTP(w.L)
|
||||
|
||||
if ui, ok := readPluginUI(w.L); ok {
|
||||
p.UI = ui
|
||||
if ui.StateScope != nil {
|
||||
ui.StateScope.Depth = compileDepthMap(ui.StateScope.Depth)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- exports ----
|
||||
// Read here, while the state is locked and before restore() runs: the list is
|
||||
// static for the life of the plugin, so Call never has to re-enter Lua just
|
||||
// to decide whether a call is allowed.
|
||||
p.exports = readPluginExports(w.L)
|
||||
|
||||
// Restore persisted state AFTER the plugin compiled and registered its
|
||||
// hooks, so the restore overwrites the compiled-in defaults instead of being
|
||||
// overwritten by them. Doing it earlier would mean a restart resets the
|
||||
@ -675,9 +770,47 @@ func pluginHookName(L *golua.State, stage string) (string, bool) {
|
||||
}
|
||||
|
||||
// readPluginUI reads the optional ui extension block.
|
||||
// readPluginExports collects plugin.exports, the list of functions the WebUI is
|
||||
// allowed to invoke through POST /api/plugins/{name}/call/{fn}.
|
||||
//
|
||||
// It accepts either an array of names or a map whose KEYS are the names — both
|
||||
// shapes read naturally in Lua and authors reach for either. A missing or
|
||||
// non-conforming table yields nil, which Call treats as "nothing is callable":
|
||||
// a plugin that never declared an export simply has no callable surface.
|
||||
func readPluginExports(L *golua.State) []string {
|
||||
L.GetGlobal(pluginGlobal)
|
||||
if L.IsNil(-1) {
|
||||
L.SetTop(0)
|
||||
return nil
|
||||
}
|
||||
L.GetField(-1, "exports")
|
||||
if L.Type(-1) != golua.LUA_TTABLE {
|
||||
L.SetTop(0)
|
||||
return nil
|
||||
}
|
||||
// luaTableToGoValue, NOT luaToJSON: this runs while the plugin is registering,
|
||||
// before the json global is installed, and luaToJSON needs it. Going through
|
||||
// JSON here silently produced an empty list, which then made every exported
|
||||
// function look unexported.
|
||||
raw := luaTableToGoValue(L, -1)
|
||||
L.SetTop(0)
|
||||
arr, ok := raw.([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
list := make([]string, 0, len(arr))
|
||||
for _, v := range arr {
|
||||
if s := asString(v); s != "" {
|
||||
list = append(list, s)
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func readPluginUI(L *golua.State) (*UIExtension, bool) {
|
||||
L.GetGlobal(pluginGlobal)
|
||||
if L.IsNil(-1) {
|
||||
L.SetTop(0)
|
||||
return nil, false
|
||||
}
|
||||
L.GetField(-1, "ui")
|
||||
@ -685,12 +818,67 @@ func readPluginUI(L *golua.State) (*UIExtension, bool) {
|
||||
L.SetTop(0)
|
||||
return nil, false
|
||||
}
|
||||
// luaTableToGoValue, NOT luaToJSON. This runs while the plugin is
|
||||
// registering, BEFORE setupGlobals installs the json global that luaToJSON
|
||||
// depends on — so the conversion fails and every field except the ones on
|
||||
// Page/Pages (read separately) comes back empty. That is why visibility and
|
||||
// state_scope silently read as unset even though the .lua declares them:
|
||||
// the third field in this file to be lost to the same cause, after
|
||||
// readPluginExports and readPluginHTTP.
|
||||
var ui UIExtension
|
||||
if err := luaToJSON(L, -1, &ui); err != nil {
|
||||
L.SetTop(0)
|
||||
return nil, false
|
||||
}
|
||||
raw := luaTableToGoValue(L, -1)
|
||||
L.SetTop(0)
|
||||
if m, ok := raw.(map[string]interface{}); ok {
|
||||
ui.Visibility = asString(m["visibility"])
|
||||
if scope, ok := m["state_scope"].(map[string]interface{}); ok {
|
||||
sc := &StateScope{}
|
||||
if gs, ok := scope["global"].([]interface{}); ok {
|
||||
for _, g := range gs {
|
||||
if s := asString(g); s != "" {
|
||||
sc.Global = append(sc.Global, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
if ds, ok := scope["depth"].(map[string]interface{}); ok {
|
||||
sc.Depth = map[string]int{}
|
||||
for k, v := range ds {
|
||||
if n, ok := asFloat(v); ok {
|
||||
sc.Depth[k] = int(n)
|
||||
}
|
||||
}
|
||||
sc.Depth = compileDepthMap(sc.Depth)
|
||||
}
|
||||
ui.StateScope = sc
|
||||
}
|
||||
if pg, ok := m["page"].(map[string]interface{}); ok {
|
||||
ui.Page = uipageFromValue(pg)
|
||||
}
|
||||
if pgs, ok := m["pages"].([]interface{}); ok {
|
||||
for _, it := range pgs {
|
||||
if pm, ok := it.(map[string]interface{}); ok {
|
||||
ui.Pages = append(ui.Pages, uipageFromValue(pm))
|
||||
}
|
||||
}
|
||||
}
|
||||
if els, ok := m["elements"].([]interface{}); ok {
|
||||
for _, it := range els {
|
||||
em, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
el := UIElement{
|
||||
Target: asString(em["target"]),
|
||||
Anchor: asString(em["anchor"]),
|
||||
Mount: asString(em["mount"]),
|
||||
Audience: asString(em["audience"]),
|
||||
}
|
||||
if n, ok := asFloat(em["order"]); ok {
|
||||
el.Order = int(n)
|
||||
}
|
||||
ui.Elements = append(ui.Elements, el)
|
||||
}
|
||||
}
|
||||
}
|
||||
if ui.Page == nil && len(ui.Pages) == 0 && len(ui.Elements) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
@ -938,6 +1126,140 @@ func (ps *Plugins) HookErrors() map[string]map[string]interface{} {
|
||||
}
|
||||
|
||||
// UI returns the merged UI extensions to inject into the WebUI.
|
||||
// audienceAllows reports whether a role is permitted by an audience string.
|
||||
//
|
||||
// The grammar is deliberately small, because it is authored in two places (a
|
||||
// .lua file and config.yaml) and a richer language would only give someone a way
|
||||
// to express a rule nobody audits:
|
||||
//
|
||||
// "" or "all" everyone
|
||||
// "admin" admin role only
|
||||
// "user" user role only
|
||||
// "admin,user" either
|
||||
//
|
||||
// "*" is deliberately NOT a synonym for "all": an audience that looks
|
||||
// restrictive in a config file while meaning everything is exactly the kind of
|
||||
// setting that fails silently.
|
||||
func audienceAllows(want, role string) bool {
|
||||
want = strings.ToLower(strings.TrimSpace(want))
|
||||
if want == "" || want == "all" {
|
||||
return true
|
||||
}
|
||||
role = strings.ToLower(strings.TrimSpace(role))
|
||||
for _, part := range strings.Split(want, ",") {
|
||||
if strings.TrimSpace(part) == role {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// visibilityOverrides holds the operator's per-plugin audience overrides.
|
||||
//
|
||||
// A plugin declares its own default and the operator may force one. Both sides
|
||||
// need to be able to: the plugin author does not know the deployment's policy,
|
||||
// and the operator must not have to edit a bundled .lua — which the next
|
||||
// upgrade would overwrite anyway.
|
||||
type visibilityOverrides map[string]string
|
||||
|
||||
// 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) {
|
||||
ps.mu.Lock()
|
||||
defer ps.mu.Unlock()
|
||||
if ps.visibility == nil {
|
||||
ps.visibility = visibilityOverrides{}
|
||||
}
|
||||
plugin = strings.TrimSpace(plugin)
|
||||
audience = strings.TrimSpace(audience)
|
||||
if audience == "" {
|
||||
delete(ps.visibility, plugin)
|
||||
} else {
|
||||
ps.visibility[plugin] = audience
|
||||
}
|
||||
ps.saveAccessOverridesLocked()
|
||||
}
|
||||
|
||||
// VisibilityOverride returns the operator's audience override for a plugin,
|
||||
// or "" when none is set. Exposed so /api/plugins can show which visibility is
|
||||
// imposed rather than only the one in effect.
|
||||
func (ps *Plugins) VisibilityOverride(plugin string) string {
|
||||
ps.mu.RLock()
|
||||
defer ps.mu.RUnlock()
|
||||
return ps.visibility[plugin]
|
||||
}
|
||||
|
||||
// uiAudience resolves the audience in force for a plugin: the operator's
|
||||
// override if set, else the plugin's declared default.
|
||||
func (ps *Plugins) uiAudience(p *Plugin, declared string) string {
|
||||
ps.mu.RLock()
|
||||
override := ps.visibility[p.Info.Name]
|
||||
ps.mu.RUnlock()
|
||||
if override != "" {
|
||||
return override
|
||||
}
|
||||
return declared
|
||||
}
|
||||
|
||||
// UIForRole returns the UI a given role may see.
|
||||
//
|
||||
// Filtering happens HERE, not in the HTTP layer, so every consumer sees the same
|
||||
// view — the WebUI boot payload, the Electron shell and the tests. Filtering at
|
||||
// one call site means the others quietly keep serving everything.
|
||||
//
|
||||
// A plugin's page/element can narrow visibility further than its own default
|
||||
// (a public summary page beside an admin-only one), so the role check runs on
|
||||
// the resolved plugin audience AND on each item's own declaration.
|
||||
func (ps *Plugins) UIForRole(role string) *UIExtension {
|
||||
full := ps.UI()
|
||||
if full == nil {
|
||||
return &UIExtension{}
|
||||
}
|
||||
// An admin sees everything, including anything a plugin hides from itself
|
||||
// by mistake: anything else makes an override unverifiable from the browser.
|
||||
if role == "admin" {
|
||||
return full
|
||||
}
|
||||
out := &UIExtension{}
|
||||
// The plugin-level default is per plugin, so pages are filtered by walking
|
||||
// plugins rather than pages: a page id alone does not say who owns it.
|
||||
for _, p := range ps.plugins {
|
||||
if p.UI == nil || p.LoadError != "" {
|
||||
continue
|
||||
}
|
||||
aud := ps.uiAudience(p, p.UI.Visibility)
|
||||
if !audienceAllows(aud, role) {
|
||||
continue
|
||||
}
|
||||
for _, pg := range p.UI.Pages {
|
||||
if pg == nil || !audienceAllows(pg.Audience, role) {
|
||||
continue
|
||||
}
|
||||
out.Pages = append(out.Pages, pg)
|
||||
}
|
||||
for _, el := range p.UI.Elements {
|
||||
if !audienceAllows(el.Audience, role) {
|
||||
continue
|
||||
}
|
||||
out.Elements = append(out.Elements, el)
|
||||
}
|
||||
// Page (singular) is the other half of the same declaration and both
|
||||
// merge in UI(). Handling only Pages made every plugin that used the
|
||||
// single-page form disappear for a user key — silently, with no error,
|
||||
// because "this plugin contributes nothing to you" is indistinguishable
|
||||
// from "this plugin is not installed".
|
||||
if p.UI.Page != nil && audienceAllows(p.UI.Page.Audience, role) {
|
||||
if out.Page == nil {
|
||||
out.Page = p.UI.Page
|
||||
}
|
||||
if !containsPage(out.Pages, p.UI.Page.PageID) {
|
||||
out.Pages = append(out.Pages, p.UI.Page)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (ps *Plugins) UI() *UIExtension {
|
||||
if u := ps.ui.Load(); u != nil {
|
||||
return u
|
||||
@ -1614,3 +1936,319 @@ func (ps *Plugins) invoke(p *Plugin, fn string, payload map[string]interface{})
|
||||
ps.markDirtyLocked(p)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Call invokes a plugin function that the plugin declared in plugin.exports.
|
||||
//
|
||||
// The gate is the declaration, not the caller: a plugin lists the handful of
|
||||
// entry points its own UI is allowed to reach, and anything absent is refused.
|
||||
// Without that list this endpoint would be a remote Lua evaluator — it could
|
||||
// call the plugin's private helpers, hand them crafted arguments, and read
|
||||
// whatever the plugin can see, which for a plugin holding an API key means
|
||||
// handing that key to anyone who can POST to the gateway.
|
||||
//
|
||||
// The return value is JSON-shaped rather than restricted to a map, because a
|
||||
// quota report is a nested structure with numbers and booleans that must not be
|
||||
// flattened. Hook results still go through invoke, whose map-typed contract the
|
||||
// state merge depends on.
|
||||
func (ps *Plugins) Call(name, fn string, args map[string]interface{}) (interface{}, error) {
|
||||
ps.mu.RLock()
|
||||
var p *Plugin
|
||||
for _, c := range ps.plugins {
|
||||
if c.Info.Name == name {
|
||||
p = c
|
||||
break
|
||||
}
|
||||
}
|
||||
ps.mu.RUnlock()
|
||||
if p == nil {
|
||||
return nil, fmt.Errorf("plugin %s not found", name)
|
||||
}
|
||||
if !pluginExportAllowed(p, fn) {
|
||||
return nil, fmt.Errorf("plugin %s does not export %q", name, fn)
|
||||
}
|
||||
p.mu.Lock()
|
||||
w := p.state
|
||||
if w == nil {
|
||||
p.mu.Unlock()
|
||||
return nil, fmt.Errorf("no state for plugin %s", name)
|
||||
}
|
||||
defer p.mu.Unlock()
|
||||
L := w.L
|
||||
L.SetTop(0)
|
||||
defer L.SetTop(0)
|
||||
|
||||
L.GetGlobal(pluginGlobal)
|
||||
if L.IsNil(-1) {
|
||||
return nil, fmt.Errorf("plugin table missing")
|
||||
}
|
||||
plug := L.GetTop()
|
||||
L.GetGlobal(hookGuardName)
|
||||
if L.IsNil(-1) {
|
||||
L.Pop(1)
|
||||
}
|
||||
L.GetField(plug, fn)
|
||||
if !L.IsFunction(-1) {
|
||||
return nil, fmt.Errorf("export %s missing", fn)
|
||||
}
|
||||
if args == nil {
|
||||
args = map[string]interface{}{}
|
||||
}
|
||||
pushGoValue(L, plainForLua(args))
|
||||
if err := L.Call(2, 2); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
errMsg := ""
|
||||
if L.GetTop() >= 2 {
|
||||
if !L.IsNil(-1) {
|
||||
errMsg = L.ToString(-1)
|
||||
}
|
||||
L.Pop(1)
|
||||
}
|
||||
if errMsg != "" {
|
||||
return nil, fmt.Errorf("plugin %s: %s", name, errMsg)
|
||||
}
|
||||
if L.GetTop() < 1 || L.IsNil(-1) {
|
||||
return nil, nil
|
||||
}
|
||||
var out interface{}
|
||||
if err := luaToJSON(L, -1, &out); err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// pluginExportAllowed reports whether fn is in the plugin's exports list.
|
||||
// An absent list means nothing is callable — the safe reading, since silently
|
||||
// allowing all would turn an undeclared plugin into a remote evaluator.
|
||||
func pluginExportAllowed(p *Plugin, fn string) bool {
|
||||
for _, e := range p.exports {
|
||||
if e == fn {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// StateVisibleTo reports whether a role may read a plugin's published state.
|
||||
//
|
||||
// The audience is resolved exactly as for the UI: operator override first, then
|
||||
// the plugin's declaration. A plugin with neither is readable by any
|
||||
// authenticated role, which is the pre-existing behaviour and the reason a
|
||||
// newly installed widget keeps working without an edit.
|
||||
|
||||
// HTTPHandler is one endpoint a plugin registers on the gateway's router.
|
||||
//
|
||||
// A plugin that only receives hooks (billing) has nothing to expose. A plugin
|
||||
// that computes something the operator may want to query from OUTSIDE the
|
||||
// browser — a health probe, a CI check, another tool — needs a route, and
|
||||
// hard-coding one in the gateway would mean every such plugin needs a gateway
|
||||
// change. So the plugin declares the path, the method and the role it requires,
|
||||
// and the gateway mounts it.
|
||||
//
|
||||
// The handler is a Lua function taking (request) and returning (response). Both
|
||||
// are plain tables: request carries method/path/query/headers/body, response
|
||||
// carries status/headers/body/json. Nothing here can reach the Go side except
|
||||
// through those two values, so the surface a plugin can express is bounded by
|
||||
// what a table can say.
|
||||
type HTTPHandler struct {
|
||||
// Path is the route suffix under /api/plugins/http/. It may contain ":name"
|
||||
// segments, captured into request.params.
|
||||
Path string `json:"path"`
|
||||
// Method is the HTTP method; "" or GET. Only GET/POST/PUT/DELETE/PATCH are
|
||||
// accepted — an arbitrary verb would need a switch in the router for no gain.
|
||||
Method string `json:"method,omitempty"`
|
||||
// Roles are the API-key roles allowed to call it. EMPTY MEANS EVERYONE, which
|
||||
// is the wrong default for an endpoint a plugin invented: an unauthenticated
|
||||
// internet scan should not be able to ask it questions. RequireRole exists
|
||||
// so a plugin that really wants public access has to say so.
|
||||
Roles []string `json:"roles,omitempty"`
|
||||
// Keys names SPECIFIC gateway keys allowed to call it, by their configured
|
||||
// name (the `name:` on a gateway_keys entry), never by the secret.
|
||||
//
|
||||
// Names rather than secrets for three reasons: a secret is rotated without
|
||||
// notice and a rule written against it would silently stop matching; putting
|
||||
// secrets in a plugin file scatters credentials across the install; and the
|
||||
// operator reads names. A key with no name cannot be named here.
|
||||
//
|
||||
// When Keys is non-empty it is the WHOLE rule, not an addition to Roles. An
|
||||
// OR would mean an operator writing a two-key allowlist had also granted
|
||||
// every user key, which is the opposite of what they meant.
|
||||
Keys []string `json:"keys,omitempty"`
|
||||
// RequireAuth defaults to true in practice: the gateway refuses a handler
|
||||
// with neither Roles nor an explicit opt-out.
|
||||
Public bool `json:"public,omitempty"`
|
||||
// Handler is the name of the plugin function to invoke.
|
||||
Handler string `json:"handler"`
|
||||
// Description is shown in /api/plugins so an operator can see what a plugin
|
||||
// has exposed without reading the .lua.
|
||||
Description string `json:"description,omitempty"`
|
||||
// Plugin is filled in by HTTPHandlers: which plugin registered this route.
|
||||
// The gateway dispatcher needs it to know which Lua state to enter, and
|
||||
// deriving it from the path would mean trusting the plugin's own prefix.
|
||||
Plugin string `json:"-"`
|
||||
}
|
||||
|
||||
// routePath renders the handler's full mount path.
|
||||
func (h HTTPHandler) routePath() string {
|
||||
return "/api/plugins/http/" + strings.Trim(h.Path, "/")
|
||||
}
|
||||
|
||||
// allows reports whether a caller may invoke this handler.
|
||||
//
|
||||
// keyName is the caller key's configured name ("" when it has none), not the
|
||||
// secret.
|
||||
func (h HTTPHandler) allows(role, keyName string) bool {
|
||||
// Admin is always admitted. An operator must be able to inspect what a plugin
|
||||
// exposes even while locked out of it, or a typo in a key list would leave
|
||||
// nobody able to diagnose the endpoint they were trying to tighten.
|
||||
if role == "admin" {
|
||||
return true
|
||||
}
|
||||
// Named keys REPLACE the role rule rather than adding to it.
|
||||
if len(h.Keys) > 0 {
|
||||
if keyName == "" {
|
||||
return false
|
||||
}
|
||||
for _, k := range h.Keys {
|
||||
if strings.EqualFold(strings.TrimSpace(k), keyName) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if h.Public {
|
||||
return true
|
||||
}
|
||||
if len(h.Roles) == 0 {
|
||||
// No declared roles and no public opt-out: treat as admin-only. A plugin
|
||||
// author who forgets this gets a 403 rather than an open endpoint, and
|
||||
// the error says which field to set.
|
||||
return role == "admin"
|
||||
}
|
||||
for _, r := range h.Roles {
|
||||
if strings.EqualFold(strings.TrimSpace(r), role) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (ps *Plugins) StateVisibleTo(name, role string) bool {
|
||||
if role == "admin" {
|
||||
return true
|
||||
}
|
||||
ps.mu.RLock()
|
||||
defer ps.mu.RUnlock()
|
||||
for _, p := range ps.plugins {
|
||||
if p.Info.Name != name {
|
||||
continue
|
||||
}
|
||||
override := ps.visibility[name]
|
||||
if override != "" {
|
||||
return audienceAllows(override, role)
|
||||
}
|
||||
declared := ""
|
||||
if p.UI != nil {
|
||||
declared = p.UI.Visibility
|
||||
}
|
||||
return audienceAllows(declared, role)
|
||||
}
|
||||
// An unknown plugin name: no visibility can be established, and this endpoint
|
||||
// answers with an empty state rather than an error, so refusing here would
|
||||
// change behaviour for a name that may simply not be installed.
|
||||
return true
|
||||
}
|
||||
|
||||
func containsPage(pages []*UIPage, id string) bool {
|
||||
for _, p := range pages {
|
||||
if p != nil && p.PageID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// readPluginHTTP collects plugin.http, the endpoints the plugin asks the gateway
|
||||
// to mount.
|
||||
//
|
||||
// A malformed entry is DROPPED rather than failing the plugin: a plugin with one
|
||||
// bad route should still load its hooks and pages, and the bad route simply does
|
||||
// not exist. Failing the whole plugin would take a working billing widget down
|
||||
// over a typo in an experimental endpoint.
|
||||
func readPluginHTTP(L *golua.State) []HTTPHandler {
|
||||
L.GetGlobal(pluginGlobal)
|
||||
if L.IsNil(-1) {
|
||||
L.SetTop(0)
|
||||
return nil
|
||||
}
|
||||
L.GetField(-1, "http")
|
||||
if L.Type(-1) != golua.LUA_TTABLE {
|
||||
L.SetTop(0)
|
||||
return nil
|
||||
}
|
||||
// luaTableToGoValue, NOT luaToJSON and NOT a hand-rolled GetField loop.
|
||||
//
|
||||
// luaToJSON needs the json global, which is not installed yet while a plugin
|
||||
// is registering. And golua's GetField does NOT convert a numeric key: with
|
||||
// a string index "1" it returns nil, so an index loop silently produced an
|
||||
// empty list and every plugin appeared to declare no endpoints at all.
|
||||
raw := luaTableToGoValue(L, -1)
|
||||
L.SetTop(0)
|
||||
items, ok := raw.([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var list []HTTPHandler
|
||||
for _, it := range items {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
h := HTTPHandler{
|
||||
Path: asString(m["path"]),
|
||||
Method: asString(m["method"]),
|
||||
Handler: asString(m["handler"]),
|
||||
Description: asString(m["description"]),
|
||||
Public: m["public"] == true,
|
||||
}
|
||||
if roles, ok := m["roles"].([]interface{}); ok {
|
||||
for _, r := range roles {
|
||||
if s := asString(r); s != "" {
|
||||
h.Roles = append(h.Roles, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
if keys, ok := m["keys"].([]interface{}); ok {
|
||||
for _, k := range keys {
|
||||
if s := asString(k); s != "" {
|
||||
h.Keys = append(h.Keys, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
// A malformed entry is dropped rather than fatal: one bad route must not
|
||||
// cost a plugin its hooks and pages. That is also why the mounted count
|
||||
// can be lower than the declared count — /api/plugins lists what mounted.
|
||||
if method, mErr := normalizeHandlerMethod(h.Method); mErr == nil {
|
||||
h.Method = method
|
||||
if validateHandlerPath(h.Path) == nil && h.Handler != "" {
|
||||
list = append(list, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// uipageFromValue builds a UIPage from a decoded Lua table.
|
||||
func uipageFromValue(m map[string]interface{}) *UIPage {
|
||||
pg := &UIPage{
|
||||
PageID: asString(m["page_id"]),
|
||||
Title: asString(m["title"]),
|
||||
Icon: asString(m["icon"]),
|
||||
Mount: asString(m["mount"]),
|
||||
Audience: asString(m["audience"]),
|
||||
}
|
||||
if n, ok := asFloat(m["order"]); ok {
|
||||
pg.Order = int(n)
|
||||
}
|
||||
return pg
|
||||
}
|
||||
|
||||
@ -192,6 +192,11 @@ local function priceFor(payload)
|
||||
out = {
|
||||
prompt = d.prompt or 0, completion = d.completion or 0,
|
||||
per_request = 0, cache_discount = d.cache_discount, peak = d.peak,
|
||||
-- nil = bill reasoning at the completion rate, which is what every
|
||||
-- provider measured actually charges. Set per-rule only for a provider
|
||||
-- that publishes a distinct rate for thinking tokens.
|
||||
reasoning_price = d.reasoning_price,
|
||||
long_context = d.long_context,
|
||||
}
|
||||
|
||||
-- model dimension (a token price overrides the default's token prices)
|
||||
@ -202,6 +207,8 @@ local function priceFor(payload)
|
||||
if mp.completion ~= nil then out.completion = mp.completion end
|
||||
if mp.per_request ~= nil then out.per_request = out.per_request + mp.per_request end
|
||||
if mp.cache_discount ~= nil then out.cache_discount = mp.cache_discount end
|
||||
if mp.reasoning_price ~= nil then out.reasoning_price = mp.reasoning_price end
|
||||
if mp.long_context ~= nil then out.long_context = mp.long_context end
|
||||
if mp.peak ~= nil then out.peak = mp.peak end
|
||||
end
|
||||
|
||||
@ -213,6 +220,8 @@ local function priceFor(payload)
|
||||
if sp.completion ~= nil then out.completion = sp.completion end
|
||||
if sp.per_request ~= nil then out.per_request = out.per_request + sp.per_request end
|
||||
if sp.cache_discount ~= nil then out.cache_discount = sp.cache_discount end
|
||||
if sp.reasoning_price ~= nil then out.reasoning_price = sp.reasoning_price end
|
||||
if sp.long_context ~= nil then out.long_context = sp.long_context end
|
||||
if sp.peak ~= nil then out.peak = sp.peak end
|
||||
end
|
||||
|
||||
@ -346,6 +355,60 @@ local function costFor(payload, price)
|
||||
+ cacheHit * price.prompt * discount
|
||||
+ completion * price.completion * mult
|
||||
|
||||
-- Long-context step: the tokens PAST a threshold cost more.
|
||||
--
|
||||
-- A marginal rate on the excess, not a blended average — that is how
|
||||
-- providers publish it (DeepSeek doubles past 128k) and what keeps it
|
||||
-- composable: a cache read beyond the threshold gets BOTH the cache discount
|
||||
-- AND the long-context rate, as it is billed. A blended average would have to
|
||||
-- choose one, and choosing wrong under-bills exactly the cache-heavy long
|
||||
-- requests the cache exists to make cheap.
|
||||
--
|
||||
-- The excess splits between cached and fresh in the same proportion as the
|
||||
-- whole prompt, so no token is charged twice and none is missed.
|
||||
local lc = price.long_context
|
||||
if lc then
|
||||
local totalPrompt = tonumber(payload.prompt_tokens) or 0
|
||||
local threshold = tonumber(lc.above) or 0
|
||||
local lcRate = tonumber(lc.prompt)
|
||||
if totalPrompt > threshold and threshold > 0 and lcRate and lcRate > 0 then
|
||||
local excess = totalPrompt - threshold
|
||||
-- Only the FRESH share of the excess carries the step: a cache read is
|
||||
-- already billed at the discounted rate, and re-pricing it at the
|
||||
-- long-context rate would charge the same tokens twice.
|
||||
local freshShare = totalPrompt > 0 and (fresh / totalPrompt) or 0
|
||||
local excessFresh = excess * freshShare
|
||||
local excessCached = excess - excessFresh
|
||||
cost = cost
|
||||
+ excessFresh * (lcRate - price.prompt) * mult
|
||||
+ excessCached * (lcRate - price.prompt * discount) * mult
|
||||
end
|
||||
end
|
||||
|
||||
-- Reasoning tokens, when the rule prices them separately.
|
||||
--
|
||||
-- reasoning_tokens is a SUBSET of completion_tokens and is already inside
|
||||
-- the term above. This block only matters when a provider charges thinking
|
||||
-- at a DIFFERENT rate — and every provider measured does not: OpenAI,
|
||||
-- DeepSeek and Anthropic all bill reasoning at the ordinary output rate. So
|
||||
-- reasoning_price is nil by default and this is skipped entirely, which is
|
||||
-- what makes the standard formula match the invoice.
|
||||
--
|
||||
-- When it IS set, only the DIFFERENCE is applied: the reasoning portion is
|
||||
-- re-priced from the normal rate to the reasoning rate. Charging it a
|
||||
-- second time in full would double-bill every thinking token.
|
||||
local reasoningPrice = tonumber(price.reasoning_price)
|
||||
if reasoningPrice and reasoningPrice ~= price.completion then
|
||||
local reasoning = tonumber(payload.reasoning_tokens) or 0
|
||||
if reasoning > 0 then
|
||||
-- Defensive clamp: a provider reporting more reasoning than completion
|
||||
-- would otherwise produce a negative visible-output count and a cost
|
||||
-- delta larger than the whole request.
|
||||
if reasoning > completion then reasoning = completion end
|
||||
cost = cost + reasoning * (reasoningPrice - price.completion) * mult
|
||||
end
|
||||
end
|
||||
|
||||
local flat = price.per_request
|
||||
if not payload.ok and not plugin.count_failures then
|
||||
flat = 0
|
||||
@ -442,6 +505,21 @@ function plugin.on_request_end(payload)
|
||||
s.unpriced_reqs = s.unpriced_reqs + 1
|
||||
local m = payload.model or "?"
|
||||
s.unpriced_models[m] = (s.unpriced_models[m] or 0) + 1
|
||||
|
||||
-- Day-keyed unpriced tally. The lifetime counters above cannot answer the
|
||||
-- question an operator actually has — "is it STILL happening?" — because they
|
||||
-- keep the debt from before a pricing rule was added. After pricing a source
|
||||
-- that was mispriced, the lifetime number never moves, so "unpriced: 6376" on
|
||||
-- a dashboard is indistinguishable from a system that is still billing
|
||||
-- nothing. Counting by day makes the fix observable: the offending day keeps
|
||||
-- its number, new days start at zero.
|
||||
if s.unpriced_by_day == nil then s.unpriced_by_day = {} end
|
||||
-- dayKey wants epoch SECONDS; payload.time is milliseconds.
|
||||
local uday = dayKey(math.floor((tonumber(payload.time) or (os.time() * 1000)) / 1000))
|
||||
local ubucket = s.unpriced_by_day[uday]
|
||||
if ubucket == nil then ubucket = { reqs = 0, models = {} }; s.unpriced_by_day[uday] = ubucket end
|
||||
ubucket.reqs = ubucket.reqs + 1
|
||||
ubucket.models[m] = (ubucket.models[m] or 0) + 1
|
||||
end
|
||||
if s.degraded_reqs == nil then s.degraded_reqs = 0 end
|
||||
-- Degradation is counted here rather than in the chain_step hook because
|
||||
@ -503,6 +581,24 @@ end
|
||||
-- A whole page. The kernel injects this HTML and evaluates the <script> after
|
||||
-- the DOM exists, and exposes `pluginAPI` for talking to the gateway.
|
||||
plugin.ui = {
|
||||
-- Readable by any role, because the state is SPLIT below: a user key sees
|
||||
-- its own spend and nothing else. Marking the page admin-only would make the
|
||||
-- split pointless; marking it "all" without the split is exactly the leak this
|
||||
-- closes — a user key pulled the whole 18KB ledger (16871 requests, $8.86,
|
||||
-- every source name) straight out of GET /state. The gateway's audience
|
||||
-- override can tighten this per plugin without editing this file.
|
||||
visibility = "all",
|
||||
-- Split the state: global figures are deployment data and are withheld from
|
||||
-- non-admin callers, while the per-key buckets describe ONE caller.
|
||||
-- depth: by_key is {maskedKey: bucket}; by_day_key is {day: {maskedKey: …}}.
|
||||
state_scope = {
|
||||
global = {
|
||||
"total", "by_source", "by_model", "by_day", "by_day_src",
|
||||
"by_day_model", "by_tier_served", "skip_reasons", "degraded_reqs",
|
||||
"unpriced_models", "unpriced_reqs", "unpriced_by_day", "started",
|
||||
},
|
||||
depth = { by_key = 1, by_day_key = 2 },
|
||||
},
|
||||
page = {
|
||||
page_id = "billing",
|
||||
title = "Billing",
|
||||
@ -575,6 +671,7 @@ plugin.ui = {
|
||||
en: {
|
||||
total: "Total", requests: "Requests", degraded: "Degraded",
|
||||
unpriced: "Unpriced", prompt: "Prompt tokens",
|
||||
unpriced7d: "Unpriced (7d)", unpricedClean: "none in last 7 days",
|
||||
completion: "Completion tokens", failures: "Failures",
|
||||
cacheRate: "Cache hit rate", cacheTokens: "Cache read tokens",
|
||||
perSource: "Per source", perModel: "Per model",
|
||||
@ -596,6 +693,7 @@ plugin.ui = {
|
||||
zh: {
|
||||
total: "总开销", requests: "请求数", degraded: "降级",
|
||||
unpriced: "未定价", prompt: "输入 tokens",
|
||||
unpriced7d: "未定价(近 7 天)", unpricedClean: "近 7 天无",
|
||||
completion: "输出 tokens", failures: "失败",
|
||||
cacheRate: "缓存命中率", cacheTokens: "缓存读取 tokens",
|
||||
perSource: "按源", perModel: "按模型",
|
||||
@ -716,13 +814,35 @@ plugin.ui = {
|
||||
});
|
||||
return out;
|
||||
}
|
||||
// recentUnpriced counts unpriced requests over the last n days. When the
|
||||
// result is zero THAT is the useful signal: every pricing rule added since
|
||||
// then is holding. Null when there is no day data at all (a state file
|
||||
// written before the buckets existed) so the UI can say "unknown" rather than
|
||||
// claim a clean record it cannot support.
|
||||
function recentUnpriced(st, n) {
|
||||
var byDay = st && st.unpriced_by_day;
|
||||
if (!byDay) return null;
|
||||
var cutoff = Date.now() - n * 86400000;
|
||||
var sum = 0, seen = false;
|
||||
Object.keys(byDay).forEach(function (d) {
|
||||
var t = Date.parse(d + "T00:00:00Z");
|
||||
if (isNaN(t)) return;
|
||||
seen = true;
|
||||
if (t >= cutoff) sum += (byDay[d].reqs || 0);
|
||||
});
|
||||
return seen ? sum : null;
|
||||
}
|
||||
|
||||
// rescale returns the state as the selected period presents it. Degraded and
|
||||
// unpriced counters are lifetime-only (there is no day-keyed tally for them)
|
||||
// so they are returned as null and the UI hides them instead of printing a
|
||||
// lifetime number beside a per-window cost — the exact mismatch the period
|
||||
// view exists to remove.
|
||||
function rescale(st) {
|
||||
if (PERIOD === "all" || !st.by_day) return st;
|
||||
if (PERIOD === "all" || !st.by_day) {
|
||||
if (st) st.unpriced_recent = recentUnpriced(st, 7);
|
||||
return st;
|
||||
}
|
||||
var days = daysInWindow(PERIOD, utcToday());
|
||||
var total = zeroBucket();
|
||||
var byDay = {};
|
||||
@ -740,6 +860,10 @@ plugin.ui = {
|
||||
by_day: byDay,
|
||||
degraded_reqs: null,
|
||||
unpriced_reqs: null,
|
||||
// The lifetime unpriced total is null here for the same reason, but the
|
||||
// day buckets are not — they are what lets the card report "0 in the last
|
||||
// 7 days" instead of leaving a settled debt indistinguishable from a leak.
|
||||
unpriced_recent: recentUnpriced(st, 7),
|
||||
};
|
||||
return out;
|
||||
}
|
||||
@ -820,6 +944,15 @@ plugin.ui = {
|
||||
// print a lifetime number next to a per-window cost.
|
||||
view.degraded_reqs != null ? [T.degraded, fmtInt(view.degraded_reqs)] : null,
|
||||
view.unpriced_reqs != null ? [T.unpriced, fmtInt(view.unpriced_reqs)] : null,
|
||||
// "Unpriced (7d)" answers what the lifetime number cannot: is it STILL
|
||||
// happening? A settled debt and a live leak print the same lifetime total.
|
||||
view.unpriced_recent != null
|
||||
? [
|
||||
T.unpriced7d,
|
||||
fmtInt(view.unpriced_recent),
|
||||
view.unpriced_recent === 0 ? T.unpricedClean : null,
|
||||
]
|
||||
: null,
|
||||
[T.prompt, fmtInt(t.prompt_tokens || 0)],
|
||||
[T.completion, fmtInt(t.completion_tokens || 0)],
|
||||
[T.failures, t.failures || 0],
|
||||
@ -931,7 +1064,11 @@ plugin.ui = {
|
||||
monthly: "Monthly fee", peakMult: "Peak multiplier",
|
||||
peakHours: "Peak hours (UTC)", peakDays: "Peak days (UTC 1=Mon)",
|
||||
models: "Per-model prices", addModel: "+ model",
|
||||
mFree: "free", mToken: "token", mSub: "subscription", mUnpriced: "unpriced",
|
||||
|
||||
reasoning: "think $/M",
|
||||
|
||||
lcAbove: "over n tok", lcPrompt: "then $/M", lcMult: "or x",
|
||||
lcHint: "Long-context step: tokens PAST the threshold cost more. Leave all three blank for one flat rate.", reasoningHint: "Thinking tokens are billed as output unless you set a different rate here.", mFree: "free", mToken: "token", mSub: "subscription", mUnpriced: "unpriced",
|
||||
noProfile: "No profile yet — create one to start pricing.",
|
||||
noRules: "No rules in this profile",
|
||||
warnNoSource: "matches no configured source",
|
||||
@ -953,7 +1090,11 @@ plugin.ui = {
|
||||
monthly: "月费", peakMult: "峰段倍数",
|
||||
peakHours: "峰段小时 (UTC)", peakDays: "峰段星期 (UTC 1=周一)",
|
||||
models: "按模型价格", addModel: "+ 模型",
|
||||
mFree: "free 免费", mToken: "token 按量",
|
||||
|
||||
reasoning: "思考 $/M",
|
||||
|
||||
lcAbove: "超过 n tok", lcPrompt: "此后 $/M", lcMult: "或 ×",
|
||||
lcHint: "长上下文阶梯:超过阈值的部分按更高价计。三项都留空 = 单一价格。", reasoningHint: "留空=思考 token 按输出价计(OpenAI/DeepSeek/Anthropic 都这样)。只有供应商对思考单独定价时才填。", mFree: "free 免费", mToken: "token 按量",
|
||||
mSub: "subscription 订阅", mUnpriced: "unpriced 只统计",
|
||||
noProfile: "还没有方案 —— 新建一个开始定价。",
|
||||
noRules: "该方案暂无规则",
|
||||
@ -1051,6 +1192,25 @@ plugin.ui = {
|
||||
"<input class='m-name' value='" + esc(m) + "' style='flex:1 1 90px;min-width:0'>" +
|
||||
"<input class='m-p' value='" + esc(t.prompt == null ? "" : t.prompt) +
|
||||
"' placeholder='" + esc(T.prompt) + "' style='flex:0 1 62px;min-width:0'>" +
|
||||
// Left BLANK by default on purpose: empty means "bill thinking at the
|
||||
// completion rate", which is what OpenAI, DeepSeek and Anthropic do.
|
||||
// A 0 here would mean "thinking is free" and would under-bill quietly.
|
||||
"<input class='m-r' value='" + esc(t.reasoning == null ? "" : t.reasoning) +
|
||||
"' placeholder='" + esc(T.reasoning) + "' title='" + esc(T.reasoningHint) +
|
||||
"' style='flex:0 1 62px;min-width:0'>" +
|
||||
// Long-context step: threshold + (absolute rate OR multiplier). All
|
||||
// three blank means NO step, deliberately distinct from a step whose
|
||||
// rate is 0 — three inputs rather than inferring the step from a zero.
|
||||
// On an agent workload 88% of requests exceed 128k prompt tokens, so
|
||||
// omitting this under-bills the MAJORITY while every number stays
|
||||
// plausible. The step is MARGINAL: it reprices the excess only.
|
||||
"<input class='m-lc-a' value='" + esc(t.long_context == null ? "" : t.long_context.above) +
|
||||
"' placeholder='" + esc(T.lcAbove) + "' title='" + esc(T.lcHint) +
|
||||
"' style='flex:0 1 52px;min-width:0'>" +
|
||||
"<input class='m-lc-p' value='" + esc(t.long_context == null ? "" : (t.long_context.prompt || "")) +
|
||||
"' placeholder='" + esc(T.lcPrompt) + "' style='flex:0 1 52px;min-width:0'>" +
|
||||
"<input class='m-lc-m' value='" + esc(t.long_context == null ? "" : (t.long_context.multiplier || "")) +
|
||||
"' placeholder='" + esc(T.lcMult) + "' style='flex:0 1 46px;min-width:0'>" +
|
||||
"<input class='m-c' value='" + esc(t.completion == null ? "" : t.completion) +
|
||||
"' placeholder='" + esc(T.completion) + "' style='flex:0 1 62px;min-width:0'>" +
|
||||
"<button class='ghost small m-del' title='" + esc(T.delRule) + "' style='flex:0 0 auto'>×</button></div>";
|
||||
@ -1127,6 +1287,39 @@ plugin.ui = {
|
||||
return;
|
||||
}
|
||||
models[n] = { prompt: pv, completion: cv };
|
||||
|
||||
// Optional thinking-token rate, read from the same row. Blank means
|
||||
// "bill reasoning at the completion rate" — what OpenAI, DeepSeek and
|
||||
// Anthropic all do. A 0 would mean "thinking is free", so an empty
|
||||
// field must stay ABSENT from the saved rule rather than becoming 0.
|
||||
var rel = r.querySelector(".m-r");
|
||||
var rtxt = rel ? String(rel.value || "").trim() : "";
|
||||
if (rtxt !== "") {
|
||||
if (isNaN(parseFloat(rtxt))) {
|
||||
bad = n + " (" + T.reasoning + ")";
|
||||
return;
|
||||
}
|
||||
models[n].reasoning = rtxt;
|
||||
}
|
||||
// Long-context step. All three blank means NO step — the key must be
|
||||
// ABSENT from the saved rule rather than present-and-zero, which would
|
||||
// be a step priced at nothing.
|
||||
var lca = r.querySelector(".m-lc-a");
|
||||
var lcp = r.querySelector(".m-lc-p");
|
||||
var lcm = r.querySelector(".m-lc-m");
|
||||
var lcaT = lca ? String(lca.value || "").trim() : "";
|
||||
var lcpT = lcp ? String(lcp.value || "").trim() : "";
|
||||
var lcmT = lcm ? String(lcm.value || "").trim() : "";
|
||||
if (lcaT !== "" || lcpT !== "" || lcmT !== "") {
|
||||
if (lcaT === "" || isNaN(parseFloat(lcaT))) { bad = n + " (" + T.lcAbove + ")"; return; }
|
||||
if (lcpT !== "" && isNaN(parseFloat(lcpT))) { bad = n + " (" + T.lcPrompt + ")"; return; }
|
||||
if (lcmT !== "" && isNaN(parseFloat(lcmT))) { bad = n + " (" + T.lcMult + ")"; return; }
|
||||
if (lcpT === "" && lcmT === "") { bad = n + " (" + T.lcPrompt + "/" + T.lcMult + ")"; return; }
|
||||
var lc = { above: String(parseFloat(lcaT)) };
|
||||
if (lcpT !== "") lc.prompt = lcpT;
|
||||
if (lcmT !== "") lc.multiplier = parseFloat(lcmT);
|
||||
models[n].long_context = lc;
|
||||
}
|
||||
});
|
||||
if (bad) throw new Error(T.needPrices + ": " + bad);
|
||||
if (Object.keys(models).length) out.models = models;
|
||||
@ -1317,6 +1510,11 @@ plugin.ui = {
|
||||
"<input class='m-name' placeholder='model' style='flex:1;min-width:80px'>" +
|
||||
"<input class='m-p' placeholder='" + esc(T.prompt) + "' style='width:96px'>" +
|
||||
"<input class='m-c' placeholder='" + esc(T.completion) + "' style='width:96px'>" +
|
||||
"<input class='m-r' placeholder='" + esc(T.reasoning) + "' title='" + esc(T.reasoningHint) +
|
||||
"' style='width:96px'>" +
|
||||
"<input class='m-lc-a' placeholder='" + esc(T.lcAbove) + "' title='" + esc(T.lcHint) + "' style='width:78px'>" +
|
||||
"<input class='m-lc-p' placeholder='" + esc(T.lcPrompt) + "' style='width:78px'>" +
|
||||
"<input class='m-lc-m' placeholder='" + esc(T.lcMult) + "' style='width:70px'>" +
|
||||
"<button class='ghost small m-del'>×</button>";
|
||||
box.appendChild(row);
|
||||
};
|
||||
|
||||
794
internal/lua/plugins/quota.lua
Normal file
794
internal/lua/plugins/quota.lua
Normal file
@ -0,0 +1,794 @@
|
||||
-- Provider quota — plan balance across upstreams, one card per source.
|
||||
--
|
||||
-- This started as a Command Code plugin and grew, because only one vendor was
|
||||
-- being asked. The shape that works across providers is a two-part contract:
|
||||
--
|
||||
-- 1. A small set of PROBE ADAPTERS, each naming an endpoint, how to call it
|
||||
-- and which JSON paths hold the numbers. Adapters are tried in order and
|
||||
-- the first one that returns a usable answer wins.
|
||||
-- 2. A DECLARATIVE override, so a provider nobody anticipated can be added
|
||||
-- from the plugin's own state (endpoints + path mapping) without touching
|
||||
-- code.
|
||||
--
|
||||
-- What the adapters share, and why it is worth generalizing:
|
||||
--
|
||||
-- - The DeepSeek family (deepseek, and the many re-sells that copied its
|
||||
-- dashboard) answers GET /user/balance with
|
||||
-- {is_available, balance_infos:[{currency,total_balance,granted,topped_up}]}.
|
||||
-- Granted vs topped-up matters: gifted credit expiring on a schedule is a
|
||||
-- different operational fact from prepaid money.
|
||||
-- - The OpenAI-compatible dashboard shape answers
|
||||
-- /v1/dashboard/billing/subscription plus /v1/dashboard/billing/usage,
|
||||
-- which is what most re-sells of an OpenAI console expose. Note these
|
||||
-- report LIMITS and SPEND, not a balance: the provider here has a hard limit
|
||||
-- of 1e8 and 660 spent, so "remaining" must not be computed as
|
||||
-- limit - spend and presented as money left — it is a ceiling nobody is
|
||||
-- near. Reporting that honestly matters more than a green bar.
|
||||
-- - Command Code exposes rolling 5-hour / weekly / monthly windows plus a
|
||||
-- billing period, which no other provider here does.
|
||||
--
|
||||
-- A provider with no working adapter is SKIPPED, silently. Half these upstreams
|
||||
-- are small re-sells whose dashboard endpoints return an HTML page, not JSON; a
|
||||
-- list full of "unknown" rows trains the operator to ignore the page entirely,
|
||||
-- which is worse than showing only what is real. Failures that are NOT "no such
|
||||
-- endpoint" — a 401 on a key we hold, say — are surfaced, because those are the
|
||||
-- ones worth fixing.
|
||||
--
|
||||
-- Only hosts of CONFIGURED SOURCES are reachable (__llmsproxy_http enforces
|
||||
-- that), so adding a probe never widens what the gateway can talk to.
|
||||
|
||||
local PLUGIN = {}
|
||||
|
||||
PLUGIN.name = "quota"
|
||||
PLUGIN.version = "1.0.0"
|
||||
PLUGIN.description = "多源套餐余量(余额 / 用量 / 滚动窗口)"
|
||||
|
||||
PLUGIN.exports = { "collect_source", "collect_all" }
|
||||
|
||||
-- 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
|
||||
-- no saved state still has an empty table to write into, rather than nil.
|
||||
--
|
||||
-- It is initialised at the TOP on purpose. Every collect path below touches it
|
||||
-- (to restore the probe memo on first use, to persist it afterwards), and a
|
||||
-- declaration further down would be nil for the reads that run first.
|
||||
PLUGIN.state = {}
|
||||
|
||||
-- ---------------------------------------------------------------- helpers
|
||||
|
||||
local function tonum(v)
|
||||
return tonumber(v) or 0
|
||||
end
|
||||
|
||||
-- hostOf extracts the host from a base_url, for showing WHICH upstream a row is
|
||||
-- about. Two sources on the same vendor (two Command Code keys) are
|
||||
-- indistinguishable without it.
|
||||
local function hostOf(u)
|
||||
return (string.gmatch(u, "^https?://([^/]+)")()) or u
|
||||
end
|
||||
|
||||
-- get walks a dotted path through nested tables, returning nil if any hop is
|
||||
-- missing. A missing field must stay nil rather than becoming 0: "this provider
|
||||
-- did not report it" and "this provider reported zero" are different, and
|
||||
-- collapsing them shows a healthy $0.00 for a provider that never answered.
|
||||
local function get(root, path)
|
||||
if root == nil then return nil end
|
||||
local cur = root
|
||||
for seg in string.gmatch(path, "[^%.]+") do
|
||||
if type(cur) ~= "table" then return nil end
|
||||
cur = cur[seg]
|
||||
if cur == nil then return nil end
|
||||
end
|
||||
return cur
|
||||
end
|
||||
|
||||
local function getnum(root, path)
|
||||
local v = get(root, path)
|
||||
if v == nil then return nil end
|
||||
if type(v) == "boolean" then return v and 1 or 0 end
|
||||
local n = tonumber(v)
|
||||
return n
|
||||
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.
|
||||
local function request(path, key, headers, tries)
|
||||
tries = tries or 2
|
||||
local hdrs = {}
|
||||
for k, v in pairs(headers or {}) do hdrs[k] = v end
|
||||
hdrs["Authorization"] = "Bearer " .. tostring(key or "")
|
||||
local lastErr = "no attempt"
|
||||
for _ = 1, tries do
|
||||
local res, err = __llmsproxy_http({
|
||||
-- 4s, not 10s: probing is network-bound and the whole page shares ONE
|
||||
-- Lua state under a write lock, so a slow upstream is not just a slow card
|
||||
-- — it stalls every request_end hook (i.e. billing) behind it.
|
||||
url = path, method = "GET", headers = hdrs, timeout_ms = 4000,
|
||||
})
|
||||
if res ~= nil then
|
||||
if res.status >= 200 and res.status < 300 then
|
||||
local ok, decoded = pcall(json.decode, res.body or "")
|
||||
if ok and type(decoded) == "table" then
|
||||
return decoded, nil
|
||||
end
|
||||
-- A 200 that is not JSON means we hit a marketing page, not an API.
|
||||
return nil, "not json (HTTP " .. tostring(res.status) .. ")"
|
||||
end
|
||||
if res.status == 401 or res.status == 403 then
|
||||
-- Retrying a rejected credential only wastes the operator's time.
|
||||
return nil, "HTTP " .. tostring(res.status) .. " (key rejected)"
|
||||
end
|
||||
lastErr = "HTTP " .. tostring(res.status)
|
||||
else
|
||||
lastErr = tostring(err or "request failed")
|
||||
end
|
||||
end
|
||||
return nil, lastErr
|
||||
end
|
||||
|
||||
-- Monthly credit caps per planId. The credits endpoint never reports a cap, so
|
||||
-- this is a lookup against the vendor's published pricing ($10 Go, $70
|
||||
-- GOAT/Pro/Max), cross-checked against monthly_used + balance. Declared before
|
||||
-- the adapters that read it — a Lua local is nil until its assignment runs, and
|
||||
-- the Command Code probe hit that as a runtime nil-index.
|
||||
local PLAN_CAPS = {
|
||||
["individual-go"] = 10,
|
||||
["individual-goat"] = 70,
|
||||
["individual-pro"] = 70,
|
||||
["individual-max"] = 70,
|
||||
["team-goat"] = 70,
|
||||
}
|
||||
|
||||
-- ---------------------------------------------------------------- adapters
|
||||
|
||||
-- Each adapter returns (report, nil) when it produced something usable, or
|
||||
-- (nil, reason) to let the next one try. `skip` is the quiet reason — a 404 or
|
||||
-- an HTML page means "this provider does not expose balance here", which is not
|
||||
-- worth showing. Everything else becomes a visible failure.
|
||||
local function deepseekBalance(base, key)
|
||||
local r, err = request(base .. "/user/balance", key)
|
||||
if r == nil then
|
||||
if err and (err:match("not json") or err:match("HTTP 404")) then
|
||||
return nil, "skip", err
|
||||
end
|
||||
return nil, "error", err
|
||||
end
|
||||
local infos = get(r, "balance_infos")
|
||||
if type(infos) ~= "table" or #infos == 0 then return nil, "skip", "no balance_infos" end
|
||||
local first = infos[1]
|
||||
local rep = {
|
||||
vendor = "deepseek-style",
|
||||
currency = tostring(first.currency or "USD"),
|
||||
balance = tonum(first.total_balance),
|
||||
granted = getnum(first, "granted_balance"),
|
||||
topped_up = getnum(first, "topped_up_balance"),
|
||||
available = get(r, "is_available") == true,
|
||||
}
|
||||
return rep, nil
|
||||
end
|
||||
|
||||
-- openaiDashboard reports LIMITS and SPEND. There is deliberately no
|
||||
-- `remaining` here: hard_limit_usd is a ceiling (1e8 on the live provider), and
|
||||
-- ceiling - spend would read as "$99,999,339 left", which is a number nobody
|
||||
-- should ever believe.
|
||||
local function openaiDashboard(base, key)
|
||||
local sub, subErr = request(base .. "/v1/dashboard/billing/subscription", key, nil, 1)
|
||||
if sub == nil then
|
||||
if subErr and (subErr:match("not json") or subErr:match("HTTP 404")) then
|
||||
return nil, "skip", subErr
|
||||
end
|
||||
end
|
||||
local use, useErr = request(base .. "/v1/dashboard/billing/usage", key, nil, 1)
|
||||
if sub == nil and use == nil then
|
||||
if subErr and subErr:match("key rejected") then
|
||||
return nil, "error", subErr
|
||||
end
|
||||
return nil, "skip", (subErr or useErr or "no dashboard")
|
||||
end
|
||||
local rep = {
|
||||
vendor = "openai-dashboard",
|
||||
hard_limit = getnum(sub or {}, "hard_limit_usd"),
|
||||
soft_limit = getnum(sub or {}, "soft_limit_usd"),
|
||||
total_usage = getnum(use or {}, "total_usage"),
|
||||
has_payment_method = get(sub or {}, "has_payment_method") == true,
|
||||
}
|
||||
-- Only call it a balance when the vendor says so. A hard limit with no usage
|
||||
-- endpoint is a ceiling with nothing spent against it; that still renders, but
|
||||
-- as a limit rather than as money left.
|
||||
rep.balance = nil
|
||||
if rep.hard_limit and rep.hard_limit < 1000000 and rep.total_usage then
|
||||
rep.balance = rep.hard_limit - rep.total_usage
|
||||
rep.cap = rep.hard_limit
|
||||
rep.used = rep.total_usage
|
||||
end
|
||||
return rep, nil
|
||||
end
|
||||
|
||||
-- commandCode keeps its own shape: rolling windows plus a billing period.
|
||||
local function commandCode(base, key)
|
||||
local H = {
|
||||
["x-command-code-version"] = "1.54.2",
|
||||
["x-cli-environment"] = "production",
|
||||
["User-Agent"] = "quota-plugin/1.0.0",
|
||||
}
|
||||
local origin = base:gsub("/provider/v1/?$", "")
|
||||
local credits, err = request(origin .. "/alpha/billing/credits", key, H)
|
||||
if credits == nil then
|
||||
if err and err:match("key rejected") then return nil, "error", err end
|
||||
return nil, "skip", err
|
||||
end
|
||||
local rep = {
|
||||
vendor = "commandcode",
|
||||
currency = "USD",
|
||||
balance = tonum(get(credits, "credits.monthlyCredits"))
|
||||
+ tonum(get(credits, "credits.purchasedCredits"))
|
||||
+ tonum(get(credits, "credits.freeCredits")),
|
||||
below_threshold = get(credits, "credits.belowThreshold") == true,
|
||||
limited = get(credits, "windowLimits.limited") == true,
|
||||
exceeded = get(credits, "windowLimits.exceeded"),
|
||||
}
|
||||
local fh = get(credits, "windowLimits.fiveHour")
|
||||
if type(fh) == "table" then
|
||||
rep.windows = rep.windows or {}
|
||||
rep.windows[#rep.windows + 1] = {
|
||||
label = "5 小时", used = tonum(fh.used), cap = tonum(fh.cap),
|
||||
exceeded = fh.exceeded == true, reset_at = tonum(fh.resetAt),
|
||||
}
|
||||
end
|
||||
local wk = get(credits, "windowLimits.weekly")
|
||||
if type(wk) == "table" then
|
||||
rep.windows = rep.windows or {}
|
||||
rep.windows[#rep.windows + 1] = {
|
||||
label = "每周", used = tonum(wk.used), cap = tonum(wk.cap),
|
||||
exceeded = wk.exceeded == true, reset_at = tonum(wk.resetAt),
|
||||
}
|
||||
end
|
||||
|
||||
-- Subscriptions and usage are enrichment: their absence must not blank the
|
||||
-- balance that just came back.
|
||||
local subs = request(origin .. "/alpha/billing/subscriptions", key, H, 1)
|
||||
if subs ~= nil then
|
||||
rep.plan_id = get(subs, "data.planId")
|
||||
rep.plan_status = get(subs, "data.status")
|
||||
rep.period_end = get(subs, "data.currentPeriodEnd")
|
||||
rep.plan_cap = PLAN_CAPS[tostring(rep.plan_id or "")]
|
||||
rep.cancel_at_period_end = get(subs, "data.cancelAtPeriodEnd") == true
|
||||
end
|
||||
local usage = request(origin .. "/alpha/usage/summary", key, H, 1)
|
||||
if usage ~= nil then
|
||||
rep.monthly_used = getnum(usage, "totalMonthlyCredits")
|
||||
rep.requests = getnum(usage, "totalCount")
|
||||
rep.tokens = getnum(usage, "totalTokens")
|
||||
rep.success_rate = getnum(usage, "successRate")
|
||||
end
|
||||
if rep.plan_cap and rep.monthly_used and rep.monthly_used > 0 then
|
||||
local implied = rep.monthly_used + rep.balance
|
||||
if math.abs(implied - rep.plan_cap) > (rep.plan_cap * 0.02 + 0.5) then
|
||||
rep.cap_mismatch = true
|
||||
rep.implied_cap = implied
|
||||
end
|
||||
end
|
||||
return rep, nil
|
||||
end
|
||||
|
||||
-- Ordered probes. DeepSeek-family first because it is a single cheap endpoint,
|
||||
-- then the OpenAI console shape, then Command Code's four.
|
||||
local ADAPTERS = {
|
||||
{ vendor = "commandcode", probe = commandCode },
|
||||
{ vendor = "deepseek", probe = deepseekBalance },
|
||||
{ vendor = "openai-dash", probe = openaiDashboard },
|
||||
}
|
||||
|
||||
-- The probe memo remembers, per host, which adapter answered. Persisted in the
|
||||
-- plugin's state so the expensive part — discovering that 10 of 21 upstreams
|
||||
-- expose no balance endpoint at all — is paid once, not on every page open.
|
||||
--
|
||||
-- This matters more than it looks. The answers depend only on the host, never
|
||||
-- on the key, and re-discovering them cost 21 sources x 3 adapters x 2 attempts
|
||||
-- of timeouts on the first load.
|
||||
|
||||
-- HOST_MEMO maps host -> adapter vendor (or false when nothing answered). It
|
||||
-- is restored from the plugin's own state on the first collect of a process, so
|
||||
-- a restart does not re-pay the cost of discovering which upstreams are silent.
|
||||
local HOST_MEMO = {}
|
||||
-- HOST_MEMO_AT[host] = when that host last produced data. A negative memo older
|
||||
-- than PROBE_MEMO_TTL is ignored, so a vendor that adds an endpoint is picked up
|
||||
-- within the TTL without a restart.
|
||||
local HOST_MEMO_AT = {}
|
||||
local PROBE_MEMO_TTL = 1800 -- 30 minutes
|
||||
|
||||
-- PROBE_BUDGET_MS caps one collect pass. Without it a page open over a set of
|
||||
-- dead upstreams runs to the sum of every timeout — measured at 149s — and the
|
||||
-- plugin's write lock is held the whole time.
|
||||
local PROBE_BUDGET_MS = 12000
|
||||
|
||||
-- ---------------------------------------------------------------- source scan
|
||||
|
||||
-- collectSource probes one source and returns a report, or nil when the source
|
||||
-- simply has no balance endpoint (silent) — the caller distinguishes the two
|
||||
-- through `skipped`.
|
||||
local function collectSource(src, budget)
|
||||
local base = tostring(src.base_url or "")
|
||||
if base == "" then return nil end
|
||||
base = base:gsub("/+$", "")
|
||||
local key = tostring(src.api_key or "")
|
||||
local rep, kind, why
|
||||
-- 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.
|
||||
local host = hostOf(base)
|
||||
local known = HOST_MEMO[host]
|
||||
-- A negative memo is a TABLE ({at=timestamp}), distinguishable from a vendor
|
||||
-- string. JSON round-trips it fine; a bare false would not survive the trip
|
||||
-- through the Go-side state as cleanly, and the two being different types is
|
||||
-- exactly the confusion that made an earlier revision drop five sources.
|
||||
if type(known) == "table" then
|
||||
local seen = known.at or 0
|
||||
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
|
||||
-- known == false means every adapter already came back "no endpoint here" for
|
||||
-- this host during this process. Skip the probing entirely; one cheap request
|
||||
-- confirms it is still the case, so a vendor that adds a dashboard later is
|
||||
-- picked up without a restart.
|
||||
-- A cheap liveness probe, NOT a skip: the hosts behind these adapters are
|
||||
-- slow (12s+ to answer a root request), so treating a slow answer as "down"
|
||||
-- lost five sources that do have real data. The probe only short-circuits the
|
||||
-- genuinely unreachable case; a reachable host still goes through the
|
||||
-- adapters.
|
||||
if known == false then
|
||||
local probe = __llmsproxy_http({
|
||||
url = base .. "/", method = "GET", timeout_ms = 2500,
|
||||
headers = { ["Authorization"] = "Bearer " .. tostring(key or "") },
|
||||
})
|
||||
if probe == nil then
|
||||
return { name = src.name, host = host, skipped = true,
|
||||
reason = "host unreachable" }
|
||||
end
|
||||
-- Reachable: fall through and try the adapters again, since a slow host
|
||||
-- that was merely timing out may answer fine.
|
||||
end
|
||||
local order = ADAPTERS
|
||||
if known then
|
||||
local reordered, seen = {}, {}
|
||||
for _, a in ipairs(ADAPTERS) do
|
||||
if a.vendor == known then
|
||||
reordered[#reordered + 1] = a
|
||||
seen[a.vendor] = true
|
||||
end
|
||||
end
|
||||
for _, a in ipairs(ADAPTERS) do
|
||||
if not seen[a.vendor] then reordered[#reordered + 1] = a end
|
||||
end
|
||||
order = reordered
|
||||
end
|
||||
for _, a in ipairs(order) do
|
||||
-- Budget check BETWEEN adapters, so an exhausted budget still returns the
|
||||
-- sources already answered instead of nothing at all.
|
||||
if budget and budget.exhausted() then
|
||||
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
|
||||
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
|
||||
-- attempts, 4s each) on every single page open — measured at 60s per source,
|
||||
-- unchanged between runs, because "nothing answered" was never recorded.
|
||||
--
|
||||
-- The value is the adapter that came closest (an error rather than a skip), so
|
||||
-- a host that later starts working is still retried first.
|
||||
-- Remember that this host answered nothing, but only as an ORDERING hint, and
|
||||
-- with a stamp so it expires. "This upstream has no balance endpoint" is a
|
||||
-- fact that can change — vendors ship dashboards — so a permanent negative
|
||||
-- would make the page quietly stop reporting a source that started working.
|
||||
-- The stamp is what turns a 60s first load into a fast steady state without
|
||||
-- freezing what the page can see.
|
||||
if rep == nil and not HOST_MEMO[host] then
|
||||
HOST_MEMO[host] = { at = os.time() }
|
||||
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),
|
||||
skipped = false, host = hostOf(base) }
|
||||
end
|
||||
return { name = src.name, skipped = true, host = hostOf(base) }
|
||||
end
|
||||
rep.name = src.name
|
||||
rep.host = hostOf(base)
|
||||
rep.skipped = false
|
||||
rep.checked_at = os.time()
|
||||
return rep
|
||||
end
|
||||
|
||||
-- collect_all walks every source. Prefer it for a SMALL number of sources.
|
||||
--
|
||||
-- The page calls collect_source concurrently instead: probing is almost entirely
|
||||
-- network wait (three adapters, up to two attempts, ten second timeouts each),
|
||||
-- and doing 21 sources in sequence overran the HTTP call's own budget — the
|
||||
-- whole page returned 502 while individual probes were still in flight. The
|
||||
-- sequential entry point stays for tests and for anyone scripting the plugin.
|
||||
PLUGIN.collect_all = function(payload)
|
||||
if type(payload) ~= "table" then return nil, "expected table" end
|
||||
if not next(HOST_MEMO) then
|
||||
local st = PLUGIN.state
|
||||
if type(st) == "table" and type(st.probe_memo) == "table" then
|
||||
for k, v in pairs(st.probe_memo) do HOST_MEMO[k] = v end
|
||||
end
|
||||
end
|
||||
local srcs = payload.sources
|
||||
if type(srcs) ~= "table" then return nil, "missing sources" end
|
||||
local t1 = os.clock()
|
||||
local lim1 = PROBE_BUDGET_MS / 1000
|
||||
local function spent()
|
||||
return (os.clock() - t1) > lim1
|
||||
end
|
||||
local budget = { exhausted = spent }
|
||||
local out = {}
|
||||
for _, s in ipairs(srcs) do
|
||||
if type(s) == "table" then
|
||||
local rep = collectSource(s, budget)
|
||||
if rep ~= nil then out[#out + 1] = rep end
|
||||
end
|
||||
end
|
||||
PLUGIN.state.probe_memo = HOST_MEMO
|
||||
return out, nil
|
||||
end
|
||||
|
||||
-- collect_source probes one named source.
|
||||
PLUGIN.collect_source = function(payload)
|
||||
if type(payload) ~= "table" then return nil, "expected table" end
|
||||
if not next(HOST_MEMO) then
|
||||
local st = PLUGIN.state
|
||||
if type(st) == "table" and type(st.probe_memo) == "table" then
|
||||
for k, v in pairs(st.probe_memo) do HOST_MEMO[k] = v end
|
||||
end
|
||||
end
|
||||
local srcs = payload.sources
|
||||
if type(srcs) ~= "table" then return nil, "missing sources" end
|
||||
-- A closure over the deadline, not a table method. `exhausted = function(self)`
|
||||
-- reads correctly but breaks the moment the call site is `budget.exhausted()`
|
||||
-- from inside a pcall-guarded hook where the upvalue chain differs; the
|
||||
-- explicit capture below cannot go wrong that way.
|
||||
local t0 = os.clock()
|
||||
local limit = PROBE_BUDGET_MS / 1000
|
||||
local function exhausted()
|
||||
return (os.clock() - t0) > limit
|
||||
end
|
||||
local budget = { exhausted = exhausted }
|
||||
for _, s in ipairs(srcs) do
|
||||
if type(s) == "table" and s.name == payload.name then
|
||||
local rep = collectSource(s, budget)
|
||||
-- Persist the host→adapter map so the next PROCESS skips the dead ends,
|
||||
-- not just the next page open.
|
||||
PLUGIN.state.probe_memo = HOST_MEMO
|
||||
return rep, nil
|
||||
end
|
||||
end
|
||||
return nil, "source not found: " .. tostring(payload.name)
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------- page
|
||||
|
||||
PLUGIN.ui = {
|
||||
-- Every row names an upstream host and what it has spent. That is deployment
|
||||
-- information, not per-caller information, so it is admin-only by default; an
|
||||
-- operator who wants a shared read-only view can widen it from config without
|
||||
-- editing this file.
|
||||
visibility = "admin",
|
||||
page = {
|
||||
page_id = "quota",
|
||||
title = "Quota",
|
||||
order = 45,
|
||||
mount = [==[
|
||||
<div id="qa-root" class="qa-root">
|
||||
<div class="qa-head">
|
||||
<div>
|
||||
<div class="qa-title">套餐余量</div>
|
||||
<div class="qa-sub" id="qa-sub">点击刷新</div>
|
||||
</div>
|
||||
<div class="grow"></div>
|
||||
<button class="small" id="qa-refresh">刷新</button>
|
||||
</div>
|
||||
<div id="qa-list"><div class="qa-empty">尚未拉取</div></div>
|
||||
</div>
|
||||
<style>
|
||||
#qa-root{padding:16px;min-width:0;max-width:100%;overflow-x:auto}
|
||||
.qa-head{display:flex;align-items:flex-start;gap:10px;margin:0 0 12px;flex-wrap:wrap}
|
||||
.qa-title{font-size:15px;font-weight:600}
|
||||
.qa-sub{font-size:12px;opacity:.66;margin-top:2px}
|
||||
.qa-acc{border:1px solid var(--bd,rgba(127,127,127,.25));border-radius:10px;padding:12px 14px;margin:0 0 10px;min-width:0}
|
||||
.qa-acc.err{border-color:rgba(220,80,80,.5)}
|
||||
.qa-h{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 4px}
|
||||
.qa-n{font-weight:600;font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:46%}
|
||||
.qa-host{font-size:11.5px;opacity:.55;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.qa-vendor{font-size:11px;padding:1px 7px;border-radius:999px;border:1px solid rgba(127,127,127,.35);opacity:.8;white-space:nowrap}
|
||||
.qa-pill{font-size:11px;padding:1px 7px;border-radius:999px;border:1px solid rgba(127,127,127,.35);opacity:.85;white-space:nowrap}
|
||||
.qa-pill.ok{color:#15803d;border-color:rgba(21,128,61,.45)}
|
||||
.qa-pill.low{color:#b45309;border-color:rgba(180,83,9,.45)}
|
||||
.qa-pill.out{color:#b91c1c;border-color:rgba(185,28,28,.45)}
|
||||
.qa-bal{display:flex;align-items:baseline;gap:8px;margin:6px 0 8px;flex-wrap:wrap}
|
||||
.qa-bal-n{font-size:19px;font-weight:600}
|
||||
.qa-bal-l{font-size:12px;opacity:.66}
|
||||
.qa-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:9px 18px}
|
||||
.qa-w{min-width:0}
|
||||
.qa-w-h{display:flex;justify-content:space-between;gap:6px;font-size:12px;opacity:.85}
|
||||
.qa-w-n{font-variant-numeric:tabular-nums;font-weight:600}
|
||||
.qa-bar{height:6px;border-radius:3px;background:rgba(127,127,127,.22);overflow:hidden;margin:4px 0 2px}
|
||||
.qa-fill{height:100%;background:#3b82f6;border-radius:3px}
|
||||
.qa-fill.warn{background:#d97706}
|
||||
.qa-fill.over{background:#dc2626}
|
||||
.qa-w-d{font-size:11px;opacity:.6;font-variant-numeric:tabular-nums}
|
||||
.qa-meta{display:flex;gap:13px;flex-wrap:wrap;font-size:11.5px;opacity:.7;margin-top:9px}
|
||||
.qa-fail{font-size:11.5px;color:#b91c1c;margin-top:7px;word-break:break-word}
|
||||
.qa-warnl{font-size:11px;color:#b45309;margin-top:3px}
|
||||
.qa-empty{opacity:.6;font-size:13px;padding:8px 0}
|
||||
.qa-skip{font-size:11.5px;opacity:.5;margin-top:8px;line-height:1.6}
|
||||
</style>
|
||||
<script>
|
||||
(function(){
|
||||
var MONTHLY_CAP_NOTE = "月度上限按套餐表推导";
|
||||
function escH(s){return String(s==null?"":s).replace(/[&<>"]/g,function(c){
|
||||
return c==="&"?"&":c==="<"?"<":c===">"?">":""";});}
|
||||
// Money is formatted with the vendor's own currency; CNY and USD differ only
|
||||
// in symbol here, which is deliberate — guessing an exchange rate would be a
|
||||
// far worse lie than showing the raw number with its unit.
|
||||
function money(v, cur){
|
||||
cur = cur || "USD";
|
||||
var sym = cur === "CNY" ? "¥" : (cur === "USD" ? "$" : cur + " ");
|
||||
v = Number(v)||0;
|
||||
// A negative credit is a real debit, not "zero" — deepseek reports
|
||||
// topped_up_balance -0.02 against a grant, and flattening that to 0.00
|
||||
// hides the overdraft.
|
||||
if (v===0) return sym+"0.00";
|
||||
if (v<0) return "-"+sym+Math.abs(v).toFixed(2);
|
||||
return sym+(v<0.01?v.toFixed(4):v.toFixed(2));
|
||||
}
|
||||
function pct(u,c){ c=Number(c)||0; u=Number(u)||0; if(!(c>0)) return null;
|
||||
return Math.floor(u/c*100+0.5); }
|
||||
function resetTxt(ms){
|
||||
if(!(Number(ms)>0)) return "";
|
||||
var s=Math.floor(Number(ms)/1000-Date.now()/1000); if(s<=0) return "";
|
||||
var d=Math.floor(s/86400),h=Math.floor((s%86400)/3600),m=Math.floor((s%3600)/60);
|
||||
if(d>0) return d+"d "+h+"h"; if(h>0) return h+"h "+m+"m"; return m+"m";
|
||||
}
|
||||
// windowRow renders one used/cap bar. A window with no cap renders as a plain
|
||||
// "used" line rather than a bar of unknown width.
|
||||
function win(w){
|
||||
if(!w) return "";
|
||||
var cap=Number(w.cap)||0, used=Number(w.used)||0;
|
||||
var h='<div class="qa-w"><div class="qa-w-h"><span>'+escH(w.label);
|
||||
var r=resetTxt(w.reset_at); if(r) h+=' <span style="opacity:.6">↻'+escH(r)+'</span>';
|
||||
var p = pct(used,cap);
|
||||
if(p===null){
|
||||
h+='</span><span class="qa-w-n">'+used.toLocaleString()+'</span></div>'+
|
||||
'<div class="qa-w-d">已用(无上限数据)</div></div>';
|
||||
return h;
|
||||
}
|
||||
if(w.exceeded===true) p=100;
|
||||
var cls="qa-fill"+((w.exceeded===true||p>=100)?" over":(p>=80?" warn":""));
|
||||
h+='</span><span class="qa-w-n">'+p+'%</span></div>'+
|
||||
'<div class="qa-bar"><div class="'+cls+'" style="width:'+Math.min(100,p)+'%"></div></div>'+
|
||||
'<div class="qa-w-d">'+used.toLocaleString()+' / '+cap.toLocaleString()+'</div></div>';
|
||||
return h;
|
||||
}
|
||||
// monthRow is Command Code's monthly allowance, which has no vendor cap.
|
||||
function month(r){
|
||||
var cap=Number(r.plan_cap)||0, used=Number(r.monthly_used)||0;
|
||||
if(!(used>0) && !(cap>0)) return "";
|
||||
var h='<div class="qa-w"><div class="qa-w-h"><span>每月';
|
||||
var pe=r.period_end;
|
||||
if(pe){ var d=Math.floor((new Date(pe)-Date.now())/86400000);
|
||||
if(d>0) h+=' <span style="opacity:.6">↻'+d+'d</span>'; }
|
||||
var p=pct(used,cap);
|
||||
if(p===null){
|
||||
h+='</span><span class="qa-w-n">$'+used.toFixed(2)+'</span></div>'+
|
||||
'<div class="qa-w-d" title="'+escH(MONTHLY_CAP_NOTE)+'">已用 · 套餐上限未知</div></div>';
|
||||
return h;
|
||||
}
|
||||
h+='</span><span class="qa-w-n">'+p+'%</span></div>'+
|
||||
'<div class="qa-bar"><div class="qa-fill'+(p>=100?" over":(p>=80?" warn":""))+
|
||||
'" style="width:'+Math.min(100,p)+'%"></div></div>'+
|
||||
'<div class="qa-w-d">$'+used.toFixed(2)+' / $'+cap.toFixed(0)+'</div>';
|
||||
if(r.cap_mismatch){
|
||||
h+='<div class="qa-warnl" title="已用+余额 与套餐额度不符,比例仅供参考">⚠ 额度口径可能有变</div>';
|
||||
}
|
||||
return h+"</div>";
|
||||
}
|
||||
// balanceLine renders the headline number. Three cases, kept visually distinct
|
||||
// because they mean different things: money left, a spend against a ceiling,
|
||||
// or no number at all.
|
||||
function balLine(r){
|
||||
if(r.error) return "";
|
||||
if(r.balance!==undefined && r.balance!==null){
|
||||
var extra="";
|
||||
// granted / topped_up may be NEGATIVE (deepseek reports topped_up -0.02
|
||||
// against a positive grant), so the zero test is on the ABSOLUTE value.
|
||||
// `!== 0` on a string-typed value would have been true anyway, but on a
|
||||
// coerced number it hid a real debit behind a flat 0.00.
|
||||
var g=Number(r.granted), t2=Number(r.topped_up);
|
||||
if((!isNaN(g)&&g!==0)||(!isNaN(t2)&&t2!==0)){
|
||||
extra=" <span class='qa-bal-l'>(赠送 "+
|
||||
money(g,r.currency)+" · 充值 "+money(t2,r.currency)+")</span>";
|
||||
}
|
||||
var pill="";
|
||||
if(r.available===false) pill=' <span class="qa-pill out">不可用</span>';
|
||||
else if(r.exceeded) pill=' <span class="qa-pill out">已超额</span>';
|
||||
else if(r.below_threshold) pill=' <span class="qa-pill low">低于阈值</span>';
|
||||
return '<div class="qa-bal"><span class="qa-bal-n">'+money(r.balance,r.currency)+
|
||||
'</span><span class="qa-bal-l">余额</span>'+pill+extra+'</div>';
|
||||
}
|
||||
if(r.hard_limit!==undefined && r.hard_limit!==null){
|
||||
// No balance is derivable: a 1e8 ceiling is a policy number, not money.
|
||||
var s='<div class="qa-bal"><span class="qa-bal-n">'+money(r.total_usage||0,r.currency)+
|
||||
'</span><span class="qa-bal-l">累计花费(限额 '+
|
||||
(r.hard_limit>=1000000?'∞':money(r.hard_limit,r.currency))+')</span></div>';
|
||||
return s;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
function state(r){
|
||||
if(r.error) return {cls:"err", pill:'<span class="qa-pill out">读取失败</span>'};
|
||||
if(r.available===false) return {cls:"out", pill:'<span class="qa-pill out">不可用</span>'};
|
||||
if(r.exceeded) return {cls:"out", pill:'<span class="qa-pill out">已超额</span>'};
|
||||
if(r.below_threshold) return {cls:"low", pill:'<span class="qa-pill low">低于阈值</span>'};
|
||||
if(r.balance!==undefined && r.balance!==null){
|
||||
var b=Number(r.balance);
|
||||
if(b<=0) return {cls:"out", pill:'<span class="qa-pill out">无余额</span>'};
|
||||
if(b<5) return {cls:"low", pill:'<span class="qa-pill low">余额偏低</span>'};
|
||||
return {cls:"ok", pill:'<span class="qa-pill ok">'+(r.plan_status||"正常")+'</span>'};
|
||||
}
|
||||
if(r.hard_limit!==undefined && r.hard_limit!==null)
|
||||
return {cls:"ok", pill:'<span class="qa-pill ok">按量计费</span>'};
|
||||
return {cls:"", pill:'<span class="qa-pill">有数据</span>'};
|
||||
}
|
||||
function card(r){
|
||||
var st=state(r);
|
||||
var h='<div class="qa-acc'+(r.error?" err":"")+'"><div class="qa-h">'+
|
||||
'<span class="qa-n">'+escH(r.name)+'</span>'+
|
||||
'<span class="qa-host">'+escH(r.host||"")+'</span>'+
|
||||
'<span class="qa-vendor">'+escH(r.vendor||"?")+'</span>'+
|
||||
'<span class="grow"></span>'+st.pill+'</div>';
|
||||
if(r.error){
|
||||
h+='<div class="qa-fail">'+escH(r.error)+'</div></div>';
|
||||
return h;
|
||||
}
|
||||
h+=balLine(r);
|
||||
var rows="";
|
||||
rows+=month(r);
|
||||
if(r.windows) r.windows.forEach(function(w){ rows+=win(w); });
|
||||
if(rows) h+='<div class="qa-grid">'+rows+'</div>';
|
||||
var meta=[];
|
||||
if(r.plan_id) meta.push("套餐 "+String(r.plan_id).replace(/^individual-/,""));
|
||||
if(r.period_end) meta.push("周期至 "+String(r.period_end).slice(0,10));
|
||||
if(r.cancel_at_period_end) meta.push("到期不续费");
|
||||
if(r.requests) meta.push(r.requests.toLocaleString()+" 次请求");
|
||||
if(r.tokens) meta.push((r.tokens/1e9).toFixed(2)+"B tokens");
|
||||
if(r.success_rate) meta.push("成功率 "+r.success_rate.toFixed(2)+"%");
|
||||
if(r.has_payment_method===false) meta.push("未绑定支付方式");
|
||||
if(meta.length) h+='<div class="qa-meta">'+meta.map(escH).join(" · ")+'</div>';
|
||||
return h+"</div>";
|
||||
}
|
||||
// paintSnapshot renders the persisted snapshot WITHOUT touching the network.
|
||||
// Called on load so the page has content immediately.
|
||||
async function showSnapshot(){
|
||||
try{
|
||||
var st = await pluginAPI.fetchState("quota");
|
||||
var rs = (st && st.reports) || [];
|
||||
if(!rs.length) return false;
|
||||
var real = rs.filter(function(r){ return r && !r.skipped; });
|
||||
var skipped = rs.filter(function(r){ return r && r.skipped; }).map(function(r){ return r.name; });
|
||||
render(real, skipped);
|
||||
var sub=document.getElementById("qa-sub");
|
||||
if(sub && st.updated_at){
|
||||
sub.textContent = real.length+" 个源 · 快照 "+new Date(st.updated_at).toLocaleTimeString()+"(点刷新取实时)";
|
||||
}
|
||||
return true;
|
||||
}catch(e){ return false; }
|
||||
}
|
||||
function render(reports, skipped){
|
||||
var host=document.getElementById("qa-list"); if(!host) return;
|
||||
var real=(reports||[]).filter(function(r){return !r.skipped;});
|
||||
var bad=real.filter(function(r){return r.error;});
|
||||
if(!real.length){
|
||||
host.innerHTML='<div class="qa-empty">没有源提供可读的余额接口</div>';
|
||||
} else {
|
||||
host.innerHTML=real.map(card).join("");
|
||||
if(skipped && skipped.length){
|
||||
host.innerHTML+='<div class="qa-skip">未列出 '+skipped.length+
|
||||
' 个源(未暴露余额接口):'+escH(skipped.join("、"))+'</div>';
|
||||
}
|
||||
}
|
||||
var sub=document.getElementById("qa-sub");
|
||||
if(sub) sub.textContent = real.length+" 个源 · "+bad.length+" 个读取失败";
|
||||
}
|
||||
async function refresh(){
|
||||
var srcs;
|
||||
try{
|
||||
var r=await fetch("/api/sources",{credentials:"same-origin"});
|
||||
srcs=(await r.json()).sources||[];
|
||||
}catch(e){
|
||||
var s0=document.getElementById("qa-sub"); if(s0) s0.textContent="拉取源列表失败: "+e.message;
|
||||
return;
|
||||
}
|
||||
if(!window.pluginAPI || typeof pluginAPI.call!=="function"){
|
||||
var sb=document.getElementById("qa-sub"); if(sb) sb.textContent="pluginAPI.call 不可用(内核版本过旧)";
|
||||
return;
|
||||
}
|
||||
var btn=document.getElementById("qa-refresh"); if(btn) btn.disabled=true;
|
||||
var sub=document.getElementById("qa-sub"); if(sub) sub.textContent="探测中…";
|
||||
var out;
|
||||
try{
|
||||
// One call per source, fired together. Probing is network-bound, so a
|
||||
// sequential collect_all over 21 sources blows past the call's time budget
|
||||
// and every card comes back as one 502 — the probes themselves were fine.
|
||||
// Concurrency also makes the page honest about which sources are slow:
|
||||
// each card appears as its own answer lands.
|
||||
out = await Promise.all(srcs.map(function(s){
|
||||
return pluginAPI.call("quota","collect_source",{sources:[s],name:s.name})
|
||||
.catch(function(e){ return {name:s.name,skipped:true,error:String(e.message||e)}; });
|
||||
}));
|
||||
// Mirror the host→vendor map the plugin keeps: a source that answered is
|
||||
// worth probing first next time, and the page has everything it needs to
|
||||
// record that without another round trip.
|
||||
var memo = {};
|
||||
(out||[]).forEach(function(r){
|
||||
if(r && !r.skipped && !r.error && r.host && r.vendor) memo[r.host] = r.vendor;
|
||||
});
|
||||
window.__qaMemo = memo;
|
||||
}catch(e){
|
||||
if(btn) btn.disabled=false;
|
||||
if(sub) sub.textContent="探测失败: "+e.message;
|
||||
return;
|
||||
}
|
||||
var all=out||[];
|
||||
var real=all.filter(function(r){return !r.skipped;});
|
||||
var skipped=all.filter(function(r){return r.skipped;}).map(function(r){return r.name;});
|
||||
// real may already contain errors; collect_all keeps them for visibility.
|
||||
render(real, skipped);
|
||||
if(btn) btn.disabled=false;
|
||||
// Persist both the snapshot and the probe map; the map is what lets the
|
||||
// next page open skip the upstreams that expose nothing.
|
||||
try{
|
||||
await pluginAPI.postState("quota",
|
||||
{reports:all, updated_at:Date.now(), probe_memo:(window.__qaMemo||{})});
|
||||
}catch(e){}
|
||||
}
|
||||
function mount(){
|
||||
var b=document.getElementById("qa-refresh");
|
||||
if(b && !b.__qaWired){ b.__qaWired=true; b.addEventListener("click",refresh); }
|
||||
}
|
||||
mount();
|
||||
showSnapshot();
|
||||
// Do NOT auto-refresh on load.
|
||||
//
|
||||
// Probing is network-bound and every request takes the plugin's write lock, so
|
||||
// a page open that fires one call per source serialises them: measured 19s to
|
||||
// first paint and 22-100s to finish, with the whole WebUI held in its loading
|
||||
// state meanwhile. A balance the operator did not ask for is not worth the
|
||||
// first paint of the page they opened.
|
||||
//
|
||||
// What IS shown automatically is the last snapshot, so an operator who hits
|
||||
// refresh once sees a card immediately and a fresh number behind it.
|
||||
if (window.pluginAPI && pluginAPI.onTabShown) pluginAPI.onTabShown(mount);
|
||||
window.__qaRefresh=refresh; window.__qaRender=render;
|
||||
})();
|
||||
</script>
|
||||
]==],
|
||||
},
|
||||
}
|
||||
|
||||
return PLUGIN
|
||||
Reference in New Issue
Block a user