fix: anthropic tool-call round-trip, cache zero-hit parity, round-robin load balancing

anthropic.lua v3.0.0:
- Issue 1: tool_result/tool_use round-trip
- Issue 3: thinking default OFF (opt-in via extra_body.thinking)
- Issue 4: tool_choice mapping
- Issue 5: collect_blocks preserves unknown part types
- message_stop no longer emits done=true (was overwriting tool_calls finish_reason)
- cache_read_input_tokens normalized even at 0

gemini.lua:
- transform_response was missing cachedContentTokenCount

openai.lua (Issue 6):
- transform_error handles flat envelopes, nginx HTML, bare text

chat.go mergeUsage:
- Keep PromptTokensDetails even when CachedTokens=0

scheduler.go:
- Remove sort.SliceStable by Pref; round-robin cursor is the only LB mechanism

provider.go ModelAvailable:
- Also check Pref() > prefMin, persistently failing slots exit cands

presets.go:
- 17 built-in source templates

Tests: 6 new test functions, 2 updated for new semantics
This commit is contained in:
JianFeeeee
2026-08-28 12:02:46 +08:00
parent 94cbcb6771
commit 624fd74b45
15 changed files with 1170 additions and 114 deletions

View File

@ -318,6 +318,7 @@ type RuntimeConfig struct {
SourceTemplates []SourceTemplate `json:"source_templates,omitempty"`
DeletedSources []string `json:"deleted_sources,omitempty"`
DeletedAdapters []string `json:"deleted_adapters,omitempty"`
PresetTemplates []string `json:"preset_templates,omitempty"` // preset names the user has seen (or deleted) — never re-seeded
Keys []GWKey `json:"keys,omitempty"`
Auto []ModelScope `json:"auto,omitempty"`
AutoImage []ModelScope `json:"auto_image,omitempty"`

View File

@ -318,3 +318,55 @@ sources:
t.Fatalf("after no-op: %v %v", len(cfg.Sources), err)
}
}
// TestSeedTemplatesOnce verifies preset templates are seeded exactly once:
// a user's edit survives a restart, and a deliberately deleted preset does
// not silently come back.
func TestSeedTemplatesOnce(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "runtime.json")
presets := []SourceTemplate{
{Name: "DeepSeek", BaseURL: "https://api.deepseek.com", Adapter: "deepseek",
Models: []Model{{ID: "deepseek-chat", Kind: "chat"}}},
{Name: "OpenAI", BaseURL: "https://api.openai.com/v1", Adapter: "openai",
Models: []Model{{ID: "gpt-4o", Kind: "chat"}}},
}
// first run: both presets land in the store
s1 := NewStore(path)
if err := s1.Load(); err != nil {
t.Fatal(err)
}
if err := s1.SeedTemplates(presets); err != nil {
t.Fatalf("seed: %v", err)
}
if got := len(s1.ListTemplates()); got != 2 {
t.Fatalf("after seed len = %d, want 2", got)
}
// user edits one preset, deletes the other
edited := presets[0]
edited.BaseURL = "https://my-proxy.internal/v1"
if err := s1.UpsertTemplate(edited); err != nil {
t.Fatal(err)
}
if _, err := s1.RemoveTemplate("OpenAI"); err != nil {
t.Fatal(err)
}
// restart: re-seeding must be a no-op for both
s2 := NewStore(path)
if err := s2.Load(); err != nil {
t.Fatal(err)
}
if err := s2.SeedTemplates(presets); err != nil {
t.Fatalf("reseed: %v", err)
}
tpls := s2.ListTemplates()
if len(tpls) != 1 {
t.Fatalf("after restart len = %d, want 1 (deleted preset must not return): %+v", len(tpls), tpls)
}
if tpls[0].Name != "DeepSeek" || tpls[0].BaseURL != "https://my-proxy.internal/v1" {
t.Fatalf("user edit lost on restart: %+v", tpls[0])
}
}

212
internal/config/presets.go Normal file
View File

