Skip to content

Commit 70fcbc5

Browse files
authored
Merge pull request #824 from 0xYiliu/fix/issue-783-fallback-alias-resolution
fix: resolve fallback model alias parsing for issue #783
2 parents 5b96923 + 99582bb commit 70fcbc5

File tree

5 files changed

+261
-2
lines changed

5 files changed

+261
-2
lines changed
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Issue #783 调研与修复执行文档
2+
3+
## 1. 问题澄清(已确认)
4+
5+
- 现象:当 `agents.*.model.primary/fallbacks` 使用 `model_name` 别名(如 `step-3.5-flash`)时,fallback 链路将别名当作真实 `provider/model` 解析,导致 `provider` 可能为空、`model` 可能错误。
6+
- 根因:`ResolveCandidates` 仅对字符串做 `ParseModelRef`,未先通过 `model_list` 将别名映射到真实 `model` 字段。
7+
- 影响:
8+
- fallback 执行可能把别名直接发给 OpenAI-compatible provider,触发 `Unknown Model`
9+
- `defaults.provider` 为空时,日志出现 `provider=` 空值。
10+
11+
## 2. 本次目标
12+
13+
- 修复 fallback 候选解析:优先通过 `model_list` 解析别名。
14+
- 兼容旧行为:若未命中 `model_list`,继续走原有 `ParseModelRef` 兜底。
15+
- 补充测试:覆盖别名、嵌套路径模型(如 `openrouter/stepfun/...`)、空默认 provider。
16+
- 验证代码风格:与当前仓库风格保持一致(命名、错误处理、测试结构)。
17+
18+
## 3. 联网最佳实践调研结论(已完成)
19+
20+
- [x] 查阅 OpenAI-compatible 网关(如 OpenRouter)对 `model` 字段的推荐处理。
21+
- [x] 查阅多 provider/fallback 设计最佳实践(候选解析、日志可观测性)。
22+
- [x] 将外部建议映射为本仓库可执行约束。
23+
24+
外部参考要点(来自 OpenRouter/LiteLLM/Cloudflare AI Gateway 等官方文档):
25+
26+
- 优先显式配置,不依赖字符串切分推断 provider。
27+
- 对网关模型标识应保留完整路径语义,避免截断导致 Unknown Model。
28+
- fallback 与 primary 应复用同一解析策略,避免“主路径正确、降级路径错误”。
29+
30+
参考链接:
31+
32+
- OpenRouter Provider Routing: https://openrouter.ai/docs/guides/routing/provider-selection
33+
- OpenRouter Model Fallbacks: https://openrouter.ai/docs/guides/routing/model-fallbacks
34+
- OpenRouter Chat Completion API: https://openrouter.ai/docs/api-reference/chat-completion
35+
- LiteLLM Router Architecture: https://docs.litellm.ai/docs/router_architecture
36+
- Cloudflare AI Gateway Chat Completion: https://developers.cloudflare.com/ai-gateway/usage/chat-completion/
37+
38+
与本仓库对应的可执行约束:
39+
40+
- 在 fallback candidate 构建阶段先做 `model_name -> model_list.model` 映射。
41+
- 未命中映射时保留旧解析行为,保证兼容性。
42+
- 用新增测试锁定“别名 + 嵌套模型路径 + 空默认 provider”场景。
43+
44+
## 4. 实施步骤(顺序执行)
45+
46+
- [x] Step 1: 对齐现有代码模式,定位最小改动点(`pkg/agent` + `pkg/providers`)。
47+
- [x] Step 2: 实现“基于 model_list 的 fallback 候选解析”。
48+
- [x] Step 3: 增加/更新单元测试,覆盖 issue 场景。
49+
- [x] Step 4: 代码风格一致性复核(与现有文件风格对照)。
50+
- [x] Step 5: 运行质量门禁(LSP + `make check`)。
51+
52+
## 5. 执行记录
53+
54+
- 状态:已完成
55+
- 已完成改动:
56+
- `pkg/providers/fallback.go`:新增 `ResolveCandidatesWithLookup`,并保持 `ResolveCandidates` 向后兼容。
57+
- `pkg/agent/instance.go`:在构建 fallback candidates 前,优先通过 `model_list` 解析别名,并对无协议模型补齐默认 `openai/` 前缀后再解析。
58+
- `pkg/providers/fallback_test.go`:新增别名解析与去重测试。
59+
- `pkg/agent/instance_test.go`:新增 agent 侧别名解析到嵌套模型路径、无协议模型解析测试。
60+
- 风格对齐检查(完成):与 `pkg/providers/fallback_test.go``pkg/providers/model_ref_test.go` 现有模式一致。
61+
- 质量验证(完成):先 `make generate`,后 `make check` 全量通过。

