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

@ -228,12 +228,64 @@ func TestSourceEndpointOverridesTemplate(t *testing.T) {
if got, want := p.ChatURL("m1", false), "https://example.test/custom/path"; got != want {
t.Errorf("URL = %q, want %q (source endpoint must override the adapter template)", got, want)
}
// And the streaming verb rewrite must not damage an unrelated path.
if got := p.ChatURL("m1", true); strings.Contains(got, "streamGenerate") {
t.Errorf("streaming rewrite leaked into a user-spelled path: %q", got)
// And a user-spelled path must survive streaming untouched: the operator
// overrode the template, so the adapter's stream endpoint is not re-applied.
if got := p.ChatURL("m1", true); got != "https://example.test/custom/path" {
t.Errorf("URL = %q, want the user path unchanged for streaming too", got)
}
}
// TestEndpointPrecedence pins the four-way precedence the endpoint_stream
// mechanism depends on. It exists because the first implementation of
// endpoint_stream DID regress the case above (adapter stream endpoint leaked
// over a user-spelled path) — the precedence is the contract, not an
// implementation detail.
func TestEndpointPrecedence(t *testing.T) {
vm := geminiVM(t)
defer vm.Stop()
base := config.Source{
Name: "s", BaseURL: "https://example.test", Adapter: "gemini",
APIKey: "k", MaxConcurrent: 2,
Models: []config.Model{{ID: "m1", Kind: "chat"}},
}
t.Run("adapter stream endpoint used when source spells nothing", func(t *testing.T) {
s := base
p := New(s, vm)
if got := p.ChatURL("m1", true); !strings.Contains(got, ":streamGenerateContent") {
t.Errorf("streaming URL = %q, want the adapter's endpoint_stream", got)
}
if got := p.ChatURL("m1", false); !strings.Contains(got, ":generateContent") {
t.Errorf("non-streaming URL = %q, want the adapter's endpoint", got)
}
})
t.Run("source endpoint wins over adapter stream endpoint", func(t *testing.T) {
s := base
s.Endpoint = "/custom/path"
p := New(s, vm)
for _, stream := range []bool{false, true} {
if got := p.ChatURL("m1", stream); got != "https://example.test/custom/path" {
t.Errorf("stream=%v URL = %q, want the source path", stream, got)
}
}
})
t.Run("source stream endpoint wins over everything", func(t *testing.T) {
s := base
s.Endpoint = "/custom/path"
s.StreamEndpoint = "/custom/stream"
p := New(s, vm)
if got := p.ChatURL("m1", false); got != "https://example.test/custom/path" {
t.Errorf("non-stream URL = %q", got)
}
if got := p.ChatURL("m1", true); got != "https://example.test/custom/stream" {
t.Errorf("stream URL = %q, want the source's explicit stream path", got)
}
})
}
// TestGeminiResponseStillParses: the endpoint fix must not disturb the body
// transform — a request that reaches the right URL still has to come back as a
// unified response with Gemini's usageMetadata mapped.

View File

@ -576,33 +576,49 @@ func (p *Provider) ImageEndpoint() string {
// modelPlaceholder is the marker an adapter puts in its endpoint template when
// the upstream carries the model id in the PATH rather than in the body.
// Gemini is the only such adapter: POST /v1beta/models/{model}:generateContent.
// Every other adapter's endpoint is a fixed path, so substituting is a no-op
// for them.
const modelPlaceholder = "{model}"
// ChatURL resolves the full chat URL for a specific model.
//
// Two substitutions happen here, both driven by the adapter's endpoint template:
// Two substitutions happen here, both driven by endpoint declarations:
//
// - "{model}" is replaced by the model id actually being sent. Without this
// the gateway would POST to Gemini's model-LIST endpoint, which answers 405.
// - for a streaming request, the ":generateContent" verb becomes
// ":streamGenerateContent". Gemini streams over the same path with a
// different verb, and the verb is part of the path, so the two cannot both
// be static. The rewrite is deliberately narrow: it only fires on the exact
// ":generateContent" suffix, so an adapter whose endpoint merely mentions
// the word keeps its path untouched.
// - a streaming request may use a DIFFERENT endpoint. This is deliberately
// not a vendor rule: the kernel does not know that one vendor's verb
// changes, only that an endpoint declaration said "use this other path
// when streaming".
//
// Precedence (a source that spells its own path takes full responsibility for
// it — the old behaviour, which must not regress):
//
// source.endpoint_stream > source.endpoint > adapter.endpoint_stream > adapter.endpoint
//
// Note the third slot only applies when the source did NOT spell its own
// chat endpoint. A source with `endpoint: /custom/path` keeps that path for
// streaming too, because the operator overrode the adapter's template and the
// kernel has no business re-adding the adapter's stream path on top.
func (p *Provider) ChatURL(model string, stream bool) string {
ep := p.Endpoint()
if stream {
switch {
case p.cfg.StreamEndpoint != "":
ep = p.cfg.StreamEndpoint
case p.cfg.Endpoint == "":
// Source did not override the chat path, so the adapter's streaming
// endpoint (if any) is authoritative.
if se := p.vm.StreamEndpoint(p.adapter); se != "" {
ep = se
}
}
}
if strings.Contains(ep, modelPlaceholder) {
// A model id is put in a URL path, so it must be escaped: an id with a
// slash would otherwise silently address a different resource.
ep = strings.ReplaceAll(ep, modelPlaceholder, url.PathEscape(model))
}
if stream {
ep = strings.Replace(ep, ":generateContent", ":streamGenerateContent", 1)
}
return strings.TrimRight(p.cfg.BaseURL, "/") + ep
}