mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-12 04:07:25 +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:
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user