pkg/agent/instance.go

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,47 @@ func NewAgentInstance(
9292
Primary: model,
9393
Fallbacks: fallbacks,
9494
}
95-
candidates := providers.ResolveCandidates(modelCfg, defaults.Provider)
95+
resolveFromModelList := func(raw string) (string, bool) {
96+
ensureProtocol := func(model string) string {
97+
model = strings.TrimSpace(model)
98+
if model == "" {
99+
return ""
100+
}
101+
if strings.Contains(model, "/") {
102+
return model
103+
}
104+
return "openai/" + model
105+
}
106+
107+
raw = strings.TrimSpace(raw)
108+
if raw == "" {
109+
return "", false
110+
}
111+
112+
if cfg != nil {
113+
if mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && strings.TrimSpace(mc.Model) != "" {
114+
return ensureProtocol(mc.Model), true
115+
}
116+
117+
for i := range cfg.ModelList {
118+
fullModel := strings.TrimSpace(cfg.ModelList[i].Model)
119+
if fullModel == "" {
120+
continue
121+
}
122+
if fullModel == raw {
123+
return ensureProtocol(fullModel), true
124+
}
125+
_, modelID := providers.ExtractProtocol(fullModel)
126+
if modelID == raw {
127+
return ensureProtocol(fullModel), true
128+
}
129+
}
130+
}
131+
132+
return "", false
133+
}
134+
135+
candidates := providers.ResolveCandidatesWithLookup(modelCfg, defaults.Provider, resolveFromModelList)
96136

97137
return &AgentInstance{
98138
ID: agentID,

pkg/agent/instance_test.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,77 @@ func TestNewAgentInstance_DefaultsTemperatureWhenUnset(t *testing.T) {
9393
t.Fatalf("Temperature = %f, want %f", agent.Temperature, 0.7)
9494
}
9595
}
96+
97+
func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) {
98+
tmpDir, err := os.MkdirTemp("", "agent-instance-test-*")
99+
if err != nil {
100+
t.Fatalf("Failed to create temp dir: %v", err)
101+
}
102+
defer os.RemoveAll(tmpDir)
103+
104+
cfg := &config.Config{
105+
Agents: config.AgentsConfig{
106+
Defaults: config.AgentDefaults{
107+
Workspace: tmpDir,
108+
Model: "step-3.5-flash",
109+
},
110+
},
111+
ModelList: []config.ModelConfig{
112+
{
113+
ModelName: "step-3.5-flash",
114+
Model: "openrouter/stepfun/step-3.5-flash:free",
115+
APIBase: "https://openrouter.ai/api/v1",
116+
},
117+
},
118+
}
119+
120+
provider := &mockProvider{}
121+
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
122+
123+
if len(agent.Candidates) != 1 {
124+
t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates))
125+
}
126+
if agent.Candidates[0].Provider != "openrouter" {
127+
t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, "openrouter")
128+
}
129+
if agent.Candidates[0].Model != "stepfun/step-3.5-flash:free" {
130+
t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, "stepfun/step-3.5-flash:free")
131+
}
132+
}
133+
134+
func TestNewAgentInstance_ResolveCandidatesFromModelListAliasWithoutProtocol(t *testing.T) {
135+
tmpDir, err := os.MkdirTemp("", "agent-instance-test-*")
136+
if err != nil {
137+
t.Fatalf("Failed to create temp dir: %v", err)
138+
}
139+
defer os.RemoveAll(tmpDir)
140+
141+
cfg := &config.Config{
142+
Agents: config.AgentsConfig{
143+
Defaults: config.AgentDefaults{
144+
Workspace: tmpDir,
145+
Model: "glm-5",
146+
},
147+
},
148+
ModelList: []config.ModelConfig{
149+
{
150+
ModelName: "glm-5",
151+
Model: "glm-5",
152+
APIBase: "https://api.z.ai/api/coding/paas/v4",
153+
},
154+
},
155+
}
156+
157+
provider := &mockProvider{}
158+
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
159+
160+
if len(agent.Candidates) != 1 {
161+
t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates))
162+
}
163+
if agent.Candidates[0].Provider != "openai" {
164+
t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, "openai")
165+
}
166+
if agent.Candidates[0].Model != "glm-5" {
167+
t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, "glm-5")
168+
}
169+
}

