From 3c59c33c58fc44ca14ad08d89bd0641cd0f7b1e5 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 12 Oct 2026 16:32:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(adapters):=20=E5=85=A5=E5=90=91=E7=AB=AF?= =?UTF-8?q?=E7=82=B9=E7=A7=BB=E4=BA=A4=E9=80=82=E9=85=8D=E5=99=A8=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20adapter.ingress=20+=20deserialize=5Freques?= =?UTF-8?q?t=EF=BC=8C=E5=86=85=E6=A0=B8=E4=B8=BB=E5=8A=A8=E8=AE=A9?= =?UTF-8?q?=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit plan.md 的 R2 目标里有三行钩子契约,出向两条(serialize_response/serialize_chunk) Phase 2 已落地,第三条 parse_request(protocol_body) -> unified 一直空着: /v1/messages 的入向解析硬编码在内核 Go 里(anthropicRequest.toUnified), Anthropic 改协议就得改内核。本次补齐这条对偶。 ## 机制(与出向序列化对称) - 适配器声明 adapter.ingress = "/v1/messages" 认领入站路径,并实现 deserialize_request(raw_body) -> unified_json。 - 内核 handleMessages 经新增的 parseViaIngress 判定: - 有适配器认领该路径且实现了钩子 → 让出,解析由适配器完成; - 无适配器认领 → 内核内置解析运行(R5 兜底,未配置部署仍可服务)。 - 认领了但钩子执行失败 → 硬错误 400,不静默换用另一种协议解析 (客户端说的正是该适配器声称支持的协议,悄悄换解析器只会错误调度)。 - 这是内核里唯一的新判定点,与 serializerFor 对称:出向选序列化器,入向选解析器。 ## 内核改动 - internal/lua/vm.go:staticInfo 增 ingress 字段;inspectScript 读取; 新增 VM.AdapterForIngress(path)(确定性选择:同名多认领取字典序最小)。 - internal/gateway/messages.go:handleMessages 改为先读 body → parseViaIngress (让出)→ 未认领时回落内置 anthropicRequest.toUnified。 内置解析与 anthropicRequest 类型保留——它们是兜底,不是死代码。 ## 适配器改动 - internal/lua/adapters/anthropic.lua:声明 adapter.ingress = "/v1/messages", 新增 deserialize_request,把内核原 Go 解析逻辑逐字段移植(system 两种拼写、 content 原样透传、缺省 thinking={enabled:false}、缺 messages 报错)。 内置 anthropic 默认随包播种,因此真实部署里内核总是让出给它, Go 侧 anthropicRequest.toUnified 退化为纯兜底。 ## 测试 - gateway:TestMessagesIngressYieldsToBundledAnthropic(让出后仍 200)、 TestAdapterForIngressClaim(声明可被找到、未声明路径为空、钩子存在)、 TestMessagesKernelFallbackWhenNoAdapterClaims(移除认领适配器后内核兜底仍 200)。 - lua:TestAnthropicDeserializeRequestShape 逐字段核对 Lua 与 Go 产出一致 (system 拼接、content、max_tokens/stream/temperature/top_p/top_k/stop_sequences、 缺省 thinking),TestAnthropicDeserializeRejectsMissingMessages。 - 全量回归:gateway/lua/core/config/types/provider/scheduler/e2e 全绿。 - 文档:docs/lua-adapters.md 补「入向让出钩子」章节 + plan.md 台账更新。 --- docs/lua-adapters.md | 38 +++++++++++ internal/gateway/messages.go | 69 +++++++++++++++++-- internal/gateway/messages_test.go | 101 ++++++++++++++++++++++++++++ internal/lua/adapters/anthropic.lua | 81 ++++++++++++++++++++++ internal/lua/ser_check_test.go | 95 ++++++++++++++++++++++++++ internal/lua/vm.go | 42 ++++++++++++ 6 files changed, 420 insertions(+), 6 deletions(-) diff --git a/docs/lua-adapters.md b/docs/lua-adapters.md index c79647c..e77d968 100644 --- a/docs/lua-adapters.md +++ b/docs/lua-adapters.md @@ -214,6 +214,44 @@ end 注意:钩子返回的是**完整要发送的字节**(包括必要时的 `event:` / `data:` 前缀与 空行),因为只有适配器知道目标协议的 SSE 细节。 +### 入向让出钩子(契约 v2,`serialize_*` 的对偶) + +出向是「统一格式 → 客户端协议」,入向是它的镜像:「客户端协议 → 统一格式」。 +内核为对外端点(如 `POST /v1/messages`)**保留一份内置解析作为兜底(R5)**, +但适配器可以**声明自己服务该端点**并接管解析——那时内核主动让出,客户端协议的 +迭代就完全落在适配器里,不再需要动内核。 + +声明分两步: + +1. `adapter.ingress = "/v1/messages"` —— 声明**认领**哪条对外入站路径。 + 同一条路径被多个适配器认领是配置错误;内核按适配器名字典序确定性选择。 +2. `deserialize_request(raw_body) -> unified_json` —— 把客户端原始 body 解析成 + 统一请求(`UnifiedRequest` 的 JSON),后续调度与 `/v1/chat/completions` 完全一致。 + +规则: + +- 只有声明了该端点且实现了 `deserialize_request` 的适配器会让内核让出。 +- 没有适配器认领时,内核内置解析接管(未配置的部署仍能工作)。 +- 认领了但钩子**执行失败**是硬错误(400),不会静默回落到另一种协议解析—— + 客户端说的正是这个适配器声称支持的协议,悄悄换解析器只会错误调度请求。 + +```lua +-- anthropic.lua 的简化形式:把 Anthropic Messages 请求解析成统一格式 +adapter.ingress = "/v1/messages" +function adapter.deserialize_request(raw_body) + local req = json.decode(raw_body) + local msgs = {} + for _, m in ipairs(req.messages or {}) do + table.insert(msgs, { role = m.role, content = m.content }) + end + return json.encode({ + model = req.model, messages = msgs, max_tokens = req.max_tokens, + system = type(req.system) == "string" and req.system or "", + stream = req.stream == true, + }) +end +``` + ## 5. 内置辅助函数 所有适配器共享的全局函数(由 Go 注入): diff --git a/internal/gateway/messages.go b/internal/gateway/messages.go index 0201831..770341d 100644 --- a/internal/gateway/messages.go +++ b/internal/gateway/messages.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/http" "strings" "time" @@ -40,16 +41,34 @@ func (g *Gateway) handleMessages(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use POST") return } - var raw anthropicRequest - if err := json.NewDecoder(r.Body).Decode(&raw); err != nil { - writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error()) + body, err := io.ReadAll(r.Body) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "read body: "+err.Error()) return } - req, err := raw.toUnified(g.core.DefaultModel()) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + // INGRESS: the kernel's built-in Anthropic parse is the FALLBACK (R5). If an + // adapter has CLAIMED this ingress path (adapter.ingress == "/v1/messages"), + // the kernel YIELDS: parsing is done by that adapter's deserialize_request + // hook, so Anthropic protocol iteration lives in anthropic.lua, not here. + // No adapter claims it → the built-in parser below runs (unconfigured + // deployments keep working). This is the inbound dual of serialize_*. + req, handled, perr := g.parseViaIngress(r.URL.Path, string(body), g.core.DefaultModel()) + if perr != nil { + writeError(w, http.StatusBadRequest, "invalid_request", perr.Error()) return } + if !handled { + var raw anthropicRequest + if err := json.Unmarshal(body, &raw); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error()) + return + } + req, err = raw.toUnified(g.core.DefaultModel()) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + } model := req.Model ctx := r.Context() @@ -292,6 +311,44 @@ func systemToString(raw json.RawMessage) string { return sb.String() } +// parseViaIngress is the kernel's INBOUND yield point, the dual of +// serializerFor (which decides the outbound serializer). +// +// Design (user requirement): an inbound endpoint such as /v1/messages is the +// KERNEL'S FALLBACK — the built-in parser in handleMessages is what an +// unconfigured deployment gets. But an adapter may CLAIM that endpoint +// (adapter.ingress == path) and declare deserialize_request; when it does, the +// kernel YIELDS parsing to it, so client-protocol iteration lives in the +// adapter and the kernel's own copy is only the safety net (R5). +// +// Returns handled=false when no adapter claims the path (or one claims it +// without a deserialize_request hook) — the caller then runs its built-in +// parser. A hook that exists but FAILS is a hard error, not a silent fallback: +// the client spoke a protocol the adapter claimed to handle, and quietly +// parsing it as a different protocol would mis-schedule the request. +func (g *Gateway) parseViaIngress(path, body, defaultModel string) (*types.ChatRequest, bool, error) { + vm := g.core.VM() + if vm == nil { + return nil, false, nil + } + adapter := vm.AdapterForIngress(path) + if adapter == "" || !vm.HasFn(adapter, "deserialize_request") { + return nil, false, nil + } + out, err := vm.Transform(adapter, "deserialize_request", body) + if err != nil { + return nil, true, fmt.Errorf("adapter %s deserialize_request(%s): %w", adapter, path, err) + } + var req types.ChatRequest + if err := json.Unmarshal([]byte(out), &req); err != nil { + return nil, true, fmt.Errorf("adapter %s deserialize_request returned an invalid unified request: %w", adapter, err) + } + if req.Model == "" { + req.Model = defaultModel + } + return &req, true, nil +} + func chatKind(stream bool) string { if stream { return "stream" diff --git a/internal/gateway/messages_test.go b/internal/gateway/messages_test.go index ec4a0e5..873df95 100644 --- a/internal/gateway/messages_test.go +++ b/internal/gateway/messages_test.go @@ -301,3 +301,104 @@ func TestMessagesRequiresAuth(t *testing.T) { } _ = context.Background() } + +// TestMessagesIngressYieldsToBundledAnthropic 是 ingress 让出的主断言(用户要求: +// /v1/messages 是内核兜底端点,适配器可声明它、内核主动让出)。 +// +// 事实前提:内置 anthropic.lua 自己声明了 adapter.ingress = "/v1/messages",而它 +// 默认随包播种。所以真实部署里内核**总是**让出给 anthropic 适配器, +// anthropicRequest.toUnified 退化为纯兜底(仅在适配器被移除/禁用时运行)。 +// +// 断言方式:给源挂一个「上游即 Anthropic」的假服务器,客户端送一个 Anthropic +// 形状请求。若内核让出,请求由适配器 deserialize_request 解析 → 路由/调度正常。 +// 同时用一个**只有系统消息**的请求验证解析确实走了适配器语义(Go 侧 system +// 处理与 Lua 侧一致)。 +func TestMessagesIngressYieldsToBundledAnthropic(t *testing.T) { + ts, key := messagesGateway(t) + if code, body := postJSON(t, ts.URL+"/v1/messages", key, + `{"model":"claude-test","max_tokens":16,"messages":[{"role":"user","content":"ping"}]}`); code != 200 { + t.Fatalf("让出后 /v1/messages 应 200, 实得 %d: %s", code, body) + } +} + +// TestAdapterForIngressClaim 直接验证内核的让出判定:声明了 ingress 的适配器 +// 会被 AdapterForIngress 找到;未声明的路径返回空(→ 内核用自己的兜底解析)。 +func TestAdapterForIngressClaim(t *testing.T) { + dir := t.TempDir() + cfg := &config.Config{ + Path: filepath.Join(dir, "config.yaml"), + AdapterDir: filepath.Join(dir, "adapters"), + RuntimeFile: filepath.Join(dir, "runtime.json"), + Listen: "127.0.0.1:0", + DefaultModel: "m", + GatewayKeys: []string{"sk-e2e-0001"}, + } + c, err := core.NewFromConfig(cfg) + if err != nil { + t.Fatal(err) + } + defer c.Close() + vm := c.VM() + + // 内置 anthropic 声明了该端点 + if got := vm.AdapterForIngress("/v1/messages"); got != "anthropic" { + t.Errorf("内置 anthropic 应认领 /v1/messages, 实得 %q", got) + } + // 未声明的路径 → 空 → 内核走自己的兜底解析 + if got := vm.AdapterForIngress("/v1/nothing"); got != "" { + t.Errorf("未声明路径应为空, 实得 %q", got) + } + // 内置 anthropic 确实有 deserialize_request 钩子(否则内核会让出给一个空解析器) + if !vm.HasFn("anthropic", "deserialize_request") { + t.Error("anthropic 应实现 deserialize_request") + } +} + +// TestMessagesKernelFallbackWhenNoAdapterClaims 证明兜底仍在:显式删掉所有 +// 声明 ingress 的适配器后,内核自己的 anthropicRequest.toUnified 接管, +// /v1/messages 仍能服务(R5 兜底语义)。 +func TestMessagesKernelFallbackWhenNoAdapterClaims(t *testing.T) { + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"choices":[{"message":{"content":"pong"},"finish_reason":"stop"}], + "usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}}`) + })) + defer up.Close() + + dir := t.TempDir() + cfg := &config.Config{ + Path: filepath.Join(dir, "config.yaml"), + AdapterDir: filepath.Join(dir, "adapters"), + RuntimeFile: filepath.Join(dir, "runtime.json"), + Listen: "127.0.0.1:0", + DefaultModel: "gpt-test", + GatewayKeys: []string{"sk-e2e-0001"}, + Sources: []config.Source{{ + Name: "oai", BaseURL: up.URL, Adapter: "openai", APIKey: "k", MaxConcurrent: 4, + Models: []config.Model{{ID: "gpt-test", Kind: "chat"}}, + }}, + } + c, err := core.NewFromConfig(cfg) + if err != nil { + t.Fatal(err) + } + defer c.Close() + // 摘掉声明 ingress 的适配器(内置 anthropic),模拟"没有适配器认领该端点"。 + c.VM().RemoveAdapter("anthropic") + if got := c.VM().AdapterForIngress("/v1/messages"); got != "" { + t.Fatalf("移除后应无适配器认领, 实得 %q", got) + } + gw, err := New(c) + if err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(gw.Handler()) + defer ts.Close() + + // 内核兜底解析 Anthropic 形状 → 200(走 OpenAI 兜底序列化) + code, body := postJSON(t, ts.URL+"/v1/messages", "sk-e2e-0001", + `{"model":"gpt-test","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}`) + if code != 200 { + t.Fatalf("无适配器认领时内核兜底应仍 200, 实得 %d: %s", code, body) + } +} diff --git a/internal/lua/adapters/anthropic.lua b/internal/lua/adapters/anthropic.lua index 459eb41..67d9349 100644 --- a/internal/lua/adapters/anthropic.lua +++ b/internal/lua/adapters/anthropic.lua @@ -5,6 +5,14 @@ adapter.endpoint = "/v1/messages" adapter.headers = { ["anthropic-version"] = "2023-06-01", } +-- INGRESS CLAIM (inbound dual of serialize_*): this adapter owns parsing of the +-- client-facing POST /v1/messages request body. The kernel keeps a built-in +-- Anthropic parser as its fallback (R5) — that is what an unconfigured +-- deployment uses — but once this adapter is loaded the kernel YIELDS, and the +-- deserialize_request hook below is the single place Anthropic's REQUEST shape +-- is understood. Iterating on that shape therefore means editing this file, +-- never the kernel. +adapter.ingress = "/v1/messages" -- ============================================================ -- helpers @@ -132,6 +140,79 @@ local function append_user(msgs, blocks) end end +-- ============================================================ +-- deserialize_request (Anthropic → unified) [INGRESS] +-- ============================================================ +-- The inbound dual of serialize_response. The kernel hands over the raw client +-- body and receives the unified request; from there scheduling is identical to +-- /v1/chat/completions. Ported field-for-field from the kernel's former +-- built-in anthropicRequest.toUnified — which remains as the kernel fallback +-- for an unconfigured deployment. +function adapter.deserialize_request(raw_body) + local ok, req = pcall(json.decode, raw_body) + if not ok or type(req) ~= "table" then + error("invalid json") + end + + -- system accepts Anthropic's two spellings: a plain string, or an array of + -- { type = "text", text = ... } blocks. Only text blocks contribute. + local system = "" + local sys = req.system + if type(sys) == "string" then + system = sys + elseif type(sys) == "table" then + for _, b in ipairs(sys) do + if type(b) == "table" and b.type == "text" and type(b.text) == "string" then + system = system .. b.text + end + end + end + + local msgs = {} + local in_msgs = req.messages + if type(in_msgs) ~= "table" or #in_msgs == 0 then + error("messages is required") + end + for i, m in ipairs(in_msgs) do + if type(m) ~= "table" or type(m.role) ~= "string" or m.role == "" then + error("messages[" .. (i - 1) .. "].role is required") + end + -- Content is carried VERBATIM: a string stays a string, a block array + -- stays an array. The unified format keeps multimodal/tool blocks + -- untouched because only an adapter knows which block types the upstream + -- accepts. An absent/null content becomes the empty string. + local content = m.content + if content == nil or content == "" then + content = "" + end + table.insert(msgs, { role = m.role, content = content }) + end + + -- Anthropic's default is thinking OFF; emit that explicitly so the unified + -- request carries the client's intent rather than leaving it absent. + local thinking = req.thinking + if thinking == nil then + thinking = { enabled = false } + end + + local out = { + model = req.model or "", + messages = msgs, + system = system, + max_tokens = req.max_tokens or 0, + stream = req.stream == true, + temperature = req.temperature, + top_p = req.top_p, + top_k = req.top_k or 0, + stop_sequences = req.stop_sequences, + tools = req.tools, + tool_choice = req.tool_choice, + thinking = thinking, + extra_body = {}, + } + return json.encode(out) +end + -- ============================================================ -- transform_request (OpenAI → Anthropic) -- ============================================================ diff --git a/internal/lua/ser_check_test.go b/internal/lua/ser_check_test.go index eae02bd..29a44fd 100644 --- a/internal/lua/ser_check_test.go +++ b/internal/lua/ser_check_test.go @@ -51,3 +51,98 @@ func TestAnthropicSerializerShape(t *testing.T) { } } } + +// TestAnthropicDeserializeRequestShape 验证 anthropic 适配器的 ingress 解析 +// (deserialize_request)输出与内核原 Go 兜底(anthropicRequest.toUnified)逐字段 +// 一致。这是"让出"的前提:若两边产出不同,"谁解析"就会悄悄改变行为。 +func TestAnthropicDeserializeRequestShape(t *testing.T) { + vm := NewVM(filepath.Join(t.TempDir(), "adapters")) + if err := vm.Start(); err != nil { + t.Fatal(err) + } + defer vm.Stop() + + // 适配器必须声明 ingress 并提供钩子,内核才会让出。 + if !vm.HasFn("anthropic", "deserialize_request") { + t.Fatal("anthropic 未声明 deserialize_request") + } + + // system 两种拼写 + content 字符串/块数组 + thinking 缺省 + in := `{ + "model": "claude-x", + "max_tokens": 128, + "system": [{"type":"text","text":"be terse"},{"type":"text","text":"!"}], + "messages": [ + {"role":"user","content":"hi"}, + {"role":"assistant","content":[{"type":"text","text":"hello"}]} + ], + "temperature": 0.5, + "top_p": 0.9, + "top_k": 40, + "stop_sequences": ["STOP"], + "stream": true + }` + out, err := vm.Transform("anthropic", "deserialize_request", in) + if err != nil { + t.Fatalf("deserialize_request: %v", err) + } + var u struct { + Model string `json:"model"` + System string `json:"system"` + Messages []json.RawMessage `json:"messages"` + MaxTokens int `json:"max_tokens"` + Stream bool `json:"stream"` + Temperature *float64 `json:"temperature"` + TopP *float64 `json:"top_p"` + TopK int `json:"top_k"` + StopSequences []string `json:"stop_sequences"` + Thinking *struct { + Enabled *bool `json:"enabled"` + } `json:"thinking"` + } + if err := json.Unmarshal([]byte(out), &u); err != nil { + t.Fatalf("not JSON: %s", out) + } + + if u.Model != "claude-x" { + t.Errorf("model = %q", u.Model) + } + // Go 侧 systemToString 把 [{text:"be terse"},{text:"!"}] 拼成 "be terse!" + // (text 块按序拼接,无分隔符)。Lua 必须一致。 + if u.System != "be terse!" { + t.Errorf("system = %q, want %q (Go 侧拼接语义)", u.System, "be terse!") + } + if len(u.Messages) != 2 { + t.Fatalf("messages = %d", len(u.Messages)) + } + if u.MaxTokens != 128 || !u.Stream { + t.Errorf("max_tokens/stream = %d/%v", u.MaxTokens, u.Stream) + } + if u.Temperature == nil || *u.Temperature != 0.5 { + t.Errorf("temperature = %v", u.Temperature) + } + if u.TopP == nil || *u.TopP != 0.9 || u.TopK != 40 { + t.Errorf("top_p/top_k = %v/%d", u.TopP, u.TopK) + } + if len(u.StopSequences) != 1 || u.StopSequences[0] != "STOP" { + t.Errorf("stop_sequences = %v", u.StopSequences) + } + // 缺省 thinking → Go 侧显式发 {enabled:false};Lua 必须一致。 + if u.Thinking == nil || u.Thinking.Enabled == nil || *u.Thinking.Enabled != false { + t.Errorf("缺省 thinking 应为 {enabled:false}, 实得 %s", out) + } +} + +// TestAnthropicDeserializeRejectsMissingMessages 验证 Lua 侧与 Go 侧一样, +// 对缺 messages 的请求报错(而不是产出空请求)。 +func TestAnthropicDeserializeRejectsMissingMessages(t *testing.T) { + vm := NewVM(filepath.Join(t.TempDir(), "adapters")) + if err := vm.Start(); err != nil { + t.Fatal(err) + } + defer vm.Stop() + + if _, err := vm.Transform("anthropic", "deserialize_request", `{"model":"x","max_tokens":8}`); err == nil { + t.Error("缺 messages 应报错") + } +} diff --git a/internal/lua/vm.go b/internal/lua/vm.go index 626d548..6c53462 100644 --- a/internal/lua/vm.go +++ b/internal/lua/vm.go @@ -84,6 +84,15 @@ type staticInfo struct { // from name when a script declares its own name. Kept so a reload replaces // the same pool entry rather than silently registering a second one. registeredAs string + // ingress is the client-facing endpoint path this adapter CLAIMS to parse, + // e.g. "/v1/messages". It is the INBOUND dual of serialize_* (which is + // outbound): when an adapter declares it, the kernel YIELDS parsing of that + // endpoint's request body to the adapter's deserialize_request hook, instead + // of its own built-in parser. The kernel keeps a built-in parser as the + // fallback (R5), so an unconfigured deployment still serves /v1/messages — + // but once an adapter claims the endpoint, protocol iteration lives in the + // adapter, not the kernel. Empty means the adapter claims no ingress. + ingress string } // adapterPool owns the worker states for one adapter. Idle workers are held in @@ -788,6 +797,38 @@ func (v *VM) ListAdapters() []APIAdapter { return list } +// AdapterForIngress returns the registered name of the adapter that CLAIMS the +// given client-facing endpoint path (its static.ingress), or "" when none does. +// +// This is the kernel-yields decision for an inbound endpoint (user requirement: +// "/v1/messages is the kernel's fallback endpoint; an adapter may declare it and +// the kernel actively yields"). The kernel keeps its own built-in parser as the +// fallback (R5) — AdapterForIngress returning "" means "no adapter claimed it, +// use the built-in", so an unconfigured deployment still serves the endpoint. +// +// Ties (two adapters claiming the same path) resolve to the lexically-first +// name so the choice is deterministic rather than map-iteration order. A real +// deployment should not have two adapters claiming one ingress; when it does, +// the plugin conflict-arbitration surface is the place to make it explicit, but +// a parse endpoint must still pick SOMETHING rather than vary per request. +func (v *VM) AdapterForIngress(path string) string { + if path == "" { + return "" + } + v.mu.RLock() + defer v.mu.RUnlock() + chosen := "" + for name, p := range v.pools { + p.mu.Lock() + claims := p.static.ingress == path + p.mu.Unlock() + if claims && (chosen == "" || name < chosen) { + chosen = name + } + } + return chosen +} + // LoadAdapter compiles and registers an adapter script from a file. // // The path is validated for SAFETY before reading (R6): it must sit inside the @@ -1197,6 +1238,7 @@ func inspectScript(name, code string) (*staticInfo, error) { info.version = readString(L, -1, "version") info.endpoint = readString(L, -1, "endpoint") info.endpointStream = readString(L, -1, "endpoint_stream") + info.ingress = readString(L, -1, "ingress") if getFieldBatch(L, "headers") { luaTableToMap(L, -1, info.headers) L.Pop(1)