mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
feat(adapters): 入向端点移交适配器 —— adapter.ingress + deserialize_request,内核主动让出
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 台账更新。
This commit is contained in:
@ -214,6 +214,44 @@ end
|
|||||||
注意:钩子返回的是**完整要发送的字节**(包括必要时的 `event:` / `data:` 前缀与
|
注意:钩子返回的是**完整要发送的字节**(包括必要时的 `event:` / `data:` 前缀与
|
||||||
空行),因为只有适配器知道目标协议的 SSE 细节。
|
空行),因为只有适配器知道目标协议的 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. 内置辅助函数
|
## 5. 内置辅助函数
|
||||||
|
|
||||||
所有适配器共享的全局函数(由 Go 注入):
|
所有适配器共享的全局函数(由 Go 注入):
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -40,16 +41,34 @@ func (g *Gateway) handleMessages(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use POST")
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use POST")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var raw anthropicRequest
|
body, err := io.ReadAll(r.Body)
|
||||||
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
writeError(w, http.StatusBadRequest, "invalid_request", "read body: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
req, err := raw.toUnified(g.core.DefaultModel())
|
// INGRESS: the kernel's built-in Anthropic parse is the FALLBACK (R5). If an
|
||||||
if err != nil {
|
// adapter has CLAIMED this ingress path (adapter.ingress == "/v1/messages"),
|
||||||
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
// 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
|
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
|
model := req.Model
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
@ -292,6 +311,44 @@ func systemToString(raw json.RawMessage) string {
|
|||||||
return sb.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 {
|
func chatKind(stream bool) string {
|
||||||
if stream {
|
if stream {
|
||||||
return "stream"
|
return "stream"
|
||||||
|
|||||||
@ -301,3 +301,104 @@ func TestMessagesRequiresAuth(t *testing.T) {
|
|||||||
}
|
}
|
||||||
_ = context.Background()
|
_ = 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -5,6 +5,14 @@ adapter.endpoint = "/v1/messages"
|
|||||||
adapter.headers = {
|
adapter.headers = {
|
||||||
["anthropic-version"] = "2023-06-01",
|
["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
|
-- helpers
|
||||||
@ -132,6 +140,79 @@ local function append_user(msgs, blocks)
|
|||||||
end
|
end
|
||||||
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)
|
-- transform_request (OpenAI → Anthropic)
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|||||||
@ -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 应报错")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -84,6 +84,15 @@ type staticInfo struct {
|
|||||||
// from name when a script declares its own name. Kept so a reload replaces
|
// 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.
|
// the same pool entry rather than silently registering a second one.
|
||||||
registeredAs string
|
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
|
// adapterPool owns the worker states for one adapter. Idle workers are held in
|
||||||
@ -788,6 +797,38 @@ func (v *VM) ListAdapters() []APIAdapter {
|
|||||||
return list
|
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.
|
// LoadAdapter compiles and registers an adapter script from a file.
|
||||||
//
|
//
|
||||||
// The path is validated for SAFETY before reading (R6): it must sit inside the
|
// 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.version = readString(L, -1, "version")
|
||||||
info.endpoint = readString(L, -1, "endpoint")
|
info.endpoint = readString(L, -1, "endpoint")
|
||||||
info.endpointStream = readString(L, -1, "endpoint_stream")
|
info.endpointStream = readString(L, -1, "endpoint_stream")
|
||||||
|
info.ingress = readString(L, -1, "ingress")
|
||||||
if getFieldBatch(L, "headers") {
|
if getFieldBatch(L, "headers") {
|
||||||
luaTableToMap(L, -1, info.headers)
|
luaTableToMap(L, -1, info.headers)
|
||||||
L.Pop(1)
|
L.Pop(1)
|
||||||
|
|||||||
Reference in New Issue
Block a user