feat(model): 按 provider 形状独立适配推理开关 + per-persona 提供商链真接线(裁决 03.4.2/08.4.1/03.4.1 余项)
- 新增 reasoning_shape.go:**每个 provider 形状一个 resolver,只发自己形状的字段**
· deepseek:thinking{type} + reasoning_effort(依据 DevDocs/reference/deepseek 官方本地副本)
· vllm:chat_template_kwargs.enable_thinking · dashscope:顶层 enable_thinking(**只允许显式声明**)
· openai:仅 reasoning_effort · anthropic:自己的 thinking{budget_tokens}(未改)
· 未知 kind → WARN 且一个字段都不发(不许静默失效)
- 形状载体:models.json 组级 `reasoning.kind`(可热更新,未声明则不写该键)+ env `LLM_REASONING_KIND`
- 红线:`effort:none` 在只有布尔开关的形状上翻成**显式关思考**,绝不降成任何强度档
- 03.4.1 余项:`Request.PersonaID` + `RuntimeLLM.ForPersona(pid)` 视图;
每 persona 协议一条链并消费 `RouteProviderForPersona`;`agent/request.go` 非流式路径同一钩子
- **生产装配(主会话补)**:默认 agent 与 personaAgentPool 都改用 `ForPersona(pid)` 视图——
否则角色级模型/提供商路由在生产里永不被选中(形同虚设)
- 复核:build/vet 绿 + model/agent/config/cmd 包测试绿;5 条变异反证先红后还原
(其中一条「红但没钉住」已改用例:通道读取加 2s 超时,避免整包 panic 掩盖信号)
- 未落:DashScope 的 auto 推断(缺官方依据,只允许显式声明);Qwen `/think` 前导消息
(design/01 §2.1 与 design/11 §3 口径冲突,停手报告)
This commit is contained in:
1 parent
71fefe5fc3
commit
8890c30bb5
17 files changed
+1496
-134
No files matched your search
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
)
|
||||
|
||||
// Anthropic Messages (/v1/messages) wire protocol constants.
|
||||
@@ -163,6 +164,13 @@ func NewAnthropicChat(cfg config.LLMConfig, opts ...AnthropicOption) *AnthropicC
|
||||
},
|
||||
log: discardLogger("runtime/model/anthropic"),
|
||||
}
|
||||
if kind := strings.TrimSpace(cfg.ReasoningKind); kind != "" && kind != runtimeconfig.ReasoningKindAuto {
|
||||
// The anthropic protocol defines its own thinking shape, so an
|
||||
// openai-shape declaration is inert here. Report it rather than letting a
|
||||
// configured option be silently ineffective (裁决 03.4.2: 禁止静默无效).
|
||||
a.log.Warn("reasoning.kind ignored: the anthropic protocol defines its own thinking shape",
|
||||
"provider", ProviderAnthropic, "field", "reasoning.kind", "kind", kind)
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(a)
|
||||
}
|
||||
@@ -311,7 +319,7 @@ func (a *AnthropicChatAdapter) buildRequest(req Request, stream bool) ([]byte, e
|
||||
// provider actually applies. Strength goes through output_config.effort
|
||||
// instead (design/04 §3.2 fact 6).
|
||||
thinking, output = anthropicDeepSeekReasoning(a.log, req.Model, req.Thinking, req.ReasoningEffort)
|
||||
reportForcedToolChoiceWithThinking(a.log, req.Model, req.ToolChoice, anthropicThinkingOn(thinking))
|
||||
reportForcedToolChoiceWithThinking(a.log, string(vendorDeepSeek), req.Model, req.ToolChoice, anthropicThinkingOn(thinking))
|
||||
} else {
|
||||
// Extended thinking shares max_tokens with the visible answer, so on the
|
||||
// Anthropic endpoint the budget is resolved together with the limit it
|
||||
|
||||
@@ -172,6 +172,21 @@ type Request struct {
|
||||
MaxTokens int
|
||||
// Purpose labels the call for CallRecord accounting (e.g. "chat").
|
||||
Purpose string
|
||||
// PersonaID is the persona this turn answers as — the persona-level routing
|
||||
// key of 裁决 03.4.1, as opposed to the LLM message role. RuntimeLLM reads it
|
||||
// to pick the persona's provider chain (models.json personaProviders, i.e. a
|
||||
// cross-protocol per-persona route). Empty means "no persona binding": the
|
||||
// active group's default chain serves the request, so every pre-existing
|
||||
// caller behaves exactly as before.
|
||||
//
|
||||
// Why a request field and not a context key: the decision happens at the
|
||||
// adapter boundary that already carries the Request, and the value is
|
||||
// per-call metadata of the same kind as Model/Purpose. The persona lives in
|
||||
// the loop's private session-env context key, which the model package cannot
|
||||
// read (model must not import loop), so a ctx carrier would have needed the
|
||||
// same write anyway — with the decision hidden behind an invisible key and
|
||||
// hard to assert across packages.
|
||||
PersonaID string
|
||||
// Tools is the interface-level placeholder for M2 function calling.
|
||||
Tools []ToolSpec
|
||||
// ToolChoice optionally directs how the provider selects a tool. It is an
|
||||
|
||||
@@ -40,6 +40,11 @@ type OpenAIChatAdapter struct {
|
||||
model string
|
||||
client *http.Client
|
||||
recorder Recorder
|
||||
// reasoningKind is this endpoint's configured reasoning-switch shape
|
||||
// (models.json group reasoning.kind / LLM_REASONING_KIND, 裁决 03.4.2/08.4.1).
|
||||
// Empty means "auto": infer the vendor from the model name/base URL and send
|
||||
// nothing when the shape is ambiguous.
|
||||
reasoningKind string
|
||||
// log reports reasoning_effort validation and ignored thinking directives. It
|
||||
// is never nil: a discard logger tagged with this adapter's source is
|
||||
// installed when the caller supplies none, so a decision is always traceable.
|
||||
@@ -77,13 +82,18 @@ func WithOpenAILogger(l *slog.Logger) OpenAIOption {
|
||||
}
|
||||
|
||||
// NewOpenAIChat builds an adapter from a supplier config. baseURL is trimmed of
|
||||
// any trailing slash before the /chat/completions path is appended.
|
||||
// any trailing slash before the /chat/completions path is appended. The
|
||||
// endpoint's configured reasoning shape (cfg.ReasoningKind) travels with the
|
||||
// adapter so every chain built from the same config — the group's chain, its
|
||||
// per-persona chain and the local rung — resolves its reasoning switches the
|
||||
// same way.
|
||||
func NewOpenAIChat(cfg config.LLMConfig, opts ...OpenAIOption) *OpenAIChatAdapter {
|
||||
a := &OpenAIChatAdapter{
|
||||
name: "openai",
|
||||
baseURL: strings.TrimRight(cfg.BaseURL, "/"),
|
||||
apiKey: cfg.APIKey,
|
||||
model: cfg.Model,
|
||||
name: "openai",
|
||||
baseURL: strings.TrimRight(cfg.BaseURL, "/"),
|
||||
apiKey: cfg.APIKey,
|
||||
model: cfg.Model,
|
||||
reasoningKind: strings.TrimSpace(cfg.ReasoningKind),
|
||||
client: &http.Client{
|
||||
Transport: http.DefaultTransport,
|
||||
Timeout: defaultHTTPTimeout,
|
||||
|
||||
@@ -25,6 +25,15 @@ type openAIChatRequest struct {
|
||||
// only after validation against the detected vendor's whitelist (design/04
|
||||
// §3.2); an unrecognized value is dropped, never turned into a different tier.
|
||||
ReasoningEffort string `json:"reasoning_effort,omitempty"`
|
||||
// ChatTemplateKwargs carries the vLLM-style thinking switch
|
||||
// (chat_template_kwargs.enable_thinking, design/04 §3.2 Qwen3 行). It is sent
|
||||
// only to an endpoint whose provider declared reasoning.kind=vllm.
|
||||
ChatTemplateKwargs *openAIChatTemplateKwargs `json:"chat_template_kwargs,omitempty"`
|
||||
// EnableThinking carries the DashScope compatible-mode thinking switch (the
|
||||
// top-level enable_thinking). It is sent only to an endpoint whose provider
|
||||
// declared reasoning.kind=dashscope — the shape is never inferred, because a
|
||||
// wrong shape 400s the request.
|
||||
EnableThinking *bool `json:"enable_thinking,omitempty"`
|
||||
}
|
||||
|
||||
// openAIThinking is the wire form of DeepSeek's thinking switch: enabled or
|
||||
@@ -33,6 +42,12 @@ type openAIThinking struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// openAIChatTemplateKwargs is the wire form of the vLLM (Qwen3) thinking switch:
|
||||
// the boolean lives nested under chat_template_kwargs.
|
||||
type openAIChatTemplateKwargs struct {
|
||||
EnableThinking *bool `json:"enable_thinking,omitempty"`
|
||||
}
|
||||
|
||||
// openAIStreamOpts asks the provider to include usage in the final stream chunk.
|
||||
type openAIStreamOpts struct {
|
||||
IncludeUsage bool `json:"include_usage"`
|
||||
@@ -85,20 +100,23 @@ func (a *OpenAIChatAdapter) buildRequest(req Request, stream bool) ([]byte, erro
|
||||
// The vendor behind an OpenAI-compatible endpoint decides which
|
||||
// reasoning_effort tiers are legal and whether it has a thinking switch;
|
||||
// classify it from the resolved model (and the base URL as a fallback) before
|
||||
// touching the request (design/04 §3.2).
|
||||
// touching the request. The endpoint's configured shape (models.json
|
||||
// reasoning.kind, 裁决 03.4.2/08.4.1) wins over that classification, because
|
||||
// the same wire protocol carries different switch shapes (vLLM vs DashScope).
|
||||
vendor := reasoningVendorFor(req.Model, a.baseURL)
|
||||
effort := resolveReasoningEffort(a.log, vendor, req.ReasoningEffort, req.Model)
|
||||
thinking, effort := resolveOpenAIReasoning(a.log, vendor, req.Model, req.Thinking, effort)
|
||||
reportForcedToolChoiceWithThinking(a.log, req.Model, req.ToolChoice, openAIThinkingOn(thinking, effort))
|
||||
plan := resolveOpenAIReasoning(a.log, a.reasoningKind, vendor, req.Model, req.Thinking, req.ReasoningEffort)
|
||||
reportForcedToolChoiceWithThinking(a.log, plan.provider, req.Model, req.ToolChoice, plan.thinkingOn())
|
||||
wire := openAIChatRequest{
|
||||
Model: req.Model,
|
||||
Messages: msgs,
|
||||
Temperature: req.Temperature,
|
||||
MaxTokens: req.MaxTokens,
|
||||
Stream: stream,
|
||||
Tools: buildTools(req.Tools),
|
||||
Thinking: thinking,
|
||||
ReasoningEffort: effort,
|
||||
Model: req.Model,
|
||||
Messages: msgs,
|
||||
Temperature: req.Temperature,
|
||||
MaxTokens: req.MaxTokens,
|
||||
Stream: stream,
|
||||
Tools: buildTools(req.Tools),
|
||||
Thinking: plan.thinking,
|
||||
ReasoningEffort: plan.effort,
|
||||
ChatTemplateKwargs: plan.template,
|
||||
EnableThinking: plan.enable,
|
||||
}
|
||||
if stream {
|
||||
wire.StreamOptions = &openAIStreamOpts{IncludeUsage: true}
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
)
|
||||
|
||||
// 本文件钉住 03.4.1 剩下的那半条接线:**per-persona provider**(跨协议换 adapter)
|
||||
// 必须真的被消费。此前 RouteProviderForPersona 没有任何消费方,于是「某个人格走另
|
||||
// 一个厂商」只是配置里的一句话;现在带 PersonaID 的请求必须落到那个人格声明的协议
|
||||
// 链上(由 models.json 里同协议的组承载),不带角色的请求保持原样。
|
||||
|
||||
// personaProviderFixture 是一次 persona-provider 装配:生效组(openai)+ 被
|
||||
// persona 指向的另一个协议的组(anthropic),两个真实 stub server 分别记录请求。
|
||||
type personaProviderFixture struct {
|
||||
m *RuntimeLLM
|
||||
openaiCh chan capturedRequest
|
||||
anthropicCh chan anthropicCaptured
|
||||
}
|
||||
|
||||
// newPersonaProviderFixture 建一个 models.json:首组(生效组)声明 persona
|
||||
// cyrene 用自己的模型 "persona-model" 走 anthropic 协议;第二组就是那个 anthropic
|
||||
// 端点。extra 允许用例改写首组以覆盖各种残缺/同协议配置。
|
||||
func newPersonaProviderFixture(t *testing.T, mutate func(*runtimeconfig.Group)) *personaProviderFixture {
|
||||
t.Helper()
|
||||
openaiCh := make(chan capturedRequest, 4)
|
||||
openaiSrv := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, openaiCh)
|
||||
anthropicCh := make(chan anthropicCaptured, 4)
|
||||
anthropicSrv := newAnthropicServer(t, http.StatusOK,
|
||||
"application/json", anthropicTextResponse, anthropicCh)
|
||||
|
||||
main := runtimeconfig.Group{
|
||||
ID: "main", Provider: ProviderOpenAI, BaseURL: openaiSrv.URL, APIKey: "sk",
|
||||
Model: "main-model",
|
||||
PersonaModels: map[string]string{"cyrene": "persona-model"},
|
||||
PersonaProviders: map[string]string{"cyrene": ProviderAnthropic},
|
||||
}
|
||||
if mutate != nil {
|
||||
mutate(&main)
|
||||
}
|
||||
path := writeRTFile(t, []runtimeconfig.Group{
|
||||
main,
|
||||
{ID: "claude", Provider: ProviderAnthropic, BaseURL: anthropicSrv.URL,
|
||||
APIKey: "sk-ant", Model: "claude-fallback"},
|
||||
})
|
||||
return &personaProviderFixture{
|
||||
m: NewRuntimeLLM(path, rtEnv(), nil),
|
||||
openaiCh: openaiCh,
|
||||
anthropicCh: anthropicCh,
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuntimeLLM_PersonaProviderSelectsTheOtherProtocol 是主用例:带 persona 的
|
||||
// 请求必须走该 persona 声明的协议端(anthropic),并把 persona 绑定的模型名带过去;
|
||||
// 不带 persona 的请求必须仍走生效组(openai)。
|
||||
func TestRuntimeLLM_PersonaProviderSelectsTheOtherProtocol(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newPersonaProviderFixture(t, nil)
|
||||
|
||||
if _, err := f.m.Chat(context.Background(), Request{
|
||||
PersonaID: "cyrene", Model: "persona-model",
|
||||
Messages: []Message{TextMessage(RoleUser, "hi")},
|
||||
}, Options{}); err != nil {
|
||||
t.Fatalf("Chat with persona: %v", err)
|
||||
}
|
||||
got := recvWithin(t, f.anthropicCh, "the persona's anthropic endpoint")
|
||||
if got.path != "/v1/messages" {
|
||||
t.Fatalf("persona request path = %q, want /v1/messages (the persona's protocol)", got.path)
|
||||
}
|
||||
if got.body["model"] != "persona-model" {
|
||||
t.Fatalf("persona request model = %v, want persona-model", got.body["model"])
|
||||
}
|
||||
|
||||
if _, err := f.m.Chat(context.Background(), Request{
|
||||
Model: "main-model", Messages: []Message{TextMessage(RoleUser, "hi")},
|
||||
}, Options{}); err != nil {
|
||||
t.Fatalf("Chat without persona: %v", err)
|
||||
}
|
||||
plain := recvWithin(t, f.openaiCh, "the default openai endpoint")
|
||||
if plain.path != "/chat/completions" {
|
||||
t.Fatalf("default request path = %q, want /chat/completions", plain.path)
|
||||
}
|
||||
if plain.body["model"] != "main-model" {
|
||||
t.Fatalf("default request model = %v, want main-model", plain.body["model"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuntimeLLM_PersonaProviderAppliesToStreamingToo asserts the streaming path
|
||||
// (the main chat path: loop.streamStep → adapter.ChatStream) resolves the same
|
||||
// persona chain as the non-streaming one — a persona route that only worked on
|
||||
// one of the two paths would be a silent half-fix.
|
||||
func TestRuntimeLLM_PersonaProviderAppliesToStreamingToo(t *testing.T) {
|
||||
t.Parallel()
|
||||
anthropicCh := make(chan anthropicCaptured, 1)
|
||||
const sse = "event: message_start\ndata: {\"type\":\"message_start\"}\n\n" +
|
||||
"event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n" +
|
||||
"event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hi\"}}\n\n" +
|
||||
"event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
anthropicSrv := newAnthropicServer(t, http.StatusOK, "text/event-stream", sse, anthropicCh)
|
||||
openaiSrv := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, nil)
|
||||
|
||||
path := writeRTFile(t, []runtimeconfig.Group{
|
||||
{ID: "main", Provider: ProviderOpenAI, BaseURL: openaiSrv.URL, APIKey: "sk", Model: "main-model",
|
||||
PersonaModels: map[string]string{"cyrene": "persona-model"},
|
||||
PersonaProviders: map[string]string{"cyrene": ProviderAnthropic}},
|
||||
{ID: "claude", Provider: ProviderAnthropic, BaseURL: anthropicSrv.URL, APIKey: "sk-ant", Model: "x"},
|
||||
})
|
||||
m := NewRuntimeLLM(path, rtEnv(), nil)
|
||||
|
||||
if _, err := m.ChatStream(context.Background(), Request{
|
||||
PersonaID: "cyrene", Model: "persona-model",
|
||||
Messages: []Message{TextMessage(RoleUser, "hi")},
|
||||
}, func(StreamEvent) {}); err != nil {
|
||||
t.Fatalf("ChatStream with persona: %v", err)
|
||||
}
|
||||
got := recvWithin(t, anthropicCh, "the persona's anthropic endpoint")
|
||||
if got.body["model"] != "persona-model" || got.body["stream"] != true {
|
||||
t.Fatalf("stream request body = %v, want the persona's model streamed over its protocol", got.body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPersonaStack_StampsPersonaOnEveryRequest pins the carrier that lets a
|
||||
// per-persona agent wire the route without the loop handing over its ctx: the
|
||||
// scoped adapter view stamps PersonaID and routes the model by that persona.
|
||||
func TestPersonaStack_StampsPersonaOnEveryRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newPersonaProviderFixture(t, nil)
|
||||
view := f.m.ForPersona(" cyrene ") // 空白也会被规范化
|
||||
|
||||
if got := view.ScopedPersona(); got != "cyrene" {
|
||||
t.Fatalf("ScopedPersona() = %q, want cyrene (trimmed)", got)
|
||||
}
|
||||
if want := f.m.Name() + ":cyrene"; view.Name() != want {
|
||||
t.Errorf("Name() = %q, want %q", view.Name(), want)
|
||||
}
|
||||
if got, _ := view.RouteRequest(Request{}); got != "persona-model" {
|
||||
t.Fatalf("RouteRequest() = %q, want the persona's own model", got)
|
||||
}
|
||||
if _, err := view.Chat(context.Background(), Request{
|
||||
Messages: []Message{TextMessage(RoleUser, "hi")},
|
||||
}, Options{}); err != nil {
|
||||
t.Fatalf("Chat through the persona view: %v", err)
|
||||
}
|
||||
got := recvWithin(t, f.anthropicCh, "the persona's anthropic endpoint")
|
||||
if got.body["model"] != "persona-model" {
|
||||
t.Fatalf("view request model = %v, want persona-model (stamped persona routed it)", got.body["model"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestPersonaStack_BlankPersonaKeepsDefaultRoute asserts an unbound view is a
|
||||
// pass-through: an empty persona must not turn into a binding of its own.
|
||||
func TestPersonaStack_BlankPersonaKeepsDefaultRoute(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newPersonaProviderFixture(t, nil)
|
||||
view := f.m.ForPersona("")
|
||||
|
||||
if view.ScopedPersona() != "" {
|
||||
t.Fatalf("ScopedPersona() = %q, want empty", view.ScopedPersona())
|
||||
}
|
||||
if view.Name() != f.m.Name() {
|
||||
t.Errorf("Name() = %q, want the plain stack name %q", view.Name(), f.m.Name())
|
||||
}
|
||||
if got, _ := view.RouteRequest(Request{}); got != "main-model" {
|
||||
t.Fatalf("RouteRequest() = %q, want the default model", got)
|
||||
}
|
||||
if _, err := view.Chat(context.Background(), Request{
|
||||
Messages: []Message{TextMessage(RoleUser, "hi")},
|
||||
}, Options{}); err != nil {
|
||||
t.Fatalf("Chat through the blank view: %v", err)
|
||||
}
|
||||
if got := recvWithin(t, f.openaiCh, "the default openai endpoint"); got.body["model"] != "main-model" {
|
||||
t.Fatalf("blank view request went to %v, want the default chain", got.body["model"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuntimeLLM_PersonaProviderWithoutOwnModelKeepsDefaultChain covers the
|
||||
// half-declared config: a personaProviders entry alone says nothing about which
|
||||
// model to send, so switching protocol would push the group's default model name
|
||||
// to another vendor's endpoint. The default chain is kept and the reason logged.
|
||||
func TestRuntimeLLM_PersonaProviderWithoutOwnModelKeepsDefaultChain(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newPersonaProviderFixture(t, func(g *runtimeconfig.Group) {
|
||||
g.PersonaModels = nil
|
||||
})
|
||||
if _, err := f.m.Chat(context.Background(), Request{
|
||||
PersonaID: "cyrene", Messages: []Message{TextMessage(RoleUser, "hi")},
|
||||
}, Options{}); err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
got := recvWithin(t, f.openaiCh, "the default openai endpoint")
|
||||
if got.path != "/chat/completions" {
|
||||
t.Fatalf("path = %q, want the default chain (%v)", got.path, got.body)
|
||||
}
|
||||
if got.body["model"] != "main-model" {
|
||||
t.Fatalf("model = %v, want the group default model", got.body["model"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuntimeLLM_PersonaProviderWithoutAGroupKeepsDefaultChain covers a declared
|
||||
// protocol that models.json has no group for: the persona keeps the default chain
|
||||
// instead of failing the turn, and the skip is reported.
|
||||
func TestRuntimeLLM_PersonaProviderWithoutAGroupKeepsDefaultChain(t *testing.T) {
|
||||
t.Parallel()
|
||||
openaiCh := make(chan capturedRequest, 1)
|
||||
openaiSrv := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, openaiCh)
|
||||
path := writeRTFile(t, []runtimeconfig.Group{
|
||||
{ID: "main", Provider: ProviderOpenAI, BaseURL: openaiSrv.URL, APIKey: "sk", Model: "main-model",
|
||||
PersonaModels: map[string]string{"cyrene": "persona-model"},
|
||||
PersonaProviders: map[string]string{"cyrene": ProviderAnthropic}},
|
||||
})
|
||||
log, buf := captureLog()
|
||||
m := NewRuntimeLLM(path, rtEnv(), log)
|
||||
|
||||
if _, err := m.Chat(context.Background(), Request{
|
||||
PersonaID: "cyrene", Messages: []Message{TextMessage(RoleUser, "hi")},
|
||||
}, Options{}); err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
if got := recvWithin(t, openaiCh, "the default openai endpoint"); got.path != "/chat/completions" {
|
||||
t.Fatalf("path = %q, want the default chain", got.path)
|
||||
}
|
||||
rec := buf.String()
|
||||
for _, want := range []string{"level=WARN", "per-persona provider", "anthropic"} {
|
||||
if !strings.Contains(rec, want) {
|
||||
t.Errorf("log %q missing %q", rec, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuntimeLLM_PersonaProviderSameProtocolReusesDefaultChain asserts a persona
|
||||
// naming the active group's own protocol builds no second chain: the default one
|
||||
// already speaks it, and the per-persona model name reaches it via the router.
|
||||
func TestRuntimeLLM_PersonaProviderSameProtocolReusesDefaultChain(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newPersonaProviderFixture(t, func(g *runtimeconfig.Group) {
|
||||
g.PersonaProviders = map[string]string{"cyrene": ProviderOpenAI}
|
||||
})
|
||||
if chains := f.m.load().providerChains; chains != nil {
|
||||
t.Fatalf("providerChains = %v, want none built for the active protocol", chains)
|
||||
}
|
||||
if _, err := f.m.Chat(context.Background(), Request{
|
||||
PersonaID: "cyrene", Model: "persona-model",
|
||||
Messages: []Message{TextMessage(RoleUser, "hi")},
|
||||
}, Options{}); err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
if got := recvWithin(t, f.openaiCh, "the default openai endpoint"); got.body["model"] != "persona-model" {
|
||||
t.Fatalf("model = %v, want the persona's model over the default chain", got.body["model"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package model
|
||||
|
||||
import "context"
|
||||
|
||||
// 本文件实现「固定角色」的 LLM 栈视图(裁决 03.4.1 的 per-persona provider:
|
||||
// 跨协议换 adapter)。
|
||||
//
|
||||
// 为什么需要它:跨协议的 per-persona 路由要求「请求携带 persona」才能选链,
|
||||
// 而 persona 只存在于 loop 的会话环境里(挂在 loop 自己的私有 ctx 键上),
|
||||
// 且 model 不能反向 import loop。但 loop 取适配器的位置恰好是**按角色**的:
|
||||
// `loop.streamStep` 用 `ag.Adapter()`,而 agent 池是一角色一个 agent
|
||||
// (design/22 §4.2 方案 A)。于是「这个 agent 服务哪个角色」由装配处一次性
|
||||
// 决定、由适配器视图携带,是唯一不外泄、可测、且不动 loop/agent 结构的载体。
|
||||
//
|
||||
// 视图只做两件事:把角色贴到每个请求上(PersonaID),并按该角色路由模型名;
|
||||
// 选链与降级仍由 RuntimeLLM 完成(它看不到 loop 的角色解析结果)。
|
||||
|
||||
// PersonaScoped 由「固定服务某个角色」的适配器视图实现。agent 的非流式路径
|
||||
// (internal/runtime/agent/request.go)读它,好让同一个 persona 钩子在
|
||||
// RunStep 与流式主路径上都成立。空字符串表示该视图不绑定角色。
|
||||
type PersonaScoped interface {
|
||||
// ScopedPersona 返回该视图固定的角色 id。
|
||||
ScopedPersona() string
|
||||
}
|
||||
|
||||
// PersonaStack 是 LLM 栈的角色视图:它实现 Adapter(贴角色后委托给底层栈)
|
||||
// 与 ModelRouter(按角色路由模型名)。把它当作某一角色的 agent 的
|
||||
// adapter 与 router 注入,该 agent 的每个请求就都带上了这个角色。
|
||||
//
|
||||
// 它是无状态的,可安全并发使用。
|
||||
type PersonaStack struct {
|
||||
persona string
|
||||
inner *RuntimeLLM
|
||||
}
|
||||
|
||||
// ForPersona 返回服务 persona 的栈视图。空白 persona 返回一个不绑定角色的
|
||||
// 视图(等价于底层栈),使「没解析出角色」的装配处保持既有行为。
|
||||
func (m *RuntimeLLM) ForPersona(persona string) *PersonaStack {
|
||||
return &PersonaStack{persona: trimKey(persona), inner: m}
|
||||
}
|
||||
|
||||
// ScopedPersona 实现 PersonaScoped。
|
||||
func (p *PersonaStack) ScopedPersona() string { return p.persona }
|
||||
|
||||
// Name 实现 Adapter。带上角色后缀,使 CallRecord 与日志能分辨是哪个角色在
|
||||
// 说话;未绑定角色时与底层栈同名。
|
||||
func (p *PersonaStack) Name() string {
|
||||
if p.persona == "" {
|
||||
return p.inner.Name()
|
||||
}
|
||||
return p.inner.Name() + ":" + p.persona
|
||||
}
|
||||
|
||||
// Chat 实现 Adapter:贴上角色后委托给底层栈。
|
||||
func (p *PersonaStack) Chat(ctx context.Context, req Request, opts Options) (Response, error) {
|
||||
req.PersonaID = p.persona
|
||||
return p.inner.Chat(ctx, req, opts)
|
||||
}
|
||||
|
||||
// ChatStream 实现 Adapter:贴上角色后委托给底层栈。
|
||||
func (p *PersonaStack) ChatStream(ctx context.Context, req Request, onEvent func(StreamEvent)) (Response, error) {
|
||||
req.PersonaID = p.persona
|
||||
return p.inner.ChatStream(ctx, req, onEvent)
|
||||
}
|
||||
|
||||
// RouteRequest 实现 ModelRouter:按该视图的角色路由模型名,未绑定角色时
|
||||
// RouteRequestForPersona 会回落到消息 role 的同一条老路。它刻意不实现
|
||||
// PersonaRouter:loop 在拿到真实角色时自己调 RouteRequestForPersona,
|
||||
// 视图再顶一层只会让「谁的角色说了算」含糊。
|
||||
func (p *PersonaStack) RouteRequest(req Request) (string, bool) {
|
||||
return p.inner.RouteRequestForPersona(p.persona, req)
|
||||
}
|
||||
@@ -132,17 +132,17 @@ func normalizeDeepSeekEffort(effort string) (string, bool) {
|
||||
// turned into a different tier — it is dropped and reported with provider, field,
|
||||
// value and model, because changing it can invert the caller's intent. A DeepSeek
|
||||
// compatibility alias is normalized to the tier the service applies and reported.
|
||||
//
|
||||
// vendor must be a vendor with a reasoning_effort whitelist (DeepSeek / OpenAI):
|
||||
// the shapes that carry no such field (vLLM / DashScope) and the endpoints whose
|
||||
// shape is unknown drop the effort themselves and report it, so their callers
|
||||
// never reach this function (see reasoning_shape.go, 裁决 03.4.2).
|
||||
func resolveReasoningEffort(log *slog.Logger, vendor reasoningVendor, effort, model string) string {
|
||||
effort = strings.TrimSpace(effort)
|
||||
if effort == "" {
|
||||
return ""
|
||||
}
|
||||
allowed, ok := reasoningEffortAllowed[vendor]
|
||||
if !ok {
|
||||
log.Warn("reasoning_effort ignored: provider does not support it",
|
||||
"provider", string(vendor), "field", "reasoning_effort", "value", effort, "model", model)
|
||||
return ""
|
||||
}
|
||||
allowed := reasoningEffortAllowed[vendor]
|
||||
if allowed[effort] {
|
||||
return effort
|
||||
}
|
||||
@@ -159,57 +159,6 @@ func resolveReasoningEffort(log *slog.Logger, vendor reasoningVendor, effort, mo
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolveOpenAIReasoning maps the caller's reasoning knobs onto the DeepSeek
|
||||
// OpenAI-compatible wire fields and returns the top-level thinking switch (nil =
|
||||
// omitted) together with the reasoning_effort to send ("" = omitted). A
|
||||
// non-DeepSeek vendor has no portable field for the switch, so its directive is
|
||||
// only reported.
|
||||
//
|
||||
// When both knobs are given and disagree, the "off" side wins and the conflict is
|
||||
// reported: the provider documents no precedence, and letting a strength tier
|
||||
// re-open thinking is exactly the inversion this resolution exists to prevent
|
||||
// (design/04 §3.2 fact 2).
|
||||
func resolveOpenAIReasoning(log *slog.Logger, vendor reasoningVendor, model string, th *Thinking, effort string) (*openAIThinking, string) {
|
||||
if vendor != vendorDeepSeek {
|
||||
reportUnmappedThinking(log, vendor, model, th)
|
||||
return nil, effort
|
||||
}
|
||||
var block *openAIThinking
|
||||
if th != nil {
|
||||
if th.BudgetTokens > 0 {
|
||||
log.Warn("thinking.budget_tokens ignored: the DeepSeek chat-completions protocol takes reasoning_effort, not a token budget",
|
||||
"provider", "deepseek", "field", "thinking.budget_tokens",
|
||||
"budget_tokens", th.BudgetTokens, "model", model)
|
||||
}
|
||||
if th.Enable != nil {
|
||||
block = &openAIThinking{Type: thinkingSwitchValue(*th.Enable)}
|
||||
}
|
||||
}
|
||||
if block != nil && block.Type == thinkingTypeDisabled {
|
||||
if effort != "" {
|
||||
log.Warn("reasoning_effort dropped: thinking is explicitly disabled",
|
||||
"provider", "deepseek", "field", "reasoning_effort", "value", effort, "model", model)
|
||||
}
|
||||
return block, ""
|
||||
}
|
||||
if effort == effortNone && block != nil {
|
||||
log.Warn("reasoning_effort none contradicts thinking enabled: thinking disabled wins",
|
||||
"provider", "deepseek", "field", "reasoning_effort", "value", effort, "model", model)
|
||||
return &openAIThinking{Type: thinkingTypeDisabled}, ""
|
||||
}
|
||||
return block, effort
|
||||
}
|
||||
|
||||
// openAIThinkingOn reports the effective thinking state of a resolved DeepSeek
|
||||
// OpenAI-compatible request: an omitted switch leaves the provider default in
|
||||
// place, and DeepSeek's default is enabled.
|
||||
func openAIThinkingOn(th *openAIThinking, effort string) bool {
|
||||
if th != nil {
|
||||
return th.Type == thinkingTypeEnabled
|
||||
}
|
||||
return effort != effortNone
|
||||
}
|
||||
|
||||
// toolChoiceForces reports whether a tool_choice directive obliges the model to
|
||||
// call a tool ("required", Anthropic's "any", or a named tool). auto/none do not.
|
||||
func toolChoiceForces(toolChoice any) bool {
|
||||
@@ -252,40 +201,10 @@ func forcedToolChoiceType(value any) bool {
|
||||
// off on its own. On the OpenAI-compatible adapter the directive is not on the
|
||||
// wire yet (M2), so the warning is prospective there — it still records that the
|
||||
// requested combination can never be honoured.
|
||||
func reportForcedToolChoiceWithThinking(log *slog.Logger, model string, toolChoice any, thinkingOn bool) {
|
||||
func reportForcedToolChoiceWithThinking(log *slog.Logger, provider, model string, toolChoice any, thinkingOn bool) {
|
||||
if !thinkingOn || !toolChoiceForces(toolChoice) {
|
||||
return
|
||||
}
|
||||
log.Warn("tool_choice forcing cannot be combined with DeepSeek thinking (forcing is rejected with a 400): disable thinking explicitly before forcing a tool call",
|
||||
"provider", "deepseek", "field", "tool_choice", "model", model)
|
||||
}
|
||||
|
||||
// reportUnmappedThinking logs why a Thinking directive cannot be carried by a
|
||||
// non-DeepSeek OpenAI-compatible request, naming the provider and the field that
|
||||
// was ignored. Qwen3 does have enable_thinking, but the protocol has no portable
|
||||
// field for it (vLLM wants chat_template_kwargs.enable_thinking, DashScope's
|
||||
// compatible mode wants a top-level enable_thinking), and sending the wrong shape
|
||||
// can 400 the request — so this version reports it instead of guessing. DeepSeek's
|
||||
// switch is mapped by resolveOpenAIReasoning, not here.
|
||||
func reportUnmappedThinking(log *slog.Logger, vendor reasoningVendor, model string, th *Thinking) {
|
||||
if th == nil {
|
||||
return
|
||||
}
|
||||
if th.Enable != nil {
|
||||
switch vendor {
|
||||
case vendorQwen:
|
||||
log.Warn("enable_thinking not forwarded: the OpenAI-compatible protocol has no portable field for it",
|
||||
"provider", "qwen", "field", "extra_body.chat_template_kwargs.enable_thinking",
|
||||
"enable", *th.Enable, "model", model)
|
||||
default:
|
||||
log.Warn("thinking switch ignored: provider has no boolean thinking switch",
|
||||
"provider", string(vendor), "field", "thinking.enable", "enable", *th.Enable, "model", model)
|
||||
}
|
||||
return
|
||||
}
|
||||
if th.BudgetTokens > 0 {
|
||||
log.Warn("thinking budget ignored: OpenAI-compatible providers take reasoning_effort, not a token budget",
|
||||
"provider", string(vendor), "field", "thinking.budget_tokens",
|
||||
"budget_tokens", th.BudgetTokens, "model", model)
|
||||
}
|
||||
log.Warn("tool_choice forcing cannot be combined with thinking (forcing is rejected with a 400): disable thinking explicitly before forcing a tool call",
|
||||
"provider", provider, "field", "tool_choice", "model", model)
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
)
|
||||
|
||||
// 本文件把调用方的推理旋钮映射到**端点自己的** wire 形状(裁决 03.4.2 / 08.4.1:
|
||||
// 不同提供商的思考开关形状各不相同,必须逐 provider 独立适配,禁止「识别到配置
|
||||
// 却只 WARN 不下发」的假适配)。形状由 models.json 组的 `reasoning.kind` 显式声明
|
||||
// (配置载体见 internal/config/runtimeconfig),未声明时按模型名/BaseURL 推断厂商,
|
||||
// 且只在形状**无歧义**时才下发。
|
||||
//
|
||||
// 依据(实现前逐条核对,不猜字段名):
|
||||
// - DeepSeek:顶层 `thinking:{type:enabled|disabled}` + `reasoning_effort`
|
||||
// (官方取值 none/low/high/max 与别名表;思考**默认开**)。来源:
|
||||
// `DevDocs/reference/deepseek/guides-thinking_mode.md`(官方文档本地副本)。
|
||||
// - OpenAI 系:只有 `reasoning_effort`,没有布尔思考开关。来源:design/04 §3.2。
|
||||
// - vLLM(承载 Qwen3 等):`chat_template_kwargs.enable_thinking`。来源:design/04
|
||||
// §3.2 的 Qwen3 行(记作 `extra_body.chat_template_kwargs.enable_thinking`;
|
||||
// extra_body 是 OpenAI SDK「把键合并进顶层 body」的机制,与 DeepSeek 官方文档
|
||||
// 对 `thinking` 的描述同构,故线上形状是顶层 `chat_template_kwargs`)。
|
||||
// - DashScope 兼容模式:顶层 `enable_thinking`。来源:design/04 §3.2(「DashScope
|
||||
// 等互不相同」)+仓库既有实现(reasoning.go 原注释)。⚠️ 本仓库没有 DashScope
|
||||
// 官方文档本地副本,因此该形状**只在配置显式声明 reasoning.kind=dashscope 时
|
||||
// 下发**,绝不从模型名/BaseURL 自动推断(同一模型名可能由 vLLM 或 DashScope
|
||||
// 承载,形状不同,发错会 400)。
|
||||
// - Anthropic:自己的 `thinking:{type,budget_tokens}`,见 anthropic_reasoning.go。
|
||||
|
||||
// switchShape 描述「只有布尔开关、没有 reasoning_effort」的端点形状。
|
||||
type switchShape struct {
|
||||
// provider 是 WARN 与日志里报告的名字(vllm / dashscope)。
|
||||
provider string
|
||||
// field 是该形状开关的线上字段路径,只用于日志(含配置指引)。
|
||||
field string
|
||||
}
|
||||
|
||||
var (
|
||||
// shapeVLLM 是 vLLM 类部署:开关在 chat_template_kwargs 里。
|
||||
shapeVLLM = switchShape{provider: "vllm", field: "chat_template_kwargs.enable_thinking"}
|
||||
// shapeDashScope 是 DashScope 兼容模式:开关在请求体顶层。
|
||||
shapeDashScope = switchShape{provider: "dashscope", field: "enable_thinking"}
|
||||
)
|
||||
|
||||
// openAIReasoning 是一次调用要并入 OpenAI 兼容请求体的推理指令。它是各形状的
|
||||
// 并集,但**只会填入该端点形状对应的那一个字段**:形状之间互不替代。
|
||||
type openAIReasoning struct {
|
||||
// thinking 是 DeepSeek 的顶层开关 {"thinking":{"type":...}}。
|
||||
thinking *openAIThinking
|
||||
// effort 是 reasoning_effort(OpenAI 系与 DeepSeek 用)。
|
||||
effort string
|
||||
// template 是 vLLM 的 {"chat_template_kwargs":{"enable_thinking":..}}。
|
||||
template *openAIChatTemplateKwargs
|
||||
// enable 是 DashScope 兼容模式的顶层 enable_thinking。
|
||||
enable *bool
|
||||
// provider 是本次解析归属的 provider 名,供日志定位。
|
||||
provider string
|
||||
// providerDefaultOn 报告该端点**未声明开关时**的思考默认状态。只有 DeepSeek
|
||||
// 有官方依据(默认 enabled),其余形状为 false:没有依据就不断言其默认。
|
||||
providerDefaultOn bool
|
||||
}
|
||||
|
||||
// thinkingOn 报告该请求的思考是否处于开启状态(显式开关优先,其次是
|
||||
// reasoning_effort none 这个官方关闭值,最后才是端点自身默认)。它只用于
|
||||
// 提示性 WARN(强制 tool_choice 与思考不兼容),不改变请求。
|
||||
func (r openAIReasoning) thinkingOn() bool {
|
||||
switch {
|
||||
case r.thinking != nil:
|
||||
return r.thinking.Type == thinkingTypeEnabled
|
||||
case r.template != nil && r.template.EnableThinking != nil:
|
||||
return *r.template.EnableThinking
|
||||
case r.enable != nil:
|
||||
return *r.enable
|
||||
case r.effort != "":
|
||||
return r.effort != effortNone
|
||||
}
|
||||
return r.providerDefaultOn
|
||||
}
|
||||
|
||||
// setSwitch 把布尔开关按形状挂到对应字段上。
|
||||
func (r *openAIReasoning) setSwitch(shape switchShape, enable bool) {
|
||||
value := enable
|
||||
switch shape {
|
||||
case shapeVLLM:
|
||||
r.template = &openAIChatTemplateKwargs{EnableThinking: &value}
|
||||
case shapeDashScope:
|
||||
r.enable = &value
|
||||
}
|
||||
}
|
||||
|
||||
// resolveOpenAIReasoning 按 provider 形状解析推理指令。kind 是配置声明的形状
|
||||
// (空/"auto" = 推断);vendor 是推断出的厂商,仅在 auto 下参与决策。
|
||||
func resolveOpenAIReasoning(log *slog.Logger, kind string, vendor reasoningVendor, model string, th *Thinking, effort string) openAIReasoning {
|
||||
switch strings.ToLower(strings.TrimSpace(kind)) {
|
||||
case runtimeconfig.ReasoningKindDeepSeek:
|
||||
return resolveDeepSeekReasoning(log, model, th, effort)
|
||||
case runtimeconfig.ReasoningKindOpenAI:
|
||||
return resolveEffortOnlyReasoning(log, vendorOpenAI, model, th, effort)
|
||||
case runtimeconfig.ReasoningKindVLLM:
|
||||
return resolveSwitchReasoning(log, shapeVLLM, model, th, effort)
|
||||
case runtimeconfig.ReasoningKindDashScope:
|
||||
return resolveSwitchReasoning(log, shapeDashScope, model, th, effort)
|
||||
case "", runtimeconfig.ReasoningKindAuto:
|
||||
return resolveAutoReasoning(log, vendor, model, th, effort)
|
||||
default:
|
||||
// 配置里写了一个本版本不认识的形状:保守不发任何字段,但必须报告,
|
||||
// 否则「配了却不生效」正是本裁决要消灭的假适配。
|
||||
log.Warn("reasoning.kind unrecognized: no reasoning field is sent",
|
||||
"kind", kind, "model", model, "field", "reasoning.kind")
|
||||
return openAIReasoning{provider: strings.TrimSpace(kind)}
|
||||
}
|
||||
}
|
||||
|
||||
// resolveAutoReasoning 处理未声明形状的端点:按推断出的厂商选择**该厂商唯一
|
||||
// 无歧义的**形状,厂商认识但形状有歧义(Qwen 既可能是 vLLM 也可能是 DashScope)
|
||||
// 时不下发开关,只报告并给出配置指引。
|
||||
func resolveAutoReasoning(log *slog.Logger, vendor reasoningVendor, model string, th *Thinking, effort string) openAIReasoning {
|
||||
switch vendor {
|
||||
case vendorDeepSeek:
|
||||
return resolveDeepSeekReasoning(log, model, th, effort)
|
||||
case vendorOpenAI:
|
||||
return resolveEffortOnlyReasoning(log, vendorOpenAI, model, th, effort)
|
||||
default:
|
||||
return resolveNoShapeReasoning(log, vendor, model, th, effort)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveDeepSeekReasoning 映射 DeepSeek 的 OpenAI 兼容形状:顶层 thinking 开关
|
||||
// 与 reasoning_effort。DeepSeek 的思考**默认开启**,所以「不传」不等于「关」;
|
||||
// 要关只能显式 disabled 或 effort none(官方文档 guides-thinking_mode)。
|
||||
func resolveDeepSeekReasoning(log *slog.Logger, model string, th *Thinking, effort string) openAIReasoning {
|
||||
effort = resolveReasoningEffort(log, vendorDeepSeek, effort, model)
|
||||
out := openAIReasoning{effort: effort, provider: string(vendorDeepSeek), providerDefaultOn: true}
|
||||
if th != nil && th.BudgetTokens > 0 {
|
||||
log.Warn("thinking.budget_tokens ignored: the DeepSeek chat-completions protocol takes reasoning_effort, not a token budget",
|
||||
"provider", string(vendorDeepSeek), "field", "thinking.budget_tokens",
|
||||
"budget_tokens", th.BudgetTokens, "model", model)
|
||||
}
|
||||
if th == nil || th.Enable == nil {
|
||||
return out
|
||||
}
|
||||
if !*th.Enable {
|
||||
if effort != "" {
|
||||
log.Warn("reasoning_effort dropped: thinking is explicitly disabled",
|
||||
"provider", string(vendorDeepSeek), "field", "reasoning_effort", "value", effort, "model", model)
|
||||
}
|
||||
out.thinking = &openAIThinking{Type: thinkingTypeDisabled}
|
||||
out.effort = ""
|
||||
return out
|
||||
}
|
||||
if effort == effortNone {
|
||||
// 「关」胜过「开」:provider 未声明优先级,让强度档把思考重新打开正是
|
||||
// 本解析要阻止的语义反转(design/04 §3.2 fact 2)。
|
||||
log.Warn("reasoning_effort none contradicts thinking enabled: thinking disabled wins",
|
||||
"provider", string(vendorDeepSeek), "field", "reasoning_effort", "value", effort, "model", model)
|
||||
out.thinking = &openAIThinking{Type: thinkingTypeDisabled}
|
||||
out.effort = ""
|
||||
return out
|
||||
}
|
||||
out.thinking = &openAIThinking{Type: thinkingTypeEnabled}
|
||||
return out
|
||||
}
|
||||
|
||||
// resolveEffortOnlyReasoning 映射「只有 reasoning_effort」的端点(OpenAI 系):
|
||||
// 强度档按该厂商白名单校验,布尔开关没有 wire 字段可发,因此只报告并说明
|
||||
// 官方替代路径(effort none 才是关闭推理的方式)。
|
||||
func resolveEffortOnlyReasoning(log *slog.Logger, vendor reasoningVendor, model string, th *Thinking, effort string) openAIReasoning {
|
||||
effort = resolveReasoningEffort(log, vendor, effort, model)
|
||||
out := openAIReasoning{effort: effort, provider: string(vendor)}
|
||||
if th == nil {
|
||||
return out
|
||||
}
|
||||
if th.Enable != nil {
|
||||
log.Warn("thinking switch ignored: this endpoint has no boolean thinking switch; reasoning_effort none is the documented way to disable reasoning",
|
||||
"provider", string(vendor), "field", "thinking.enable", "enable", *th.Enable, "model", model)
|
||||
}
|
||||
if th.BudgetTokens > 0 {
|
||||
log.Warn("thinking budget ignored: OpenAI-compatible providers take reasoning_effort, not a token budget",
|
||||
"provider", string(vendor), "field", "thinking.budget_tokens",
|
||||
"budget_tokens", th.BudgetTokens, "model", model)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// resolveSwitchReasoning 映射「只有布尔开关」的端点(vLLM / DashScope 类):
|
||||
// 开关按形状下发;该形状没有 reasoning_effort 字段,因此强度档被丢弃并报告——
|
||||
// 唯一的例外是官方关闭值 `none`,它被翻译成**显式关思考**(红线:none = 关思考,
|
||||
// 绝不翻译成任何强度档,那会把「关」变成「开」)。
|
||||
func resolveSwitchReasoning(log *slog.Logger, shape switchShape, model string, th *Thinking, effort string) openAIReasoning {
|
||||
out := openAIReasoning{provider: shape.provider}
|
||||
var enable *bool
|
||||
if th != nil {
|
||||
if th.BudgetTokens > 0 {
|
||||
log.Warn("thinking budget ignored: this endpoint takes a boolean thinking switch, not a token budget",
|
||||
"provider", shape.provider, "field", "thinking.budget_tokens",
|
||||
"budget_tokens", th.BudgetTokens, "model", model, "endpoint_field", shape.field)
|
||||
}
|
||||
enable = th.Enable
|
||||
}
|
||||
if effort == effortNone {
|
||||
if enable != nil && *enable {
|
||||
log.Warn("reasoning_effort none contradicts thinking enabled: thinking disabled wins",
|
||||
"provider", shape.provider, "field", "reasoning_effort", "value", effort, "model", model)
|
||||
} else if enable == nil {
|
||||
log.Warn("reasoning_effort has no field on this endpoint: none is applied as the explicit thinking switch off",
|
||||
"provider", shape.provider, "field", shape.field, "value", effort, "model", model)
|
||||
}
|
||||
out.setSwitch(shape, false)
|
||||
return out
|
||||
}
|
||||
if effort != "" {
|
||||
dropUnsupportedEffort(log, shape, model, effort)
|
||||
}
|
||||
if enable != nil {
|
||||
out.setSwitch(shape, *enable)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// dropUnsupportedEffort 报告该形状没有 reasoning_effort 字段(含配置指引),
|
||||
// 绝不改写成近似档位:改档会反转调用方意图,正是旧实现踩过的坑。
|
||||
func dropUnsupportedEffort(log *slog.Logger, shape switchShape, model, effort string) {
|
||||
log.Warn("reasoning_effort dropped: this endpoint has no reasoning_effort field",
|
||||
"provider", shape.provider, "field", "reasoning_effort", "value", effort,
|
||||
"model", model, "endpoint_field", shape.field)
|
||||
}
|
||||
|
||||
// resolveNoShapeReasoning 处理「厂商未知或形状有歧义」的端点:不发送任何推理
|
||||
// 字段(发错形状会 400),但逐项报告被忽略的旋钮与配置指引(design/04 §3.2:
|
||||
// 禁止静默无效)。
|
||||
func resolveNoShapeReasoning(log *slog.Logger, vendor reasoningVendor, model string, th *Thinking, effort string) openAIReasoning {
|
||||
reportUnmappedThinking(log, vendor, model, th)
|
||||
if effort != "" {
|
||||
log.Warn("reasoning_effort ignored: provider does not support it",
|
||||
"provider", string(vendor), "field", "reasoning_effort", "value", effort, "model", model)
|
||||
}
|
||||
return openAIReasoning{provider: string(vendor)}
|
||||
}
|
||||
|
||||
// reportUnmappedThinking 报告一个 Thinking 指令为何无法由该端点承载:厂商认识
|
||||
// 但形状有歧义(Qwen 既可能由 vLLM 承载、也可能由 DashScope 承载,两者字段形状
|
||||
// 不同),或厂商未知。两条路都给出配置指引,让主人能显式声明形状而不是让我们猜。
|
||||
// DeepSeek 的开关由 resolveDeepSeekReasoning 映射,不经过这里。
|
||||
func reportUnmappedThinking(log *slog.Logger, vendor reasoningVendor, model string, th *Thinking) {
|
||||
if th == nil {
|
||||
return
|
||||
}
|
||||
if th.Enable != nil {
|
||||
switch vendor {
|
||||
case vendorQwen:
|
||||
log.Warn("enable_thinking not forwarded: the endpoint's switch shape is ambiguous (vLLM wants chat_template_kwargs.enable_thinking, DashScope wants a top-level enable_thinking) and sending the wrong shape can 400; declare reasoning.kind to choose one",
|
||||
"provider", "qwen", "field", "extra_body.chat_template_kwargs.enable_thinking",
|
||||
"enable", *th.Enable, "model", model, "config_field", "reasoning.kind")
|
||||
default:
|
||||
log.Warn("thinking switch ignored: provider has no boolean thinking switch",
|
||||
"provider", string(vendor), "field", "thinking.enable", "enable", *th.Enable, "model", model)
|
||||
}
|
||||
return
|
||||
}
|
||||
if th.BudgetTokens > 0 {
|
||||
log.Warn("thinking budget ignored: OpenAI-compatible providers take reasoning_effort, not a token budget",
|
||||
"provider", string(vendor), "field", "thinking.budget_tokens",
|
||||
"budget_tokens", th.BudgetTokens, "model", model)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
)
|
||||
|
||||
// 本文件钉住「按提供商独立适配推理开关」(裁决 03.4.2 / 08.4.1):声明了形状的端点
|
||||
// 必须真的下发**自己形状**的开关(而不是只 WARN),且绝不发别人的形状——发错形状会
|
||||
// 400,比不发更糟。自动模式(未声明形状)保持保守:形状有歧义时不下发。
|
||||
|
||||
// recvWithin 在超时内从记录通道取一次请求。路由/下发出错时(请求打到了另一个端点
|
||||
// 或根本没发),用例必须**立刻失败**并指明哪个端点没收到,而不是挂在通道上把整个包
|
||||
// 拖到测试超时——挂死的红是让人看不懂的红。
|
||||
func recvWithin[T any](t *testing.T, ch <-chan T, endpoint string) T {
|
||||
t.Helper()
|
||||
select {
|
||||
case got := <-ch:
|
||||
return got
|
||||
case <-time.After(2 * time.Second):
|
||||
var zero T
|
||||
t.Fatalf("%s did not receive the request within 2s", endpoint)
|
||||
return zero
|
||||
}
|
||||
}
|
||||
|
||||
// newKindAdapter 构造一个 OpenAI 兼容适配器,其配置声明了给定的推理开关形状。
|
||||
// 真实端点由 stub server 顶替,请求体被记录供断言。
|
||||
func newKindAdapter(t *testing.T, kind string, ch chan<- capturedRequest, opts ...OpenAIOption) *OpenAIChatAdapter {
|
||||
t.Helper()
|
||||
srv := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, ch)
|
||||
return NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: "endpoint-default", ReasoningKind: kind}, opts...)
|
||||
}
|
||||
|
||||
// declaredShapeCases 是「声明形状 → 该形状的开关字段」的对照表。model 刻意取一个
|
||||
// **会误导自动识别**的名字:显式形状必须压过模型名/BaseURL 的厂商推断。
|
||||
var declaredShapeCases = []struct {
|
||||
name string
|
||||
kind string
|
||||
model string
|
||||
field string
|
||||
}{
|
||||
{"deepseek", runtimeconfig.ReasoningKindDeepSeek, "qwen3-max", "thinking"},
|
||||
{"vllm", runtimeconfig.ReasoningKindVLLM, "deepseek-flash", "chat_template_kwargs"},
|
||||
{"dashscope", runtimeconfig.ReasoningKindDashScope, "deepseek-flash", "enable_thinking"},
|
||||
}
|
||||
|
||||
// allSwitchFields 是全部形状的开关字段,用于断言「只发自己那一个」。
|
||||
var allSwitchFields = []string{"thinking", "chat_template_kwargs", "enable_thinking"}
|
||||
|
||||
// switchBool 把一个开关字段读成布尔值(thinking 的开关读嵌套的 type)。
|
||||
func switchBool(body map[string]any, field string) (bool, bool) {
|
||||
value, present := body[field]
|
||||
if !present {
|
||||
return false, false
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case bool:
|
||||
return v, true
|
||||
case map[string]any:
|
||||
if field == "thinking" {
|
||||
return v["type"] == thinkingTypeEnabled, true
|
||||
}
|
||||
if nested, ok := v["enable_thinking"].(bool); ok {
|
||||
return nested, true
|
||||
}
|
||||
}
|
||||
return false, true
|
||||
}
|
||||
|
||||
// TestOpenAIChat_DeclaredShapeSendsItsOwnSwitch 是本次裁决的主用例:每个 provider
|
||||
// 声明自己的形状后,关闭思考必须真的下发到该形状的字段上,且请求体不含任何别的形状。
|
||||
func TestOpenAIChat_DeclaredShapeSendsItsOwnSwitch(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range declaredShapeCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ch := make(chan capturedRequest, 1)
|
||||
ad := newKindAdapter(t, tc.kind, ch)
|
||||
if _, err := ad.Chat(context.Background(), Request{
|
||||
Model: tc.model,
|
||||
Messages: []Message{TextMessage(RoleUser, "hi")},
|
||||
Thinking: &Thinking{Enable: boolPtr(false)},
|
||||
}, Options{}); err != nil {
|
||||
t.Fatalf("Chat error: %v", err)
|
||||
}
|
||||
body := recvWithin(t, ch, "the declared endpoint").body
|
||||
got, present := switchBool(body, tc.field)
|
||||
if !present {
|
||||
t.Fatalf("body[%q] missing: the declared shape %q was not forwarded: %v", tc.field, tc.kind, body)
|
||||
}
|
||||
if got {
|
||||
t.Fatalf("body[%q] = thinking on, want off", tc.field)
|
||||
}
|
||||
for _, other := range allSwitchFields {
|
||||
if other == tc.field {
|
||||
continue
|
||||
}
|
||||
if _, present := body[other]; present {
|
||||
t.Errorf("body[%q] = %v, want no other shape's field (shape %q)", other, body[other], tc.kind)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenAIChat_DeclaredShapeEnablesThinking pins the on direction as well, so the
|
||||
// switch cannot be hard-wired off by a refactor.
|
||||
func TestOpenAIChat_DeclaredShapeEnablesThinking(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range declaredShapeCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ch := make(chan capturedRequest, 1)
|
||||
ad := newKindAdapter(t, tc.kind, ch)
|
||||
if _, err := ad.Chat(context.Background(), Request{
|
||||
Model: tc.model,
|
||||
Messages: []Message{TextMessage(RoleUser, "hi")},
|
||||
Thinking: &Thinking{Enable: boolPtr(true)},
|
||||
}, Options{}); err != nil {
|
||||
t.Fatalf("Chat error: %v", err)
|
||||
}
|
||||
got, present := switchBool(recvWithin(t, ch, "the declared endpoint").body, tc.field)
|
||||
if !present || !got {
|
||||
t.Fatalf("body[%q] present=%v value=%v, want an explicit on", tc.field, present, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenAIChat_DeclaredSwitchShapeDropsEffort 是「该形状没有 reasoning_effort」
|
||||
// 的红线用例:强度档被丢弃并报告,绝不改写成近似档(改档会反转调用方意图)。
|
||||
func TestOpenAIChat_DeclaredSwitchShapeDropsEffort(t *testing.T) {
|
||||
t.Parallel()
|
||||
ch := make(chan capturedRequest, 1)
|
||||
log, buf := captureLog()
|
||||
ad := newKindAdapter(t, runtimeconfig.ReasoningKindVLLM, ch, WithOpenAILogger(log))
|
||||
|
||||
if _, err := ad.Chat(context.Background(), Request{
|
||||
Model: "qwen3-max",
|
||||
Messages: []Message{TextMessage(RoleUser, "hi")},
|
||||
ReasoningEffort: "high",
|
||||
}, Options{}); err != nil {
|
||||
t.Fatalf("Chat error: %v", err)
|
||||
}
|
||||
body := recvWithin(t, ch, "the declared endpoint").body
|
||||
if _, present := body["reasoning_effort"]; present {
|
||||
t.Fatalf("body.reasoning_effort = %v, want the field dropped", body["reasoning_effort"])
|
||||
}
|
||||
if _, present := body["chat_template_kwargs"]; present {
|
||||
t.Fatalf("body.chat_template_kwargs = %v, want no switch invented from an effort", body["chat_template_kwargs"])
|
||||
}
|
||||
rec := buf.String()
|
||||
for _, want := range []string{"level=WARN", "reasoning_effort", "high", "vllm", "chat_template_kwargs.enable_thinking"} {
|
||||
if !strings.Contains(rec, want) {
|
||||
t.Errorf("log %q missing %q", rec, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenAIChat_EffortNoneBecomesExplicitSwitchOff 是红线用例:`reasoning_effort:
|
||||
// none` 的语义是**关思考**。在只有布尔开关的端点上它必须落成显式关闭,绝不能被
|
||||
// 翻成任何强度档(那正是旧实现把「关」变成「开」的语义反转)。
|
||||
func TestOpenAIChat_EffortNoneBecomesExplicitSwitchOff(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range declaredShapeCases {
|
||||
if tc.kind == runtimeconfig.ReasoningKindDeepSeek {
|
||||
continue // DeepSeek 有 reasoning_effort 字段,none 原样下发(另有用例钉住)
|
||||
}
|
||||
t.Run(tc.kind, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ch := make(chan capturedRequest, 1)
|
||||
log, buf := captureLog()
|
||||
ad := newKindAdapter(t, tc.kind, ch, WithOpenAILogger(log))
|
||||
if _, err := ad.Chat(context.Background(), Request{
|
||||
Model: tc.model,
|
||||
Messages: []Message{TextMessage(RoleUser, "hi")},
|
||||
ReasoningEffort: effortNone,
|
||||
}, Options{}); err != nil {
|
||||
t.Fatalf("Chat error: %v", err)
|
||||
}
|
||||
body := recvWithin(t, ch, "the declared endpoint").body
|
||||
got, present := switchBool(body, tc.field)
|
||||
if !present || got {
|
||||
t.Fatalf("body[%q] present=%v value=%v, want an explicit off", tc.field, present, got)
|
||||
}
|
||||
if _, present := body["reasoning_effort"]; present {
|
||||
t.Fatalf("body.reasoning_effort = %v, want the field dropped", body["reasoning_effort"])
|
||||
}
|
||||
if rec := buf.String(); !strings.Contains(rec, effortNone) || !strings.Contains(rec, "level=WARN") {
|
||||
t.Errorf("log %q, want a WARN explaining the none→off translation", rec)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenAIChat_ReasoningKindOpenAIKeepsOpenAITiers 用一个 DeepSeek 风格的模型名
|
||||
// 证明显式形状压过厂商推断:`medium` 在 OpenAI 白名单里是合法档,必须原样下发,
|
||||
// 不能按 DeepSeek 别名表被改写成 high。
|
||||
func TestOpenAIChat_ReasoningKindOpenAIKeepsOpenAITiers(t *testing.T) {
|
||||
t.Parallel()
|
||||
ch := make(chan capturedRequest, 1)
|
||||
ad := newKindAdapter(t, runtimeconfig.ReasoningKindOpenAI, ch)
|
||||
|
||||
if _, err := ad.Chat(context.Background(), Request{
|
||||
Model: "deepseek-flash",
|
||||
Messages: []Message{TextMessage(RoleUser, "hi")},
|
||||
ReasoningEffort: "medium",
|
||||
}, Options{}); err != nil {
|
||||
t.Fatalf("Chat error: %v", err)
|
||||
}
|
||||
body := recvWithin(t, ch, "the declared endpoint").body
|
||||
if got := body["reasoning_effort"]; got != "medium" {
|
||||
t.Fatalf("body.reasoning_effort = %v, want medium (an OpenAI tier, not the DeepSeek alias high)", got)
|
||||
}
|
||||
for _, other := range allSwitchFields {
|
||||
if _, present := body[other]; present {
|
||||
t.Errorf("body[%q] = %v, want no switch shape on an effort-only endpoint", other, body[other])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenAIChat_ReasoningKindOpenAIReportsBooleanSwitch asserts the effort-only
|
||||
// shape does not silently swallow a boolean thinking switch: it reports it, and
|
||||
// points at the documented way to disable reasoning.
|
||||
func TestOpenAIChat_ReasoningKindOpenAIReportsBooleanSwitch(t *testing.T) {
|
||||
t.Parallel()
|
||||
ch := make(chan capturedRequest, 1)
|
||||
log, buf := captureLog()
|
||||
ad := newKindAdapter(t, runtimeconfig.ReasoningKindOpenAI, ch, WithOpenAILogger(log))
|
||||
|
||||
if _, err := ad.Chat(context.Background(), Request{
|
||||
Model: "gpt-4o",
|
||||
Messages: []Message{TextMessage(RoleUser, "hi")},
|
||||
Thinking: &Thinking{Enable: boolPtr(false)},
|
||||
}, Options{}); err != nil {
|
||||
t.Fatalf("Chat error: %v", err)
|
||||
}
|
||||
body := recvWithin(t, ch, "the declared endpoint").body
|
||||
for _, other := range allSwitchFields {
|
||||
if _, present := body[other]; present {
|
||||
t.Fatalf("body[%q] present, want nothing sent for a boolean switch on an effort-only endpoint", other)
|
||||
}
|
||||
}
|
||||
rec := buf.String()
|
||||
for _, want := range []string{"level=WARN", "thinking.enable", "openai", "reasoning_effort"} {
|
||||
if !strings.Contains(rec, want) {
|
||||
t.Errorf("log %q missing %q", rec, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenAIChat_UnknownReasoningKindSendsNothing asserts a shape this build does
|
||||
// not implement is reported and sends nothing — even when the model name would
|
||||
// otherwise look like a vendor we do understand. Guessing here is exactly the
|
||||
// "fake adaptation" the ruling forbids.
|
||||
func TestOpenAIChat_UnknownReasoningKindSendsNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
ch := make(chan capturedRequest, 1)
|
||||
log, buf := captureLog()
|
||||
ad := newKindAdapter(t, "gemini", ch, WithOpenAILogger(log))
|
||||
|
||||
if _, err := ad.Chat(context.Background(), Request{
|
||||
Model: "gpt-4o",
|
||||
Messages: []Message{TextMessage(RoleUser, "hi")},
|
||||
Thinking: &Thinking{Enable: boolPtr(false)},
|
||||
ReasoningEffort: "low",
|
||||
}, Options{}); err != nil {
|
||||
t.Fatalf("Chat error: %v", err)
|
||||
}
|
||||
body := recvWithin(t, ch, "the declared endpoint").body
|
||||
for _, field := range append(append([]string{}, allSwitchFields...), "reasoning_effort") {
|
||||
if _, present := body[field]; present {
|
||||
t.Errorf("body[%q] = %v, want nothing sent for an unknown shape", field, body[field])
|
||||
}
|
||||
}
|
||||
rec := buf.String()
|
||||
for _, want := range []string{"level=WARN", "reasoning.kind", "gemini"} {
|
||||
if !strings.Contains(rec, want) {
|
||||
t.Errorf("log %q missing %q", rec, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenAIChat_AutoQwenShapeStaysConservative pins the un-declared default: a
|
||||
// Qwen endpoint may be vLLM or DashScope, whose field shapes differ, so auto mode
|
||||
// sends nothing and asks for an explicit declaration instead of guessing one.
|
||||
func TestOpenAIChat_AutoQwenShapeStaysConservative(t *testing.T) {
|
||||
t.Parallel()
|
||||
ch := make(chan capturedRequest, 1)
|
||||
log, buf := captureLog()
|
||||
ad := newKindAdapter(t, "", ch, WithOpenAILogger(log))
|
||||
|
||||
if _, err := ad.Chat(context.Background(), Request{
|
||||
Model: "qwen3-max",
|
||||
Messages: []Message{TextMessage(RoleUser, "hi")},
|
||||
Thinking: &Thinking{Enable: boolPtr(false)},
|
||||
}, Options{}); err != nil {
|
||||
t.Fatalf("Chat error: %v", err)
|
||||
}
|
||||
body := recvWithin(t, ch, "the declared endpoint").body
|
||||
for _, field := range allSwitchFields {
|
||||
if _, present := body[field]; present {
|
||||
t.Errorf("body[%q] = %v, want nothing sent while the shape is ambiguous", field, body[field])
|
||||
}
|
||||
}
|
||||
rec := buf.String()
|
||||
for _, want := range []string{"level=WARN", "enable_thinking", "qwen", "reasoning.kind"} {
|
||||
if !strings.Contains(rec, want) {
|
||||
t.Errorf("log %q missing %q", rec, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewChatChain_CarriesReasoningKind 钉住配置到适配器的贯通:链的每一步都从
|
||||
// 同一个 group 配置构建,因此声明过的形状必须随配置抵达适配器(否则 models.json
|
||||
// 里的声明会静默失效)。
|
||||
func TestNewChatChain_CarriesReasoningKind(t *testing.T) {
|
||||
t.Parallel()
|
||||
chain, err := NewChatChain(config.LLMConfig{
|
||||
Provider: ProviderOpenAI, BaseURL: "http://127.0.0.1:1/v1", Model: "m", Fallback: "fb",
|
||||
ReasoningKind: runtimeconfig.ReasoningKindDashScope,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewChatChain: %v", err)
|
||||
}
|
||||
if len(chain.steps) != 2 {
|
||||
t.Fatalf("chain steps = %d, want 2 (primary + fallback)", len(chain.steps))
|
||||
}
|
||||
for i, step := range chain.steps {
|
||||
ad, ok := step.Adapter.(*OpenAIChatAdapter)
|
||||
if !ok {
|
||||
t.Fatalf("step %d adapter is %T, want *OpenAIChatAdapter", i, step.Adapter)
|
||||
}
|
||||
if ad.reasoningKind != runtimeconfig.ReasoningKindDashScope {
|
||||
t.Errorf("step %d reasoningKind = %q, want %q", i, ad.reasoningKind, runtimeconfig.ReasoningKindDashScope)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,11 @@ type runtimeChainSnapshot struct {
|
||||
router *Router
|
||||
group runtimeconfig.Group
|
||||
groups []runtimeconfig.Group
|
||||
// providerChains holds one chain per *other* protocol declared by the active
|
||||
// group's personaProviders (裁决 03.4.1 的 per-persona provider:跨协议换
|
||||
// adapter). A persona whose provider binding resolves to one of these keys is
|
||||
// served by that chain; every other request keeps the default chain above.
|
||||
providerChains map[string]*Chain
|
||||
}
|
||||
|
||||
// RuntimeLLM is the hot-swappable LLM provider stack for the runtime. It
|
||||
@@ -96,11 +101,12 @@ func (m *RuntimeLLM) Model() string { return m.load().group.Model }
|
||||
// Name implements Adapter, returning the active group id.
|
||||
func (m *RuntimeLLM) Name() string { return m.load().group.ID }
|
||||
|
||||
// Chat implements Adapter, delegating to the current chain. A request that began
|
||||
// before a reload completes on the snapshot it captured. When no model is
|
||||
// configured (nil chain) it returns ErrNoModel rather than panicking.
|
||||
// Chat implements Adapter, delegating to the chain the request's persona
|
||||
// resolves to. A request that began before a reload completes on the snapshot it
|
||||
// captured. When no model is configured (nil chain) it returns ErrNoModel rather
|
||||
// than panicking.
|
||||
func (m *RuntimeLLM) Chat(ctx context.Context, req Request, opts Options) (Response, error) {
|
||||
chain := m.load().chain
|
||||
chain := m.chainFor(req.PersonaID)
|
||||
if chain == nil {
|
||||
return Response{}, ErrNoModel
|
||||
}
|
||||
@@ -109,13 +115,39 @@ func (m *RuntimeLLM) Chat(ctx context.Context, req Request, opts Options) (Respo
|
||||
|
||||
// ChatStream implements Adapter.
|
||||
func (m *RuntimeLLM) ChatStream(ctx context.Context, req Request, onEvent func(StreamEvent)) (Response, error) {
|
||||
chain := m.load().chain
|
||||
chain := m.chainFor(req.PersonaID)
|
||||
if chain == nil {
|
||||
return Response{}, ErrNoModel
|
||||
}
|
||||
return chain.ChatStream(ctx, req, onEvent)
|
||||
}
|
||||
|
||||
// chainFor resolves the chain that serves this persona (裁决 03.4.1 的
|
||||
// per-persona provider). It consumes Router.RouteProviderForPersona — the
|
||||
// provider decision the router already made for this persona — and only switches
|
||||
// when BOTH hold:
|
||||
//
|
||||
// - the persona has its own model binding (an entry in personaProviders alone
|
||||
// would send the group's default model name to another vendor's endpoint),
|
||||
// - and a chain exists for that provider (a declared protocol with no matching
|
||||
// group in models.json is reported when the snapshot is built, and the
|
||||
// persona then keeps the default chain rather than failing the turn).
|
||||
//
|
||||
// Everything else — no persona, no binding, same protocol as the active group —
|
||||
// returns the default chain, so a deployment without per-persona providers
|
||||
// behaves exactly as before.
|
||||
func (m *RuntimeLLM) chainFor(persona string) *Chain {
|
||||
snap := m.load()
|
||||
if p := trimKey(persona); p != "" && snap.router != nil {
|
||||
if _, _, ok := snap.group.PersonaRoute(p); ok {
|
||||
if c := snap.providerChains[snap.router.RouteProviderForPersona(p)]; c != nil {
|
||||
return c
|
||||
}
|
||||
}
|
||||
}
|
||||
return snap.chain
|
||||
}
|
||||
|
||||
// RouteRequest implements ModelRouter, delegating to the current router.
|
||||
func (m *RuntimeLLM) RouteRequest(req Request) (string, bool) {
|
||||
r := m.load().router
|
||||
@@ -196,7 +228,99 @@ func buildSnapshot(groups []runtimeconfig.Group, env config.LLMConfig, log *slog
|
||||
chain = chain.WithTokenBudget(active.MaxTokens, env.FallbackMaxTokens)
|
||||
}
|
||||
router := NewRouter(DefaultModelForProvider(cfg), personaRouterOptions(active, cfg)...)
|
||||
return &runtimeChainSnapshot{chain: chain, router: router, group: active, groups: groups}, nil
|
||||
return &runtimeChainSnapshot{
|
||||
chain: chain,
|
||||
router: router,
|
||||
group: active,
|
||||
groups: groups,
|
||||
providerChains: personaProviderChains(groups, active, env, log),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// personaProviderChains builds one chain per protocol that the active group's
|
||||
// personaProviders declares and that models.json actually provides a group for
|
||||
// (裁决 03.4.1 的 per-persona provider:跨协议换 adapter). The persona's own
|
||||
// model name is pinned as the chain's primary model, so a caller that stamps the
|
||||
// persona without a routed model name still reaches the right endpoint.
|
||||
//
|
||||
// A persona that names the same protocol as the active group needs no chain of
|
||||
// its own: the default chain already speaks that protocol, and the per-persona
|
||||
// model name reaches it through the router. A personaProviders entry whose
|
||||
// protocol has no group is reported and skipped — a half-built chain could only
|
||||
// fail, and the persona keeps the default chain instead.
|
||||
func personaProviderChains(groups []runtimeconfig.Group, active runtimeconfig.Group, env config.LLMConfig, log *slog.Logger) map[string]*Chain {
|
||||
if len(active.PersonaProviders) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]*Chain, len(active.PersonaProviders))
|
||||
for persona := range active.PersonaProviders {
|
||||
model, provider, ok := active.PersonaRoute(persona)
|
||||
if !ok {
|
||||
warn(log, "personaProviders entry ignored: the persona binds no model of its own",
|
||||
"persona", persona, "field", "personaModels")
|
||||
continue
|
||||
}
|
||||
if provider == active.Provider {
|
||||
continue
|
||||
}
|
||||
if _, built := out[provider]; built {
|
||||
continue
|
||||
}
|
||||
g, found := groupForProvider(groups, provider)
|
||||
if !found {
|
||||
warn(log, "per-persona provider skipped: no models.json group speaks it",
|
||||
"persona", persona, "provider", provider)
|
||||
continue
|
||||
}
|
||||
chain := personaChain(g, model, env, log)
|
||||
if chain != nil {
|
||||
out[provider] = chain
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// personaChain builds and budgets the chain serving one persona through another
|
||||
// vendor's group: the group's flat config with the persona's own model name as
|
||||
// the primary model, plus the shared local last rung. The reasoning shape
|
||||
// (reasoning.kind) travels with the group's config, so a persona routed to
|
||||
// another vendor keeps that vendor's switch shape. It reports the reason and
|
||||
// returns nil when the group cannot serve a chain.
|
||||
func personaChain(g runtimeconfig.Group, model string, env config.LLMConfig, log *slog.Logger) *Chain {
|
||||
cfg := g.ToLLMConfig()
|
||||
if cfg.Provider == ProviderAnthropic {
|
||||
cfg.AnthropicModel = model
|
||||
} else {
|
||||
cfg.Model = model
|
||||
}
|
||||
cfg.Local = env.Local
|
||||
chain, err := NewChatChainWithLog(cfg, log)
|
||||
if err != nil && !errors.Is(err, ErrNoModel) {
|
||||
warn(log, "per-persona chain skipped", "group", g.ID, "model", model, "error", err)
|
||||
return nil
|
||||
}
|
||||
if chain == nil {
|
||||
warn(log, "per-persona chain skipped: the provider group has no base url or model",
|
||||
"group", g.ID, "model", model)
|
||||
return nil
|
||||
}
|
||||
return chain.WithTokenBudget(g.MaxTokens, env.FallbackMaxTokens)
|
||||
}
|
||||
|
||||
// groupForProvider returns the first group speaking the given protocol. The group
|
||||
// order in models.json is the operator's choice, so "first match" is a stable,
|
||||
// documentable rule rather than a silent pick — a duplicate protocol is reported
|
||||
// by the builder when it matters.
|
||||
func groupForProvider(groups []runtimeconfig.Group, provider string) (runtimeconfig.Group, bool) {
|
||||
for _, g := range groups {
|
||||
if g.Provider == provider {
|
||||
return g, true
|
||||
}
|
||||
}
|
||||
return runtimeconfig.Group{}, false
|
||||
}
|
||||
|
||||
// personaRouterOptions turns the active group's per-persona model/provider
|
||||
|
||||
Reference in new issue
Block a user