package model import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "path/filepath" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" ) // newFallbackDispatchServer 由一个端点服务两个链步骤: // "main" 模型失败以便链继续前进,其它所有模型成功。 func newFallbackDispatchServer(t *testing.T, ch chan<- capturedRequest) *httptest.Server { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body map[string]any _ = json.NewDecoder(r.Body).Decode(&body) ch <- capturedRequest{method: r.Method, path: r.URL.Path, body: body} if body["model"] == "main" { w.WriteHeader(http.StatusInternalServerError) _, _ = io.WriteString(w, `{"error":"down"}`) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = io.WriteString(w, okCompletion) })) t.Cleanup(srv.Close) return srv } func chatOnce(t *testing.T, chain *Chain) { t.Helper() if _, err := chain.Chat(context.Background(), Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{}); err != nil { t.Fatalf("Chain.Chat error: %v", err) } } // TestChain_Chat_GroupBudgetAppliesToPrimary 断言生效组自己的 // maxTokens 会作用于未携带显式预算的请求。 func TestChain_Chat_GroupBudgetAppliesToPrimary(t *testing.T) { t.Parallel() ch := make(chan capturedRequest, 1) srv := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, ch) chain := NewChain(Step{Model: "m", Adapter: NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: "m"})}). WithTokenBudget(4096, 8192) chatOnce(t, chain) if got := (<-ch).body["max_tokens"]; got != float64(4096) { t.Errorf("body.max_tokens = %v, want the group budget 4096", got) } } // TestChain_Chat_ExplicitRequestWinsOverGroupBudget 断言显式的 // Request.MaxTokens 绝不会被覆盖(用于小额的评审/裁判预算)。 func TestChain_Chat_ExplicitRequestWinsOverGroupBudget(t *testing.T) { t.Parallel() ch := make(chan capturedRequest, 1) srv := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, ch) chain := NewChain(Step{Model: "m", Adapter: NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: "m"})}). WithTokenBudget(4096, 8192) if _, err := chain.Chat(context.Background(), Request{Messages: []Message{TextMessage(RoleUser, "hi")}, MaxTokens: 64}, Options{}); err != nil { t.Fatalf("Chain.Chat error: %v", err) } if got := (<-ch).body["max_tokens"]; got != float64(64) { t.Errorf("body.max_tokens = %v, want the explicit 64", got) } } // TestChain_Chat_FallbackEndAppliesEnvBudget 断言兜底预算只 // 到达最后的降级步骤,绝不到达主步骤。 func TestChain_Chat_FallbackEndAppliesEnvBudget(t *testing.T) { t.Parallel() primaryCh := make(chan capturedRequest, 1) primary := newCompletionServer(t, http.StatusInternalServerError, "application/json", `{"error":"down"}`, primaryCh) backupCh := make(chan capturedRequest, 1) backup := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, backupCh) chain := NewChain( Step{Model: "main", Adapter: NewOpenAIChat(config.LLMConfig{BaseURL: primary.URL, Model: "main"})}, Step{Model: "backup", Adapter: NewOpenAIChat(config.LLMConfig{BaseURL: backup.URL, Model: "backup"})}, ).WithTokenBudget(0, 3000) chatOnce(t, chain) if got := (<-primaryCh).body["max_tokens"]; got != nil { t.Errorf("primary body.max_tokens = %v, want omitted (no budget on the primary)", got) } if got := (<-backupCh).body["max_tokens"]; got != float64(3000) { t.Errorf("fallback body.max_tokens = %v, want the env fallback budget 3000", got) } } // TestChain_Chat_GroupBudgetWinsOverFallbackEnv 断言组的 maxTokens 胜过 // 兜底 env 预算,即便在最后的降级步骤上。 func TestChain_Chat_GroupBudgetWinsOverFallbackEnv(t *testing.T) { t.Parallel() primaryCh := make(chan capturedRequest, 1) primary := newCompletionServer(t, http.StatusInternalServerError, "application/json", `{"error":"down"}`, primaryCh) backupCh := make(chan capturedRequest, 1) backup := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, backupCh) chain := NewChain( Step{Model: "main", Adapter: NewOpenAIChat(config.LLMConfig{BaseURL: primary.URL, Model: "main"})}, Step{Model: "backup", Adapter: NewOpenAIChat(config.LLMConfig{BaseURL: backup.URL, Model: "backup"})}, ).WithTokenBudget(2000, 999) chatOnce(t, chain) if got := (<-backupCh).body["max_tokens"]; got != float64(2000) { t.Errorf("fallback body.max_tokens = %v, want the group budget 2000", got) } } // TestChain_Chat_SingleStepIgnoresFallbackEnv 断言没有降级 // 模型(单步 = 主模型)的链绝不会拿到兜底预算,因此 // 适配器默认值得以保留。 func TestChain_Chat_SingleStepIgnoresFallbackEnv(t *testing.T) { t.Parallel() ch := make(chan capturedRequest, 1) srv := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, ch) chain := NewChain(Step{Model: "m", Adapter: NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: "m"})}). WithTokenBudget(0, 3000) chatOnce(t, chain) if got := (<-ch).body["max_tokens"]; got != nil { t.Errorf("body.max_tokens = %v, want omitted (single-step chain is not a fallback)", got) } } // TestRuntimeLLM_GroupMaxTokensEndToEnd 证明 models.json 的 maxTokens 字段 // 经真实运行时栈到达提供商请求体。 func TestRuntimeLLM_GroupMaxTokensEndToEnd(t *testing.T) { ch := make(chan capturedRequest, 1) srv := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, ch) path := filepath.Join(t.TempDir(), "models.json") if err := runtimeconfig.SaveModels(path, []runtimeconfig.Group{ {ID: "openai", Provider: ProviderOpenAI, BaseURL: srv.URL, APIKey: "sk-1", Model: "m", MaxTokens: 1234}, }); err != nil { t.Fatalf("SaveModels: %v", err) } m := NewRuntimeLLM(path, rtEnv(), nil) if got := m.Groups()[0].MaxTokens; got != 1234 { t.Fatalf("Groups()[0].MaxTokens = %d, want 1234", got) } if _, err := m.Chat(context.Background(), Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{}); err != nil { t.Fatalf("RuntimeLLM.Chat error: %v", err) } if got := (<-ch).body["max_tokens"]; got != float64(1234) { t.Errorf("body.max_tokens = %v, want the group budget 1234", got) } } // TestRuntimeLLM_FallbackEndAppliesEnvBudget 证明 LLM_FALLBACK_MAX_TOKENS // 由配置经 buildSnapshot 接线到最后的降级步骤。 func TestRuntimeLLM_FallbackEndAppliesEnvBudget(t *testing.T) { ch := make(chan capturedRequest, 2) srv := newFallbackDispatchServer(t, ch) path := filepath.Join(t.TempDir(), "models.json") if err := runtimeconfig.SaveModels(path, []runtimeconfig.Group{ {ID: "openai", Provider: ProviderOpenAI, BaseURL: srv.URL, APIKey: "sk-1", Model: "main", Fallback: "backup"}, }); err != nil { t.Fatalf("SaveModels: %v", err) } env := rtEnv() env.FallbackMaxTokens = 2500 m := NewRuntimeLLM(path, env, nil) if _, err := m.Chat(context.Background(), Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{}); err != nil { t.Fatalf("RuntimeLLM.Chat error: %v", err) } primary := <-ch backup := <-ch if got := primary.body["max_tokens"]; got != nil { t.Errorf("primary body.max_tokens = %v, want omitted", got) } if got := backup.body["max_tokens"]; got != float64(2500) { t.Errorf("fallback body.max_tokens = %v, want LLM_FALLBACK_MAX_TOKENS 2500", got) } }