Files
AskaEth 53002f1e11
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(model): LLM token 预算可配置化 — models.json每模型maxTokens + LLM_FALLBACK_MAX_TOKENS回退兜底
- config.go: LLMConfig.FallbackMaxTokens(LLM_FALLBACK_MAX_TOKENS, 默认8192)
- runtimeconfig/models.go: Group.MaxTokens(models.json每模型独立预算, 负值拒绝)
- model/fallback.go: Chain.WithTokenBudget + applyBudget; runtime.go buildSnapshot接入(active.MaxTokens, env.FallbackMaxTokens)
- 优先级: 显式Request.MaxTokens > 组maxTokens > 仅多步链末端的LLM_FALLBACK_MAX_TOKENS > 适配器默认8192; 单步链不动env兜底(避免OpenAI主模型行为回归)
- gateway admin: AdminLlmProvider.MaxTokens 透传/回显/负值400
- .env.example + .env(LLM_FALLBACK_MAX_TOKENS=8192); Docs/api-client v0.27 + api-reference + design/04
- 新增测试(config/runtimeconfig/budget_test/admin_write), build/vet/test绿+3平台交叉编译
2026-09-08 22:42:27 +08:00

229 lines
8.1 KiB
Go

package runtimeconfig
import (
"os"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
func envCfg() config.LLMConfig {
return config.LLMConfig{
Provider: ProviderOpenAI,
BaseURL: "https://api.openai.com/v1",
APIKey: "sk-env",
Model: "gpt-4o",
Fallback: "gpt-4o-mini",
}
}
func writeModelsFile(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "models.json")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("write models.json: %v", err)
}
return path
}
func TestLoadModels_FileValid(t *testing.T) {
path := writeModelsFile(t, `[
{"id":"openai","provider":"openai","baseUrl":"https://api.openai.com/v1","apiKey":"sk-1","model":"gpt-4o","fallback":"gpt-4o-mini","multimodal":true}
]`)
groups, warn := LoadModels(path, envCfg())
if warn != nil {
t.Fatalf("warn = %v, want nil", warn)
}
if len(groups) != 1 {
t.Fatalf("len(groups) = %d, want 1", len(groups))
}
g := groups[0]
if g.ID != "openai" || g.Provider != ProviderOpenAI || g.Model != "gpt-4o" || !g.Multimodal {
t.Fatalf("group = %+v, want openai/gpt-4o/multimodal", g)
}
}
func TestLoadModels_MissingFileFallsBackEnv(t *testing.T) {
path := filepath.Join(t.TempDir(), "nonexistent.json")
groups, warn := LoadModels(path, envCfg())
if warn != nil {
t.Fatalf("warn = %v, want nil (missing file is the normal case)", warn)
}
if len(groups) != 1 || groups[0].Model != "gpt-4o" || groups[0].APIKey != "sk-env" {
t.Fatalf("groups = %+v, want env fallback gpt-4o/sk-env", groups)
}
}
func TestLoadModels_InvalidFallsBackEnvWithWarn(t *testing.T) {
path := writeModelsFile(t, `{ not json`)
groups, warn := LoadModels(path, envCfg())
if warn == nil {
t.Fatal("warn = nil, want a warning for an invalid file")
}
if len(groups) != 1 || groups[0].APIKey != "sk-env" {
t.Fatalf("groups = %+v, want env fallback", groups)
}
}
func TestLoadModels_EmptyArrayFallsBackEnv(t *testing.T) {
path := writeModelsFile(t, `[]`)
groups, warn := LoadModels(path, envCfg())
if warn == nil {
t.Fatal("warn = nil, want a warning for an empty file")
}
if len(groups) != 1 {
t.Fatalf("groups = %+v, want env fallback single group", groups)
}
}
func TestValidate(t *testing.T) {
valid := Group{ID: "a", Provider: ProviderOpenAI, BaseURL: "https://x", Model: "m"}
if err := valid.Validate(); err != nil {
t.Fatalf("valid group: %v", err)
}
cases := []Group{
{ID: "", Provider: ProviderOpenAI, BaseURL: "https://x", Model: "m"},
{ID: "a", Provider: "bogus", BaseURL: "https://x", Model: "m"},
{ID: "a", Provider: ProviderOpenAI, BaseURL: "", Model: "m"},
{ID: "a", Provider: ProviderOpenAI, BaseURL: "https://x", Model: ""},
}
for i, c := range cases {
if err := c.Validate(); err == nil {
t.Fatalf("case %d failed to validate: %+v", i, c)
}
}
}
func TestToLLMConfig(t *testing.T) {
g := Group{ID: "a", Provider: ProviderOpenAI, BaseURL: "https://x", APIKey: "sk", Model: "m", Fallback: "f"}
cfg := g.ToLLMConfig()
if cfg.Provider != ProviderOpenAI || cfg.BaseURL != "https://x" || cfg.APIKey != "sk" || cfg.Model != "m" || cfg.Fallback != "f" {
t.Fatalf("ToLLMConfig = %+v", cfg)
}
ag := Group{ID: "b", Provider: ProviderAnthropic, BaseURL: "https://y", APIKey: "sk2", Model: "claude"}
acfg := ag.ToLLMConfig()
if acfg.Provider != ProviderAnthropic || acfg.AnthropicBaseURL != "https://y" || acfg.AnthropicModel != "claude" {
t.Fatalf("anthropic ToLLMConfig = %+v", acfg)
}
}
func TestMaskedKey(t *testing.T) {
empty := Group{APIKey: ""}.MaskedKey()
if empty != "" {
t.Fatalf("empty key masked = %q, want empty", empty)
}
short := Group{APIKey: "abc"}.MaskedKey()
if short != "****" {
t.Fatalf("short key masked = %q, want ****", short)
}
long := Group{APIKey: "sk-abcdef1234"}.MaskedKey()
if long != "****1234" || strings.Contains(long, "sk-") {
t.Fatalf("masked = %q, want ****1234 without the prefix", long)
}
}
func TestActivate(t *testing.T) {
base := []Group{
{ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://a", Model: "gpt-4o"},
{ID: "anthropic", Provider: ProviderAnthropic, BaseURL: "https://b", Model: "claude"},
}
// New group is prepended (becomes the active index 0).
out := Activate(base, Group{ID: "deepseek", Provider: ProviderOpenAI, BaseURL: "https://c", Model: "ds"})
if len(out) != 3 || out[0].ID != "deepseek" || out[1].ID != "openai" || out[2].ID != "anthropic" {
t.Fatalf("Activate new = %+v", out)
}
// An existing group is replaced and promoted to the front.
out2 := Activate(out, Group{ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://a2", Model: "gpt-4o-new"})
if len(out2) != 3 || out2[0].ID != "openai" || out2[0].Model != "gpt-4o-new" || out2[1].ID != "deepseek" {
t.Fatalf("Activate existing = %+v", out2)
}
}
func TestSaveLoadRoundTrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "models.json")
in := []Group{
{ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://a", APIKey: "sk-secret", Model: "gpt-4o", Multimodal: true},
{ID: "anthropic", Provider: ProviderAnthropic, BaseURL: "https://b", APIKey: "sk-ant", Model: "claude"},
}
if err := SaveModels(path, in); err != nil {
t.Fatalf("SaveModels: %v", err)
}
groups, warn := LoadModels(path, envCfg())
if warn != nil {
t.Fatalf("warn = %v", warn)
}
if len(groups) != 2 || groups[0].APIKey != "sk-secret" || groups[1].APIKey != "sk-ant" {
t.Fatalf("round trip = %+v", groups)
}
// The key stays in the file (it is the runtime config), but never leaks into
// the masked view.
if got := groups[0].MaskedKey(); got != "****cret" {
t.Fatalf("masked key = %q, want ****cret", got)
}
}
func TestGroupMaxTokensRoundTrip(t *testing.T) {
// maxTokens is decoded from a hand-written models.json.
path := writeModelsFile(t, `[
{"id":"openai","provider":"openai","baseUrl":"https://a","apiKey":"sk-1","model":"gpt-4o","maxTokens":4096}
]`)
groups, warn := LoadModels(path, envCfg())
if warn != nil {
t.Fatalf("warn = %v, want nil", warn)
}
if len(groups) != 1 || groups[0].MaxTokens != 4096 {
t.Fatalf("groups = %+v, want one group with maxTokens 4096", groups)
}
// SaveModels writes it back, and LoadModels reads it again.
out := filepath.Join(t.TempDir(), "models.json")
in := []Group{
{ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://a", APIKey: "sk-1", Model: "gpt-4o", MaxTokens: 2048},
{ID: "claude", Provider: ProviderAnthropic, BaseURL: "https://b", APIKey: "sk-2", Model: "claude"},
}
if err := SaveModels(out, in); err != nil {
t.Fatalf("SaveModels: %v", err)
}
raw, err := os.ReadFile(out)
if err != nil {
t.Fatalf("read saved file: %v", err)
}
if !strings.Contains(string(raw), `"maxTokens": 2048`) {
t.Errorf("saved file missing maxTokens: %s", raw)
}
round, warn := LoadModels(out, envCfg())
if warn != nil {
t.Fatalf("load warn = %v", warn)
}
if round[0].MaxTokens != 2048 || round[1].MaxTokens != 0 {
t.Fatalf("round trip maxTokens = (%d,%d), want (2048,0)", round[0].MaxTokens, round[1].MaxTokens)
}
// Upsert and Activate preserve the field.
upserted := Upsert(round, Group{ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://a2", Model: "gpt-4o", MaxTokens: 3000})
if upserted[0].MaxTokens != 3000 {
t.Errorf("Upsert maxTokens = %d, want 3000", upserted[0].MaxTokens)
}
activated := Activate(round, Group{ID: "claude", Provider: ProviderAnthropic, BaseURL: "https://b", Model: "claude", MaxTokens: 512})
if activated[0].MaxTokens != 512 {
t.Errorf("Activate maxTokens = %d, want 512", activated[0].MaxTokens)
}
// A negative budget is rejected by validation (never silently persisted).
if err := (Group{ID: "x", Provider: ProviderOpenAI, BaseURL: "https://x", Model: "m", MaxTokens: -1}).Validate(); err == nil {
t.Fatal("expected Validate to reject a negative maxTokens")
}
}
func TestSaveModels_RejectsInvalid(t *testing.T) {
path := filepath.Join(t.TempDir(), "models.json")
if err := SaveModels(path, []Group{{ID: "", Provider: "x", BaseURL: "https://a", Model: "m"}}); err == nil {
t.Fatal("expected error saving an invalid group")
}
if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
t.Fatalf("file should not be written when validation fails")
}
}