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

@ -73,15 +73,20 @@ type Model struct {
// Source describes a single upstream LLM provider.
type Source struct {
Name string `yaml:"name" json:"name"`
BaseURL string `yaml:"base_url" json:"base_url"`
APIKey string `yaml:"api_key" json:"api_key"`
APIKeyEnv string `yaml:"api_key_env,omitempty" json:"-"` // reference to an env var holding the key (overrides api_key)
Adapter string `yaml:"adapter" json:"adapter"`
Endpoint string `yaml:"endpoint" json:"endpoint,omitempty"` // chat endpoint override
ImageEndpoint string `yaml:"image_endpoint" json:"image_endpoint,omitempty"` // image endpoint override
Models []Model `yaml:"models" json:"models"`
Headers map[string]string `yaml:"headers" json:"headers,omitempty"`
Name string `yaml:"name" json:"name"`
BaseURL string `yaml:"base_url" json:"base_url"`
APIKey string `yaml:"api_key" json:"api_key"`
APIKeyEnv string `yaml:"api_key_env,omitempty" json:"-"` // reference to an env var holding the key (overrides api_key)
Adapter string `yaml:"adapter" json:"adapter"`
Endpoint string `yaml:"endpoint" json:"endpoint,omitempty"` // chat endpoint override
// StreamEndpoint overrides the CHAT endpoint for streaming requests. Most
// upstreams stream on the same path; Gemini is the exception (same path,
// different verb). Declaring it here keeps that knowledge out of the kernel,
// which only compares opaque strings.
StreamEndpoint string `yaml:"endpoint_stream" json:"endpoint_stream,omitempty"`
ImageEndpoint string `yaml:"image_endpoint" json:"image_endpoint,omitempty"` // image endpoint override
Models []Model `yaml:"models" json:"models"`
Headers map[string]string `yaml:"headers" json:"headers,omitempty"`
// ProxyURL routes this source's HTTP(S) traffic through an explicit proxy
// (e.g. http://127.0.0.1:7890). Empty = direct connection. Needed for
// upstreams behind geo/IP blocks that only respond through a proxy.
@ -373,13 +378,14 @@ type RuntimeConfig struct {
// name and api_key) so the WebUI can spin up multiple key-bearing sources
// from one shared template.
type SourceTemplate struct {
Name string `json:"name"`
BaseURL string `json:"base_url"`
Adapter string `json:"adapter"`
Endpoint string `json:"endpoint,omitempty"`
ImageEndpoint string `json:"image_endpoint,omitempty"`
Models []Model `json:"models"`
Headers map[string]string `json:"headers,omitempty"`
Name string `json:"name"`
BaseURL string `json:"base_url"`
Adapter string `json:"adapter"`
Endpoint string `json:"endpoint,omitempty"`
StreamEndpoint string `json:"endpoint_stream,omitempty"`
ImageEndpoint string `json:"image_endpoint,omitempty"`
Models []Model `json:"models"`
Headers map[string]string `json:"headers,omitempty"`
// ProxyURL routes this source's HTTP(S) traffic through an explicit proxy.
ProxyURL string `json:"proxy_url,omitempty"`
Meta map[string]interface{} `json:"meta,omitempty"`
@ -393,18 +399,18 @@ type SourceTemplate struct {
// (full management) or "user" (sees only its own key); Models is the allowed
// model scope with per-model token quota (0 = unlimited).
type GWKey struct {
Key string `yaml:"key" json:"key"`
Role string `yaml:"role" json:"role"`
Name string `yaml:"name,omitempty" json:"name,omitempty"`
Key string `yaml:"key" json:"key"`
Role string `yaml:"role" json:"role"`
Name string `yaml:"name,omitempty" json:"name,omitempty"`
// Auto is this key's own AUTO scheduling chain. It overrides the global
// cfg.Auto for requests authenticated with this key; nil (or an empty
// list) means "inherit the global chain", so keys created before per-key
// chains existed keep working untouched.
Auto []ModelScope `yaml:"auto,omitempty" json:"auto,omitempty"`
Models []ModelScope `yaml:"models,omitempty" json:"models,omitempty"`
Note string `yaml:"note,omitempty" json:"note,omitempty"`
CreatedAt int64 `yaml:"created_at,omitempty" json:"created_at,omitempty"`
Seed bool `yaml:"seed,omitempty" json:"seed,omitempty"` // true if migrated from config gateway_keys
Auto []ModelScope `yaml:"auto,omitempty" json:"auto,omitempty"`
Models []ModelScope `yaml:"models,omitempty" json:"models,omitempty"`
Note string `yaml:"note,omitempty" json:"note,omitempty"`
CreatedAt int64 `yaml:"created_at,omitempty" json:"created_at,omitempty"`
Seed bool `yaml:"seed,omitempty" json:"seed,omitempty"` // true if migrated from config gateway_keys
}
// HasOwnAuto reports whether the key declares its own AUTO chain rather than

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)

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
}