@ -0,0 +1,212 @@
package config
// Built-in preset source templates. Seeded into the runtime store on first
// startup so the WebUI can present "从模板创建" for popular providers — the
// user only needs to supply a name and API key.
//
// Model IDs verified against provider docs 2026-08-28. Providers ship new IDs
// frequently; treat these as a starting point and edit in the WebUI as needed.
var PresetTemplates = []SourceTemplate{
// ── 国内热门 ────────────────────────────────────────────────────────────
{
Name: "DeepSeek",
BaseURL: "https://api.deepseek.com",
Adapter: "deepseek",
MaxConcurrent: 8,
Models: []Model{
{ID: "deepseek-v4-flash", Priority: 100, Kind: "chat"},
{ID: "deepseek-v4-pro", Priority: 90, Kind: "chat"},
{ID: "deepseek-v4-flash-vision-exp", Priority: 60, Kind: "chat"},
},
},
{
Name: "智谱 GLM",
BaseURL: "https://open.bigmodel.cn/api/paas/v4",
Adapter: "openai",
MaxConcurrent: 8,
Models: []Model{
{ID: "glm-5.3", Priority: 100, Kind: "chat"},
{ID: "glm-5.3-flash", Priority: 90, Kind: "chat"},
{ID: "glm-5.2", Priority: 80, Kind: "chat"},
{ID: "glm-5.1", Priority: 60, Kind: "chat"},
},
},
{
Name: "Kimi / Moonshot",
BaseURL: "https://api.moonshot.cn/v1",
Adapter: "openai",
MaxConcurrent: 8,
Models: []Model{
{ID: "kimi-k3", Priority: 100, Kind: "chat"},
{ID: "kimi-k2.7-code", Priority: 90, Kind: "chat"},
{ID: "kimi-k2.7-code-highspeed", Priority: 85, Kind: "chat"},
{ID: "kimi-k2.6", Priority: 70, Kind: "chat"},
},
},
{
Name: "SiliconFlow",
BaseURL: "https://api.siliconflow.cn/v1",
Adapter: "openai",
MaxConcurrent: 8,
Models: []Model{
{ID: "deepseek-ai/DeepSeek-V4-Pro", Priority: 100, Kind: "chat"},
{ID: "deepseek-ai/DeepSeek-V4-Flash", Priority: 90, Kind: "chat"},
{ID: "moonshotai/Kimi-K2.6", Priority: 80, Kind: "chat"},
{ID: "zai-org/GLM-5.1", Priority: 70, Kind: "chat"},
{ID: "Kwai-Kolors/Kolors", Priority: 50, Kind: "image"},
},
},
{
Name: "讯飞星火 (Spark)",
BaseURL: "https://spark-api-open.xf-yun.com/v1",
Adapter: "openai",
MaxConcurrent: 4,
Models: []Model{
{ID: "4.0Ultra", Priority: 100, Kind: "chat"},
{ID: "generalv3.5", Priority: 80, Kind: "chat"},
{ID: "max-32k", Priority: 70, Kind: "chat"},
},
},
{
Name: "阿里云 通义千问",
BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
Adapter: "openai",
MaxConcurrent: 8,
Models: []Model{
{ID: "qwen3.8-max", Priority: 100, Kind: "chat"},
{ID: "qwen3.8-flash", Priority: 90, Kind: "chat"},
{ID: "qwen3.7-plus", Priority: 85, Kind: "chat"},
{ID: "deepseek-v4-pro", Priority: 70, Kind: "chat"},
{ID: "kimi-k3", Priority: 70, Kind: "chat"},
},
},
{
Name: "百度 千帆",
BaseURL: "https://qianfan.baidubce.com/v2",
Adapter: "openai",
MaxConcurrent: 8,
Models: []Model{
{ID: "ernie-5.0-turbo", Priority: 100, Kind: "chat"},
{ID: "ernie-5.0", Priority: 90, Kind: "chat"},
{ID: "deepseek-v4-pro", Priority: 70, Kind: "chat"},
},
},
{
Name: "豆包 (火山方舟)",
BaseURL: "https://ark.cn-beijing.volces.com/api/v3",
Adapter: "openai",
MaxConcurrent: 8,
Models: []Model{
{ID: "doubao-seed-2.0-pro", Priority: 100, Kind: "chat"},
{ID: "doubao-seed-2.0", Priority: 90, Kind: "chat"},
{ID: "doubao-seed-2.0-flash", Priority: 80, Kind: "chat"},
},
},
{
Name: "MiniMax",
BaseURL: "https://api.minimax.chat/v1",
Adapter: "openai",
MaxConcurrent: 8,
Models: []Model{
{ID: "MiniMax-M3", Priority: 100, Kind: "chat"},
{ID: "MiniMax-M2.5", Priority: 80, Kind: "chat"},
},
},
// ── 国际主流 ────────────────────────────────────────────────────────────
{
Name: "OpenAI",
BaseURL: "https://api.openai.com/v1",
Adapter: "openai",
MaxConcurrent: 8,
Models: []Model{
{ID: "gpt-5.6-sol", Priority: 100, Kind: "chat"},
{ID: "gpt-5.6-terra", Priority: 90, Kind: "chat"},
{ID: "gpt-5.6-luna", Priority: 80, Kind: "chat"},
},
},
{
Name: "Anthropic Claude",
BaseURL: "https://api.anthropic.com",
Adapter: "anthropic",
MaxConcurrent: 8,
Models: []Model{
{ID: "claude-opus-5", Priority: 100, Kind: "chat"},
{ID: "claude-fable-5", Priority: 95, Kind: "chat"},
{ID: "claude-sonnet-5", Priority: 85, Kind: "chat"},
{ID: "claude-haiku-4-5", Priority: 70, Kind: "chat"},
},
},
{
Name: "Google Gemini",
BaseURL: "https://generativelanguage.googleapis.com/v1beta",
Adapter: "gemini",
MaxConcurrent: 8,
Models: []Model{
{ID: "gemini-3.6-flash", Priority: 100, Kind: "chat"},
{ID: "gemini-3.5-flash", Priority: 90, Kind: "chat"},
{ID: "gemini-3.5-flash-lite", Priority: 80, Kind: "chat"},
{ID: "gemini-3.1-pro", Priority: 85, Kind: "chat"},
},
},
{
Name: "Groq",
BaseURL: "https://api.groq.com/openai/v1",
Adapter: "groq",
MaxConcurrent: 8,
Models: []Model{
{ID: "openai/gpt-oss-120b", Priority: 100, Kind: "chat"},
{ID: "openai/gpt-oss-20b", Priority: 80, Kind: "chat"},
{ID: "groq/compound", Priority: 70, Kind: "chat"},
},
},
{
Name: "Mistral",
BaseURL: "https://api.mistral.ai/v1",
Adapter: "mistral",
MaxConcurrent: 8,
Models: []Model{
{ID: "mistral-medium-latest", Priority: 100, Kind: "chat"},
{ID: "mistral-large-latest", Priority: 90, Kind: "chat"},
{ID: "codestral-latest", Priority: 85, Kind: "chat"},
},
},
{
Name: "OpenRouter",
BaseURL: "https://openrouter.ai/api/v1",
Adapter: "openai",
MaxConcurrent: 8,
Headers: map[string]string{
"HTTP-Referer": "http://localhost:8080",
"X-Title": "ModelRouter",
},
Models: []Model{
{ID: "openrouter/auto", Priority: 100, Kind: "chat"},
{ID: "deepseek/deepseek-v4-flash", Priority: 90, Kind: "chat"},
{ID: "anthropic/claude-opus-5", Priority: 85, Kind: "chat"},
{ID: "openai/gpt-5.6-sol", Priority: 85, Kind: "chat"},
},
},
{
Name: "GitHub Models",
BaseURL: "https://models.inference.ai.azure.com",
Adapter: "github",
MaxConcurrent: 8,
Models: []Model{
{ID: "gpt-5.6-sol", Priority: 100, Kind: "chat"},
{ID: "gpt-5.6-luna", Priority: 80, Kind: "chat"},
},
},
// ── 本地模型 ────────────────────────────────────────────────────────────
{
Name: "Ollama (localhost)",
BaseURL: "http://127.0.0.1:11434",
Adapter: "ollama",
Endpoint: "/api/chat",
MaxConcurrent: 4,
Models: []Model{
{ID: "qwen3", Priority: 80, Kind: "chat"},
{ID: "deepseek-r1", Priority: 80, Kind: "chat"},
{ID: "llama3.3", Priority: 70, Kind: "chat"},
},
},
}

View File

@ -138,6 +138,38 @@ func (s *Store) DeletedAdapters() map[string]bool {
return out
}
// SeedTemplates adds preset templates that the store has never seen before.
// A preset is seeded at most once: its name is recorded in PresetTemplates so
// a user who edits or deletes it never gets it silently restored on restart.
func (s *Store) SeedTemplates(presets []SourceTemplate) error {
s.mu.Lock()
defer s.mu.Unlock()
seen := make(map[string]bool, len(s.data.PresetTemplates))
for _, n := range s.data.PresetTemplates {
seen[n] = true
}
existing := make(map[string]bool, len(s.data.SourceTemplates))
for _, t := range s.data.SourceTemplates {
existing[t.Name] = true
}
added := false
for _, p := range presets {
if p.Name == "" || seen[p.Name] {
continue
}
s.data.PresetTemplates = append(s.data.PresetTemplates, p.Name)
added = true
if existing[p.Name] {
continue // user already has a template by this name: never overwrite
}
s.data.SourceTemplates = append(s.data.SourceTemplates, p)
}
if !added {
return nil
}
return s.persistLocked()
}
// UpsertTemplate adds or replaces a source template and persists.
func (s *Store) UpsertTemplate(t SourceTemplate) error {
s.mu.Lock()