- 新增 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 口径冲突,停手报告)
346 lines
13 KiB
Go
346 lines
13 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|