package lua // Plugin runtime. // // A plugin is a single .lua file, loaded from its own directory, that extends // the gateway in two ways: // // 1. Hooks: it registers callbacks on the request pipeline's stages // (request_start, response_end, …). A hook receives a JSON table and // returns either nil (no opinion) or a JSON object. // 2. UI: at boot it returns HTML/CSS/JS fragments that the kernel injects // into the WebUI — either as a whole new page, or as an extra element on // an existing page. // // Design notes that are load-bearing (each one cost something to learn): // // - Plugins run in a SEPARATE VM from adapters, and each plugin gets its own // elastic pool, exactly like an adapter. Sharing one state would let a // plugin's globals corrupt an adapter's protocol translation (or vice // versa), and a plugin is third-party code while an adapter is core. // // - A plugin that errors must NEVER break request forwarding. Hook calls are // wrapped so a plugin error is logged and the original value is returned // unchanged. A broken plugin is a missing feature, not an outage — the same // reason transform_stream_chunk swallows errors today. // // - The billing plugin therefore cannot be trusted to be the source of truth // for anything the gateway must enforce. It reads usage off the hook // payload and accumulates in its own state; the gateway's own quota // accounting (internal/gateway/stats.go) stays authoritative for limits. // Two accounting paths that disagree is worse than one that is slightly // less featureful, so the split is explicit and documented. import ( "encoding/json" "fmt" "os" "path/filepath" "sort" "strings" "sync" "sync/atomic" "time" golua "github.com/aarzilli/golua/lua" ) // Stage identifies a point in the request pipeline. Plugins may register a // function for any stage; unknown stages are ignored at call time so an old // plugin survives a gateway that grew new stages. type Stage string // The pipeline stages. The order here is the order they fire in; it is the // contract plugins are written against. const ( // StageRequestStart fires after the gateway has parsed and authorized a // request but BEFORE any upstream slot is chosen. payload: // stage, type ("chat"|"stream"|"image"), model (as requested), // key (masked gateway key id), role, source (empty), stream (bool), // messages_count, tools_count, ts (unix seconds). StageRequestStart Stage = "request_start" // StageRouted fires once a (source, model) slot has been chosen and before // the upstream call. payload adds: source, model (the resolved one), // tier (AUTO tier, -1 for the direct path), stream. StageRouted Stage = "routed" // StageChainStep fires ONCE PER STEP of an AUTO chain walk, and only on the // AUTO path (a direct request has no chain and therefore emits nothing). // // This exists because StageRouted cannot express degradation: it fires once, // after the walk, with the slot that finally won. "tier 1 was cooling so we // dropped to tier 3" and "tier 1 served it" were indistinguishable. That // distinction is the whole point of a priority chain, and it is what an // operator debugging "why did my expensive model not get used" needs. // // payload: // kind "tier_skip" | "slot_fail" | "tier_busy" | "selected" // tier the AUTO tier this step belongs to (1 = highest priority) // source / model set for slot_fail and selected // reason human-readable cause, for tier_skip and tier_busy // error the underlying error text, for slot_fail // attempt 1-based slot attempt within this walk // // Ordering: every step precedes StageRouted, and the "selected" step is the // last one. A plugin accumulating the walk therefore has the full picture // by the time request_end arrives. // // These events are OBSERVATION ONLY — see the accounting note in // docs/plugins.md: nothing here feeds back into routing, cooldown or quota. StageChainStep Stage = "chain_step" // StageRequestEnd fires exactly once per request, after the client response // has been produced (or after a failure was recorded). payload adds: // source, model, ok, status, latency_ms, first_byte_ms, prompt_tokens, // completion_tokens, cache_hit_tokens, cache_miss_tokens, image_count, // error ("" when ok). // // This is the stage a billing plugin should read: it carries the final // accounting for the request, including the upstream's own usage numbers. StageRequestEnd Stage = "request_end" ) // AllStages is the firing order, used by the docs and by the hook listing. var AllStages = []Stage{ StageRequestStart, StageChainStep, StageRouted, StageRequestEnd, } // UIExtension is what a plugin contributes to the WebUI at boot. type UIExtension struct { // Page is a whole new sidebar entry + pane. Requires PageID and Title. // The kernel renders Page's HTML into a pane whose id is "tab-"+PageID and // adds a sidebar button with data-tab="". Page *UIPage `json:"page,omitempty"` // 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"` } // UIPage is a plugin-provided page. type UIPage struct { PageID string `json:"page_id"` // kebab-case; becomes data-tab and #tab- 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) // Mount is the page body. It may contain