mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-15 16:58:28 +08: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:
@ -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