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