feat(provider): 端点机制去厂商化 —— 适配器声明 endpoint_stream

内核不再知道 Gemini 的流式动词。之前 provider.ChatURL 对
":generateContent" 做字符串替换(注释自认 "Gemini is the only such
adapter"),把厂商协议知识放进了调度器。

现在适配器自己声明:

    adapter.endpoint_stream = "/v1beta/models/{model}:streamGenerateContent"

内核只比较两个不透明字符串,四级优先级:

    source.endpoint_stream > source.endpoint > adapter.endpoint_stream
                                          > adapter.endpoint

- staticInfo.endpointStream + VM.StreamEndpoint() 编译期提取
- config.Source.StreamEndpoint(yaml: endpoint_stream)
- gemini.lua 声明 endpoint_stream
- 内核代码 generateContent 计数归零(仅注释/适配器持有)

首版实现踩了一个回归并当场修掉:源自定义 endpoint 时适配器的 stream
endpoint 会错误覆盖用户的显式路径。TestEndpointPrecedence 把四级优先级
钉死,防止再犯。

验证:go test -tags luajit ./... 全绿;golden 字节级未漂移。
This commit is contained in:
JianFeeeee
2026-10-11 08:30:39 +08:00
parent 3ad46ea23e
commit 1c6a2821e4
5 changed files with 145 additions and 39 deletions

View File

@ -18,6 +18,10 @@ adapter.version = "2.0.0"
-- can never work. A source that overrides `endpoint:` bypasses the template
-- entirely and must then spell the whole path itself.
adapter.endpoint = "/v1beta/models/{model}:generateContent"
-- 流式走同一路径的不同动词。这个事实以前写在 Go 内核里
-- (provider.ChatURL 对 ":generateContent" 做字符串替换),属于把厂商协议
-- 知识放进调度器;现在由适配器自己声明,内核只比较两个不透明字符串。
adapter.endpoint_stream = "/v1beta/models/{model}:streamGenerateContent"
adapter.headers = {}
-- Gemini API: POST /v1/models/{model}:generateContent

View File

@ -67,7 +67,18 @@ type staticInfo struct {
name string
version string
endpoint string
headers map[string]string
// endpointStream is the streaming counterpart of endpoint. It exists so the
// KERNEL never has to know a vendor's streaming path: Gemini streams over the
// same path with a different verb (:generateContent -> :streamGenerateContent),
// and the verb is part of the path, so the two cannot both be static.
//
// Before this field the kernel carried `strings.Replace(ep,
// ":generateContent", ":streamGenerateContent", 1)` — vendor protocol
// knowledge inside the dispatcher. It now lives here, where it belongs:
// the adapter declares both endpoints and the kernel just picks one.
// An adapter that omits endpoint_stream uses endpoint for both (most do).
endpointStream string
headers map[string]string
}
// adapterPool owns the worker states for one adapter. Idle workers are held in
@ -923,6 +934,22 @@ func (v *VM) Endpoint(name string) string {
return p.static.endpoint
}
// StreamEndpoint returns the adapter's streaming endpoint, or "" when it
// declares none (in which case the caller keeps using Endpoint).
//
// This is the whole mechanism that keeps vendor streaming verbs out of the
// kernel: an adapter whose stream path differs declares endpoint_stream, and
// the dispatcher only ever compares two opaque strings.
func (v *VM) StreamEndpoint(name string) string {
p := v.pool(name)
if p == nil {
return ""
}
p.mu.Lock()
defer p.mu.Unlock()
return p.static.endpointStream
}
// ---- static inspection (compile-once at load time) ----
func inspectScript(name, code string) (*staticInfo, error) {
@ -943,6 +970,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")
if getFieldBatch(L, "headers") {
luaTableToMap(L, -1, info.headers)
L.Pop(1)