From 1c6a2821e4f627e2d45e0677b39d0eb55e925720 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 11 Oct 2026 08:30:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(provider):=20=E7=AB=AF=E7=82=B9=E6=9C=BA?= =?UTF-8?q?=E5=88=B6=E5=8E=BB=E5=8E=82=E5=95=86=E5=8C=96=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E9=80=82=E9=85=8D=E5=99=A8=E5=A3=B0=E6=98=8E=20end?= =?UTF-8?q?point=5Fstream?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 内核不再知道 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 字节级未漂移。 --- internal/config/config.go | 54 +++++++++++---------- internal/lua/adapters/gemini.lua | 4 ++ internal/lua/vm.go | 30 +++++++++++- internal/provider/gemini_endpoint_test.go | 58 +++++++++++++++++++++-- internal/provider/provider.go | 38 ++++++++++----- 5 files changed, 145 insertions(+), 39 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index c173229..d80a201 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 diff --git a/internal/lua/adapters/gemini.lua b/internal/lua/adapters/gemini.lua index d17ff14..3475851 100644 --- a/internal/lua/adapters/gemini.lua +++ b/internal/lua/adapters/gemini.lua @@ -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 diff --git a/internal/lua/vm.go b/internal/lua/vm.go index 9ad0e45..d8c9ac4 100644 --- a/internal/lua/vm.go +++ b/internal/lua/vm.go @@ -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) diff --git a/internal/provider/gemini_endpoint_test.go b/internal/provider/gemini_endpoint_test.go index 7267efc..2eb78ae 100644 --- a/internal/provider/gemini_endpoint_test.go +++ b/internal/provider/gemini_endpoint_test.go @@ -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. diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 11bd00a..ac0fc1a 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -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 }