pkg/providers/fallback.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,26 @@ func NewFallbackChain(cooldown *CooldownTracker) *FallbackChain {
4343

4444
// ResolveCandidates parses model config into a deduplicated candidate list.
4545
func ResolveCandidates(cfg ModelConfig, defaultProvider string) []FallbackCandidate {
46+
return ResolveCandidatesWithLookup(cfg, defaultProvider, nil)
47+
}
48+
49+
func ResolveCandidatesWithLookup(
50+
cfg ModelConfig,
51+
defaultProvider string,
52+
lookup func(raw string) (resolved string, ok bool),
53+
) []FallbackCandidate {
4654
seen := make(map[string]bool)
4755
var candidates []FallbackCandidate
4856

4957
addCandidate := func(raw string) {
50-
ref := ParseModelRef(raw, defaultProvider)
58+
candidateRaw := strings.TrimSpace(raw)
59+
if lookup != nil {
60+
if resolved, ok := lookup(candidateRaw); ok {
61+
candidateRaw = resolved
62+
}
63+
}
64+
65+
ref := ParseModelRef(candidateRaw, defaultProvider)
5166
if ref == nil {
5267
return
5368
}

pkg/providers/fallback_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,75 @@ func TestResolveCandidates_EmptyPrimary(t *testing.T) {
453453
}
454454
}
455455

456+
func TestResolveCandidatesWithLookup_AliasResolvesToNestedModel(t *testing.T) {
457+
cfg := ModelConfig{
458+
Primary: "step-3.5-flash",
459+
Fallbacks: nil,
460+
}
461+
462+
lookup := func(raw string) (string, bool) {
463+
if raw == "step-3.5-flash" {
464+
return "openrouter/stepfun/step-3.5-flash:free", true
465+
}
466+
return "", false
467+
}
468+
469+
candidates := ResolveCandidatesWithLookup(cfg, "", lookup)
470+
if len(candidates) != 1 {
471+
t.Fatalf("candidates = %d, want 1", len(candidates))
472+
}
473+
if candidates[0].Provider != "openrouter" {
474+
t.Fatalf("provider = %q, want openrouter", candidates[0].Provider)
475+
}
476+
if candidates[0].Model != "stepfun/step-3.5-flash:free" {
477+
t.Fatalf("model = %q, want stepfun/step-3.5-flash:free", candidates[0].Model)
478+
}
479+
}
480+
481+
func TestResolveCandidatesWithLookup_DeduplicateAfterLookup(t *testing.T) {
482+
cfg := ModelConfig{
483+
Primary: "step-3.5-flash",
484+
Fallbacks: []string{"openrouter/stepfun/step-3.5-flash:free"},
485+
}
486+
487+
lookup := func(raw string) (string, bool) {
488+
if raw == "step-3.5-flash" {
489+
return "openrouter/stepfun/step-3.5-flash:free", true
490+
}
491+
return "", false
492+
}
493+
494+
candidates := ResolveCandidatesWithLookup(cfg, "", lookup)
495+
if len(candidates) != 1 {
496+
t.Fatalf("candidates = %d, want 1", len(candidates))
497+
}
498+
}
499+
500+
func TestResolveCandidatesWithLookup_AliasWithoutProtocolUsesDefaultProvider(t *testing.T) {
501+
cfg := ModelConfig{
502+
Primary: "glm-5",
503+
Fallbacks: nil,
504+
}
505+
506+
lookup := func(raw string) (string, bool) {
507+
if raw == "glm-5" {
508+
return "glm-5", true
509+
}
510+
return "", false
511+
}
512+
513+
candidates := ResolveCandidatesWithLookup(cfg, "openai", lookup)
514+
if len(candidates) != 1 {
515+
t.Fatalf("candidates = %d, want 1", len(candidates))
516+
}
517+
if candidates[0].Provider != "openai" {
518+
t.Fatalf("provider = %q, want openai", candidates[0].Provider)
519+
}
520+
if candidates[0].Model != "glm-5" {
521+
t.Fatalf("model = %q, want glm-5", candidates[0].Model)
522+
}
523+
}
524+
456525
func TestFallbackExhaustedError_Message(t *testing.T) {
457526
e := &FallbackExhaustedError{
458527
Attempts: []FallbackAttempt{

0 commit comments

Comments
 (0)