- 只翻注释/文档字符串:`go/scanner` 剥注释后按 token 序列+literal 逐 token 比对,**54/54 文件 SAME**;
改动文件总行数与 HEAD 漂移 = 0(严格 1:1,无合并/拆行)
- 裁决编号与 design 节号逐字保留(03.4.1/03.4.2/03.4.3/14.4.1/14.4.5/07.4.2-4、design/04 §3.1/§3.2 等);
`chat_template_kwargs` / `enable_thinking` / `reasoning_effort` / `thinking:{type}` 字段名与形状说明未动
- 存疑译法(已登记):`persona` 在叙事语境用「角色」、标识符语境保留 `persona`(仓库既有模块本身混用);
`rung`→「级」;`provider` 存在「提供商/提供者」两种落笔(可按需一键统一)
- 复核:build/vet 绿 + 两包测试绿;gofmt 干净;残留英文注释 0 条
- 既有存量(未修,只报告):`model/anthropic_test.go` 612 行 >500;`applyAnthropicEvent` 115 行 >80
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 也钉住「开启」方向,
|
|
// 以免开关被一次重构硬接成关闭。
|
|
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 断言「仅 effort」
|
|
// 形状不会静默吞掉布尔 thinking 开关:它会报告该情况,并
|
|
// 指出文档记录在案的关闭推理的做法。
|
|
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 断言本次构建
|
|
// 未实现的形状会被上报、且什么都不发——即便模型名
|
|
// 看上去像我们确实理解的厂商也一样。在这里靠猜正是
|
|
// 裁决所禁止的「假适配」。
|
|
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 钉住未声明的默认:
|
|
// Qwen 端点可能是 vLLM 或 DashScope,两者字段形状不同,所以 auto 模式
|
|
// 什么都不发,并改为要求显式声明,而不是替它猜一个。
|
|
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)
|
|
}
|
|
}
|
|
}
|