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
@@ -55,8 +55,11 @@ func (a *app) buildPersonaAgents(llm *model.RuntimeLLM, dispatcher *tool.Dispatc
|
||||
if !ok || p == nil {
|
||||
continue
|
||||
}
|
||||
// 该角色的 agent 必须绑它自己的 persona 视图:否则 models.json 的 personaModels /
|
||||
// personaProviders(角色级模型/提供商路由)在生产里不会被消费,形同虚设。
|
||||
stack := llm.ForPersona(c.PersonaID)
|
||||
ag, err := agent.New(instructionsFromPersona(p), dispatcher, guards,
|
||||
agent.WithModel(llm, llm),
|
||||
agent.WithModel(stack, stack),
|
||||
agent.WithOptions(agent.Options{MaxRounds: defaultMaxRounds, Purpose: "chat"}))
|
||||
if err != nil {
|
||||
log.Warn("persona agent skipped", "persona", c.PersonaID, "error", err)
|
||||
|
||||
@@ -150,14 +150,18 @@ func (a *app) wireRuntime() error {
|
||||
|
||||
guards := guardrail.New()
|
||||
instructions := a.defaultInstructions()
|
||||
// 默认角色的 agent 也要绑它自己的 persona 视图:models.json 的 personaModels /
|
||||
// personaProviders 可能给默认角色单独指定模型或提供商,用裸栈会漏掉这一层
|
||||
// (persona 为空时该视图与裸栈等价,行为不变)。
|
||||
personaID := a.defaultPersonaID()
|
||||
defaultStack := runtimeStack.ForPersona(personaID)
|
||||
ag, aerr := agent.New(instructions, a.dispatcher, guards,
|
||||
agent.WithModel(runtimeStack, runtimeStack),
|
||||
agent.WithModel(defaultStack, defaultStack),
|
||||
agent.WithOptions(agent.Options{MaxRounds: defaultMaxRounds, Purpose: "chat"}))
|
||||
if aerr != nil {
|
||||
return fmt.Errorf("agent: %w", aerr)
|
||||
}
|
||||
|
||||
personaID := a.defaultPersonaID()
|
||||
store := a.memStore
|
||||
// One agent per role, all sharing the dispatcher/guards/adapter above, so a session
|
||||
// can answer as any role (design/22 §4.2 方案 A); the loop picks per turn.
|
||||
|
||||
@@ -112,6 +112,16 @@ type LLMConfig struct {
|
||||
// budget. Zero disables the cap (the adapter default then applies).
|
||||
FallbackMaxTokens int `env:"LLM_FALLBACK_MAX_TOKENS" default:"8192"`
|
||||
|
||||
// ReasoningKind declares this OpenAI-compatible endpoint's reasoning-switch
|
||||
// wire shape (裁决 03.4.2/08.4.1: 不同提供商的开关形状不同,必须逐 provider
|
||||
// 独立适配). Values mirror runtimeconfig.ReasoningKind*: "" or "auto" infers
|
||||
// the vendor from the model name/base URL and sends nothing when the shape is
|
||||
// ambiguous (the conservative default, so an existing .env is unchanged);
|
||||
// "deepseek" / "openai" / "vllm" / "dashscope" select that endpoint's shape
|
||||
// explicitly. It is the env-fallback twin of the models.json group's
|
||||
// `reasoning.kind`, which is the primary carrier (hot-reloadable, panel-edited).
|
||||
ReasoningKind string `env:"LLM_REASONING_KIND" default:""`
|
||||
|
||||
// Anthropic native provider, used when Provider=anthropic. Independent
|
||||
// config block so each vendor is a self-contained config.
|
||||
AnthropicBaseURL string `env:"ANTHROPIC_BASE_URL"`
|
||||
|
||||
@@ -31,6 +31,40 @@ const (
|
||||
modelsFileName = "models.json"
|
||||
)
|
||||
|
||||
// ReasoningKind* 是 provider 级推理开关形状的合法取值(裁决 03.4.2 / 08.4.1:
|
||||
// 不同提供商的思考开关形状各不相同,必须逐 provider 独立适配)。这些常量是
|
||||
// 形状名的**唯一来源**:配置层(本包)校验它们,模型适配器按它们选 wire 字段,
|
||||
// 面板按它们渲染下拉框。
|
||||
//
|
||||
// 取值刻意用**部署形态**命名(vllm / dashscope)而不是厂商名:同一家厂商的
|
||||
// 模型在不同形态下的字段形状不同(vLLM 用 chat_template_kwargs.enable_thinking,
|
||||
// DashScope 兼容模式用顶层 enable_thinking),形状由端点决定,不是由模型名决定。
|
||||
const (
|
||||
// ReasoningKindAuto 是缺省值:按模型名/BaseURL 推断厂商,形状有歧义时
|
||||
// 不下发任何开关(只报告),因此它对旧配置零行为变化。
|
||||
ReasoningKindAuto = "auto"
|
||||
// ReasoningKindDeepSeek 是 DeepSeek:顶层 thinking:{type} + reasoning_effort。
|
||||
ReasoningKindDeepSeek = "deepseek"
|
||||
// ReasoningKindOpenAI 是 OpenAI 系:只有 reasoning_effort,无布尔开关。
|
||||
ReasoningKindOpenAI = "openai"
|
||||
// ReasoningKindVLLM 是 vLLM 类部署(承载 Qwen3 等):
|
||||
// chat_template_kwargs.enable_thinking。
|
||||
ReasoningKindVLLM = "vllm"
|
||||
// ReasoningKindDashScope 是 DashScope 兼容模式:顶层 enable_thinking。
|
||||
ReasoningKindDashScope = "dashscope"
|
||||
)
|
||||
|
||||
// ReasoningOptions 是一个 provider 组的推理开关形状声明。它是「按提供商
|
||||
// 独立适配」的配置载体(裁决 03.4.2 / 08.4.1):面板与 models.json 对每个
|
||||
// 供应商各写一份,因此 vLLM 端点与 DashScope 端点可以同时存在、各自下发
|
||||
// 自己形状的开关,而不是被套进一个「通用」形状。默认(零值)保守:不声明
|
||||
// 形状就不下发开关,只报告——发错形状会 400,比不发更糟。
|
||||
type ReasoningOptions struct {
|
||||
// Kind 显式声明该端点的思考开关形状(见 ReasoningKind* 常量)。
|
||||
// 空或 "auto" = 按模型名/BaseURL 推断厂商(形状有歧义时不下发)。
|
||||
Kind string `json:"kind,omitempty"`
|
||||
}
|
||||
|
||||
// Group is one independent multi-supplier LLM configuration. Each vendor's
|
||||
// base/key/model/fallback is a self-contained unit, so multiple vendors coexist
|
||||
// and Provider selects the active wire protocol.
|
||||
@@ -59,6 +93,13 @@ type Group struct {
|
||||
// (裁决 03.4.1 的 per-persona provider). Pair it with PersonaModels; an entry
|
||||
// without a provider keeps this group's protocol.
|
||||
PersonaProviders map[string]string `json:"personaProviders,omitempty"`
|
||||
// Reasoning declares this provider/endpoint's reasoning-switch wire shape
|
||||
// (裁决 03.4.2 / 08.4.1). It is a provider-level option because the shape
|
||||
// belongs to the endpoint, not to a persona: every group carries its own, so
|
||||
// two vendors with different switch shapes coexist without a shared guess.
|
||||
// omitzero keeps an undeclared shape out of models.json entirely (an empty
|
||||
// object would be schema noise for every group).
|
||||
Reasoning ReasoningOptions `json:"reasoning,omitzero"`
|
||||
}
|
||||
|
||||
// ErrInvalidGroup is returned when a group fails schema validation.
|
||||
@@ -93,6 +134,27 @@ func (g Group) Validate() error {
|
||||
if err := validatePersonaMap("personaProviders", g.PersonaProviders, true); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateReasoning(g.Provider, g.Reasoning)
|
||||
}
|
||||
|
||||
// validateReasoning checks a provider-level reasoning shape declaration. A blank
|
||||
// or "auto" kind is always valid (the conservative default: infer from the model
|
||||
// name/base URL, and send nothing when the shape is ambiguous). Any other kind
|
||||
// must be a shape this build implements and has evidence for; and the
|
||||
// openai-compatible shape names are rejected on an anthropic group, whose switch
|
||||
// shape is defined by its own protocol — failing loudly beats a silently inert
|
||||
// option.
|
||||
func validateReasoning(provider string, r ReasoningOptions) error {
|
||||
kind := strings.TrimSpace(r.Kind)
|
||||
switch kind {
|
||||
case "", ReasoningKindAuto, ReasoningKindDeepSeek, ReasoningKindOpenAI,
|
||||
ReasoningKindVLLM, ReasoningKindDashScope:
|
||||
default:
|
||||
return fmt.Errorf("reasoning.kind %q must be one of auto/deepseek/openai/vllm/dashscope", kind)
|
||||
}
|
||||
if provider == ProviderAnthropic && kind != "" && kind != ReasoningKindAuto {
|
||||
return fmt.Errorf("reasoning.kind %q applies to the openai-compatible provider only", kind)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -140,14 +202,16 @@ func (g Group) ToLLMConfig() config.LLMConfig {
|
||||
AnthropicAPIKey: g.APIKey,
|
||||
AnthropicModel: g.Model,
|
||||
AnthropicFallback: g.Fallback,
|
||||
ReasoningKind: strings.TrimSpace(g.Reasoning.Kind),
|
||||
}
|
||||
}
|
||||
return config.LLMConfig{
|
||||
Provider: ProviderOpenAI,
|
||||
BaseURL: g.BaseURL,
|
||||
APIKey: g.APIKey,
|
||||
Model: g.Model,
|
||||
Fallback: g.Fallback,
|
||||
Provider: ProviderOpenAI,
|
||||
BaseURL: g.BaseURL,
|
||||
APIKey: g.APIKey,
|
||||
Model: g.Model,
|
||||
Fallback: g.Fallback,
|
||||
ReasoningKind: strings.TrimSpace(g.Reasoning.Kind),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,24 +285,30 @@ func loadFile(path string) ([]Group, error) {
|
||||
// group's `multimodal` flag, which the provider panel writes (design/06 §0 首条;
|
||||
// 裁决 14.4.1). LLM_VISION_MODEL configures the vision *tool's* model, not the
|
||||
// main model's ability.
|
||||
//
|
||||
// Reasoning.Kind is carried through so an env-only deployment can still declare
|
||||
// its endpoint's switch shape (LLM_REASONING_KIND); its env default is blank,
|
||||
// which keeps every existing deployment at "auto" (no new field is ever sent).
|
||||
func GroupFromEnv(env config.LLMConfig) []Group {
|
||||
if env.Provider == ProviderAnthropic {
|
||||
return []Group{{
|
||||
ID: ProviderAnthropic,
|
||||
Provider: ProviderAnthropic,
|
||||
BaseURL: env.AnthropicBaseURL,
|
||||
APIKey: env.AnthropicAPIKey,
|
||||
Model: env.AnthropicModel,
|
||||
Fallback: env.AnthropicFallback,
|
||||
ID: ProviderAnthropic,
|
||||
Provider: ProviderAnthropic,
|
||||
BaseURL: env.AnthropicBaseURL,
|
||||
APIKey: env.AnthropicAPIKey,
|
||||
Model: env.AnthropicModel,
|
||||
Fallback: env.AnthropicFallback,
|
||||
Reasoning: ReasoningOptions{Kind: strings.TrimSpace(env.ReasoningKind)},
|
||||
}}
|
||||
}
|
||||
return []Group{{
|
||||
ID: ProviderOpenAI,
|
||||
Provider: ProviderOpenAI,
|
||||
BaseURL: env.BaseURL,
|
||||
APIKey: env.APIKey,
|
||||
Model: env.Model,
|
||||
Fallback: env.Fallback,
|
||||
ID: ProviderOpenAI,
|
||||
Provider: ProviderOpenAI,
|
||||
BaseURL: env.BaseURL,
|
||||
APIKey: env.APIKey,
|
||||
Model: env.Model,
|
||||
Fallback: env.Fallback,
|
||||
Reasoning: ReasoningOptions{Kind: strings.TrimSpace(env.ReasoningKind)},
|
||||
}}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
)
|
||||
|
||||
// 本文件钉住 provider 级推理开关形状的配置面(裁决 03.4.2 / 08.4.1):每个
|
||||
// provider 组各声明自己的形状,只有本版本实现且有依据的形状才被接受,未声明的
|
||||
// 组在 models.json 里不留下空对象(保守默认 = 不发任何推理字段)。
|
||||
|
||||
// TestValidateReasoningKind 覆盖合法/非法形状:未知形状必须让保存失败(否则
|
||||
// 「配了却不生效」正是裁决要消灭的假适配),anthropic 组不允许声明 OpenAI 侧形状。
|
||||
func TestValidateReasoningKind(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
provider string
|
||||
kind string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "unset", provider: ProviderOpenAI},
|
||||
{name: "auto", provider: ProviderOpenAI, kind: ReasoningKindAuto},
|
||||
{name: "deepseek", provider: ProviderOpenAI, kind: ReasoningKindDeepSeek},
|
||||
{name: "openai", provider: ProviderOpenAI, kind: ReasoningKindOpenAI},
|
||||
{name: "vllm", provider: ProviderOpenAI, kind: ReasoningKindVLLM},
|
||||
{name: "dashscope", provider: ProviderOpenAI, kind: ReasoningKindDashScope},
|
||||
{name: "padded", provider: ProviderOpenAI, kind: " vllm "},
|
||||
{name: "auto_on_anthropic", provider: ProviderAnthropic, kind: ReasoningKindAuto},
|
||||
{name: "unknown", provider: ProviderOpenAI, kind: "gemini", wantErr: "must be one of"},
|
||||
{name: "openai_shape_on_anthropic", provider: ProviderAnthropic, kind: ReasoningKindVLLM,
|
||||
wantErr: "openai-compatible"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
g := Group{
|
||||
ID: "g", Provider: tc.provider, BaseURL: "https://x/v1", Model: "m",
|
||||
Reasoning: ReasoningOptions{Kind: tc.kind},
|
||||
}
|
||||
err := g.Validate()
|
||||
if tc.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() = %v, want nil for kind %q", err, tc.kind)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("Validate() = %v, want an error containing %q", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestReasoningKindJSONShape asserts an undeclared shape is absent from models.json
|
||||
// (no empty object churn on every group) and a declared one round-trips by name.
|
||||
func TestReasoningKindJSONShape(t *testing.T) {
|
||||
t.Parallel()
|
||||
plain := Group{ID: "g", Provider: ProviderOpenAI, BaseURL: "https://x/v1", Model: "m"}
|
||||
raw, err := json.Marshal(plain)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal: %v", err)
|
||||
}
|
||||
if strings.Contains(string(raw), "reasoning") {
|
||||
t.Fatalf("marshal = %s, want no reasoning key for an undeclared shape", raw)
|
||||
}
|
||||
plain.Reasoning.Kind = ReasoningKindDashScope
|
||||
raw, err = json.Marshal(plain)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"reasoning":{"kind":"dashscope"}`) {
|
||||
t.Fatalf("marshal = %s, want the declared shape serialized", raw)
|
||||
}
|
||||
|
||||
path := t.TempDir() + "/models.json"
|
||||
if err := SaveModels(path, []Group{plain}); err != nil {
|
||||
t.Fatalf("SaveModels: %v", err)
|
||||
}
|
||||
groups, err := LoadModels(path, config.LLMConfig{})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadModels: %v", err)
|
||||
}
|
||||
if got := groups[0].Reasoning.Kind; got != ReasoningKindDashScope {
|
||||
t.Fatalf("round-tripped kind = %q, want %q", got, ReasoningKindDashScope)
|
||||
}
|
||||
}
|
||||
|
||||
// TestToLLMConfigCarriesReasoningKind 断言形状随组配置抵达适配器那一层(否则
|
||||
// models.json 的声明会静默失效)。
|
||||
func TestToLLMConfigCarriesReasoningKind(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, provider := range []string{ProviderOpenAI, ProviderAnthropic} {
|
||||
g := Group{
|
||||
ID: "g", Provider: provider, BaseURL: "https://x", APIKey: "k", Model: "m",
|
||||
Reasoning: ReasoningOptions{Kind: " " + ReasoningKindDeepSeek + " "},
|
||||
}
|
||||
if got := g.ToLLMConfig().ReasoningKind; got != ReasoningKindDeepSeek {
|
||||
t.Errorf("provider %s: ReasoningKind = %q, want the trimmed declared shape", provider, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGroupFromEnvCarriesReasoningKind pins the env fallback twin: an env-only
|
||||
// deployment can declare its endpoint's shape, and its default stays blank so no
|
||||
// reasoning field is ever sent without an explicit declaration.
|
||||
func TestGroupFromEnvCarriesReasoningKind(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := GroupFromEnv(config.LLMConfig{})[0].Reasoning.Kind; got != "" {
|
||||
t.Fatalf("env default kind = %q, want empty (conservative)", got)
|
||||
}
|
||||
env := config.LLMConfig{BaseURL: "https://x/v1", APIKey: "k", Model: "m", ReasoningKind: " vllm "}
|
||||
groups := GroupFromEnv(env)
|
||||
if len(groups) != 1 {
|
||||
t.Fatalf("groups = %d, want 1", len(groups))
|
||||
}
|
||||
if got := groups[0].Reasoning.Kind; got != ReasoningKindVLLM {
|
||||
t.Fatalf("env kind = %q, want %q", got, ReasoningKindVLLM)
|
||||
}
|
||||
if err := groups[0].Validate(); err != nil {
|
||||
t.Fatalf("a fully configured env group must validate: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,17 @@ const defaultPurpose = "agent"
|
||||
// configured model/temperature/max-tokens/purpose and the registered tool
|
||||
// schemas. When a router is injected it overrides the model from the request
|
||||
// contents, which is how vision requests are routed to the vision model.
|
||||
//
|
||||
// 本路径与流式主路径(loop.buildRequest)共用同一处 persona 钩子(裁决 03.4.1):
|
||||
// 角色由**适配器视图**携带(model.PersonaScoped,见 model/persona_stack.go),
|
||||
// 写成请求上的 PersonaID,模型路由也按它走。为什么角色不在本函数参数里:RunStep
|
||||
// 由 loop 与 automations 调用,角色只存在于 loop 的会话环境 ctx 里,而 agent
|
||||
// 不能反向 import loop;按角色分开注入的适配器视图(一角色一 agent,
|
||||
// design/22 §4.2 方案 A)是唯一不依赖 ctx 的载体。
|
||||
//
|
||||
// 角色**已知**时才切到 RouteRequestForPersona:未知角色(普通 *model.Router 或
|
||||
// 未绑角色的视图)保持原来的 RouteRequest —— 后者带 vision 模型的改写规则,
|
||||
// RouteRequestForPersona 刻意不含(图像路由已归 vision.Router,裁决 14.4.1/14.4.5)。
|
||||
func (a *Agent) buildRequest(messages []model.Message) model.Request {
|
||||
req := model.Request{
|
||||
Model: a.opts.Model,
|
||||
@@ -19,7 +30,16 @@ func (a *Agent) buildRequest(messages []model.Message) model.Request {
|
||||
Purpose: a.purpose(),
|
||||
Tools: a.toolSchemas(),
|
||||
}
|
||||
if ps, ok := a.adapter.(model.PersonaScoped); ok {
|
||||
req.PersonaID = ps.ScopedPersona()
|
||||
}
|
||||
if a.router != nil {
|
||||
if pr, ok := a.router.(model.PersonaRouter); ok && req.PersonaID != "" {
|
||||
if routeModel, _ := pr.RouteRequestForPersona(req.PersonaID, req); routeModel != "" {
|
||||
req.Model = routeModel
|
||||
}
|
||||
return req
|
||||
}
|
||||
if routeModel, _ := a.router.RouteRequest(req); routeModel != "" {
|
||||
req.Model = routeModel
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// 本文件钉住非流式路径(agent.RunStep)的 persona 钩子(裁决 03.4.1):它必须与
|
||||
// 流式主路径(loop.buildRequest 的 RouteRequestForPersona)同源。角色由适配器视图
|
||||
// 携带(model.PersonaScoped),因为它不经 ctx 也能到达这里,且一角色一 agent 的装配
|
||||
// 方式(design/22 §4.2 方案 A)本来就把它定在装配处。
|
||||
|
||||
// personaScopedAdapter 是带角色的 mockAdapter 视图,模拟 model.PersonaStack:
|
||||
// RunStep 应据此把角色写进请求,并按角色路由模型名。
|
||||
type personaScopedAdapter struct {
|
||||
mockAdapter
|
||||
persona string
|
||||
}
|
||||
|
||||
// ScopedPersona 实现 model.PersonaScoped。
|
||||
func (p *personaScopedAdapter) ScopedPersona() string { return p.persona }
|
||||
|
||||
// TestRunStep_PersonaScopedAdapterRoutesByPersona 断言:带角色的适配器视图让非流式
|
||||
// 路径按 persona 路由(而不是按消息 role),并把角色带到请求上,供模型层选
|
||||
// persona 的协议链(models.json personaProviders)。
|
||||
func TestRunStep_PersonaScopedAdapterRoutesByPersona(t *testing.T) {
|
||||
t.Parallel()
|
||||
router := model.NewRouter("default",
|
||||
model.WithRoleModel(model.RoleUser, "user-model"),
|
||||
model.WithPersonaModel("cyrene", "persona-model"),
|
||||
)
|
||||
ad := &personaScopedAdapter{
|
||||
mockAdapter: mockAdapter{resp: model.Response{Model: "persona-model", Content: "ok"}},
|
||||
persona: "cyrene",
|
||||
}
|
||||
a := newTestAgent(t, ad, nil, WithModel(ad, router))
|
||||
|
||||
if _, err := a.RunStep(context.Background(), "s",
|
||||
[]model.Message{model.TextMessage(model.RoleUser, "hi")}); err != nil {
|
||||
t.Fatalf("RunStep: %v", err)
|
||||
}
|
||||
if ad.gotReq.PersonaID != "cyrene" {
|
||||
t.Fatalf("req.PersonaID = %q, want cyrene", ad.gotReq.PersonaID)
|
||||
}
|
||||
if ad.gotReq.Model != "persona-model" {
|
||||
t.Fatalf("req.Model = %q, want persona-model (persona binding wins over the message role)",
|
||||
ad.gotReq.Model)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunStep_PersonaStampedWithoutRouter asserts the persona travels with the
|
||||
// adapter view even when no router is injected — the carrier must not depend on
|
||||
// the routing decision, or an adapter-only wiring would silently lose it.
|
||||
func TestRunStep_PersonaStampedWithoutRouter(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := &personaScopedAdapter{
|
||||
mockAdapter: mockAdapter{resp: model.Response{Content: "ok"}},
|
||||
persona: "cyrene",
|
||||
}
|
||||
a := newTestAgent(t, ad, nil, WithModel(ad, nil))
|
||||
|
||||
if _, err := a.RunStep(context.Background(), "s",
|
||||
[]model.Message{model.TextMessage(model.RoleUser, "hi")}); err != nil {
|
||||
t.Fatalf("RunStep: %v", err)
|
||||
}
|
||||
if ad.gotReq.PersonaID != "cyrene" {
|
||||
t.Fatalf("req.PersonaID = %q, want cyrene", ad.gotReq.PersonaID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunStep_UnknownPersonaKeepsLegacyRouting 断言未绑定角色的 agent 行为不变:
|
||||
// 没有角色就继续走原来的 RouteRequest,其中包含「有图像 → 换视觉模型」这条老规则
|
||||
// (RouteRequestForPersona 故意不含它,图像路由已归 vision.Router)。这条断言防止
|
||||
// 把两代路由语义混在一起。
|
||||
func TestRunStep_UnknownPersonaKeepsLegacyRouting(t *testing.T) {
|
||||
t.Parallel()
|
||||
router := model.NewRouter("default",
|
||||
model.WithRoleModel(model.RoleUser, "user-model"),
|
||||
model.WithVisionModel("vision-model"),
|
||||
)
|
||||
ad := &mockAdapter{resp: model.Response{Model: "vision-model", Content: "ok"}}
|
||||
a := newTestAgent(t, ad, nil, WithModel(ad, router))
|
||||
|
||||
msg := model.ImageMessage(model.RoleUser, "what is this", "data:image/png;base64,abc")
|
||||
if _, err := a.RunStep(context.Background(), "s", []model.Message{msg}); err != nil {
|
||||
t.Fatalf("RunStep: %v", err)
|
||||
}
|
||||
if ad.gotReq.PersonaID != "" {
|
||||
t.Fatalf("req.PersonaID = %q, want empty for an unbound agent", ad.gotReq.PersonaID)
|
||||
}
|
||||
if ad.gotReq.Model != "vision-model" {
|
||||
t.Fatalf("req.Model = %q, want vision-model (the legacy image routing must survive)",
|
||||
ad.gotReq.Model)
|
||||
}
|
||||
}
|
||||
@@ -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