package runtime import ( "testing" core "agent-system-core" ) func effOf(kv map[string]any) core.EffectiveSettings { return core.EffectiveSettings(kv) } // 三层覆盖链解析:defaults → user → project 由 settings 服务合并(server 集成测试覆盖); // 此处验证解析器对「设置层结果 + 画像层」的最后一级覆盖与回落。 func TestResolveChatModelLayers(t *testing.T) { eff := effOf(map[string]any{"models": map[string]any{"chat": map[string]any{ "model": "model-settings", "baseUrl": "https://s.example", "apiKey": "k0", }}}) prof := core.AgentProfile{} // 仅设置层。 got := ResolveChatModel(prof, eff) if got.Model != "model-settings" || got.Base != "https://s.example" || got.Key != "k0" { t.Fatalf("settings layer: %+v", got) } // 画像覆盖模型(端点沿用设置层)。 prof.Model.Chat = "model-profile" got = ResolveChatModel(prof, eff) if got.Model != "model-profile" || got.Base != "https://s.example" { t.Fatalf("profile layer should override model only: %+v", got) } } func TestResolveLightweightFallback(t *testing.T) { eff := effOf(map[string]any{"models": map[string]any{ "chat": map[string]any{"model": "main-m", "baseUrl": "https://main", "apiKey": "k"}, "lightweight": map[string]any{"model": ""}, }}) got := ResolveLightweightModel(core.AgentProfile{}, eff) if got.Model != "main-m" || got.Base != "https://main" { t.Fatalf("lightweight should fall back to chat: %+v", got) } // 轻量档只配模型名:模型用轻量、端点回落主档。 eff["models"].(map[string]any)["lightweight"] = map[string]any{"model": "lite-m"} got = ResolveLightweightModel(core.AgentProfile{Model: core.ModelRoute{Lightweight: "prof-lite"}}, eff) if got.Model != "prof-lite" || got.Base != "https://main" { t.Fatalf("profile lightweight + chat endpoint: %+v", got) } } func TestChatFallbacksDefaultEmpty(t *testing.T) { if got := ChatFallbacks(effOf(nil)); got != nil { t.Fatalf("default %#v", got) } eff := effOf(map[string]any{"models": map[string]any{"chat": map[string]any{ "fallbacks": " backup , primary ,backup", }}}) got := ChatFallbacks(eff) if len(got) != 2 || got[0] != "backup" || got[1] != "primary" { t.Fatalf("%#v", got) } } func TestResolveVisionModel(t *testing.T) { eff := effOf(map[string]any{"models": map[string]any{"chat": map[string]any{ "model": "deepseek-v4.1-flash", "baseUrl": "https://gw.example/v1", "apiKey": "k", "fallbacks": "plain-text,ZHIPU/GLM-5.3-FlashX", }, "vision": map[string]any{ "model": "qwen3.8-flash", "capable": "deepseek-v4.1-flash,qwen3.8-flash,ZHIPU/GLM-5.3-FlashX", }}}) prof := core.AgentProfile{} got := ResolveVisionModel(prof, eff, false) if got.Model != "deepseek-v4.1-flash" { t.Fatalf("no image should keep chat model: %+v", got) } got = ResolveVisionModel(prof, eff, true) if got.Model != "deepseek-v4.1-flash" || got.Base != "https://gw.example/v1" || got.Key != "k" { t.Fatalf("capable model should stay: %+v", got) } prof.Model.Chat = "plain-text" got = ResolveVisionModel(prof, eff, true) if got.Model != "qwen3.8-flash" || got.Base != "https://gw.example/v1" || got.Key != "k" { t.Fatalf("incapable model should swap, keeping endpoint: %+v", got) } fb := ChatFallbacksFor(eff, true) if len(fb) != 1 || fb[0] != "ZHIPU/GLM-5.3-FlashX" { t.Fatalf("image fallbacks %#v", fb) } fb = ChatFallbacksFor(eff, false) if len(fb) != 2 || fb[0] != "plain-text" { t.Fatalf("text fallbacks %#v", fb) } } // 只有 Settings 端口的宿主:快照可取,没有就报缺失(调用方自己决定回落)。 type settingsOnlyCtx struct{ eff core.EffectiveSettings } func (c settingsOnlyCtx) Settings() core.EffectiveSettings { return c.eff } // 只有 Run 端口的宿主:属主只能来自 run。 type runOnlyCtx struct{ owner string } func (c runOnlyCtx) Run() core.Run { return core.Run{ID: "run_t", Owner: core.ID(c.owner)} } // 两个端口都有的宿主(runmgr 的 runCtx 形态)。 type runProfileCtx struct { settingsOnlyCtx runOnlyCtx prof core.AgentProfile } func (c runProfileCtx) Profile() core.AgentProfile { return c.prof } func TestRunEffectiveSettingsNeedsHostPort(t *testing.T) { eff := effOf(map[string]any{"models": map[string]any{"chat": map[string]any{"model": "m"}}}) if got, ok := RunEffectiveSettings(settingsOnlyCtx{eff: eff}); !ok || got.GetString("models.chat.model", "") != "m" { t.Fatalf("run snapshot = %+v ok=%v, want the host snapshot", got, ok) } if _, ok := RunEffectiveSettings(settingsOnlyCtx{}); ok { t.Fatal("empty snapshot must be reported as missing so the caller can fall back") } if _, ok := RunEffectiveSettings(runOnlyCtx{owner: "alice"}); ok { t.Fatal("host without a Settings port must not be reported as having one") } } func TestRunProfileNeverFallsBackToDefaultOwner(t *testing.T) { prof := core.AgentProfile{Owner: "alice", Model: core.ModelRoute{Chat: "alice-model"}} both := runProfileCtx{settingsOnlyCtx: settingsOnlyCtx{}, runOnlyCtx: runOnlyCtx{owner: "alice"}, prof: prof} if got := RunProfile(both); got.Owner != "alice" || got.Model.Chat != "alice-model" { t.Fatalf("profile port must win: %+v", got) } if got := RunProfile(both).Owner; got != "alice" { t.Fatalf("owner = %q, want alice", got) } // 画像端口缺席(或没带 Owner)时取 run.Owner——密钥层归属不能退化成默认账号。 bare := runProfileCtx{settingsOnlyCtx: settingsOnlyCtx{}, runOnlyCtx: runOnlyCtx{owner: "bob"}} if got := RunProfile(bare).Owner; got != "bob" { t.Fatalf("owner = %q, want the run owner bob", got) } if got := RunProfile(runProfileCtx{settingsOnlyCtx: settingsOnlyCtx{}, runOnlyCtx: runOnlyCtx{owner: "bob"}, prof: core.AgentProfile{}}).Owner; got != "bob" { t.Fatalf("empty profile owner = %q, want the run owner bob", got) } if got := RunProfile(runOnlyCtx{}).Owner; got != "" { t.Fatalf("owner without any port = %q, want empty", got) } } // 带自定义快照的运行上下文(其余端口由 fakeRunCtx 满足)。 type runSnapCtx struct { *fakeRunCtx eff core.EffectiveSettings } func (c *runSnapCtx) Settings() core.EffectiveSettings { return c.eff } // 运行内解析主路由:快照 + 画像覆盖 + 属主密钥层,与直连同一条规则。 func TestRunChatRouteUsesRunSnapshotAndOwnerKey(t *testing.T) { saved := SecretLookup defer func() { SecretLookup = saved }() secrets := map[string]string{"alice": "sk-alice-synthetic", "bob": "sk-bob-synthetic"} SecretLookup = func(owner, _ string) (string, string, bool) { if owner == "" { owner = "local" } v, ok := secrets[owner] return v, "owner", ok } eff := effOf(map[string]any{"models": map[string]any{"chat": map[string]any{ "model": "settings-model", "baseUrl": "https://shared.example/v1", "apiKey": "sk-settings-synthetic", }}}) for _, owner := range []string{"alice", "bob"} { rc := &runSnapCtx{fakeRunCtx: &fakeRunCtx{prof: core.AgentProfile{Owner: owner, Model: core.ModelRoute{Chat: owner + "-profile-model"}}}, eff: eff} got := RunChatRoute(rc) want := ResolveChatModel(rc.Profile(), rc.Settings()) if got != want { t.Fatalf("%s: run route %+v != direct rule %+v", owner, got, want) } if got.Model != owner+"-profile-model" || got.Base != "https://shared.example/v1" { t.Fatalf("%s: route = %+v, want the profile model on the shared endpoint", owner, got) } if got.Key != "sk-"+owner+"-synthetic" || got.KeySource != "owner" { t.Fatalf("%s: key = %q (%s), want that owner's own secret", owner, got.Key, got.KeySource) } } } // 客户端构造:reasoning split 与受限 EOF 契约都只看本路由自己的 model+base, // 画像覆盖后的路由重新判定(与直连路径同一个构造器)。 func TestOpenRouteClientDecidesPerRoute(t *testing.T) { const base = "https://api.example.com/v1" contract := routeEOFMatching(routeEOFModel, base) eff := routeEOFEff(map[string]any{"model": routeEOFModel, "baseUrl": base, "apiKey": "k"}, contract) route := ResolvedModel{Kind: "chat", Model: routeEOFModel, Base: base, Key: "k"} client := OpenRouteClient(route, eff) if client.BaseURL != base || client.APIKey != "k" { t.Fatalf("client route = %q/%q, want %q/k", client.BaseURL, client.APIKey, base) } if client.ReasoningSplit { t.Fatal("non-MiniMax model must not enable reasoning split") } if client.RouteContract == nil { t.Fatal("matching configuration must attach the contract") } // 画像覆盖改的是模型名:分流与契约都按覆盖后的路由重新判定。 for _, tc := range []struct { model string wantSplit bool wantEOfRule bool }{ {"MiniMax-M2", true, false}, {routeEOFModel, false, true}, {"other-model", false, false}, } { prof := core.AgentProfile{Owner: "alice", Model: core.ModelRoute{Chat: tc.model}} overridden := ResolveChatModel(prof, eff) got := OpenRouteClient(overridden, eff) if got.ReasoningSplit != tc.wantSplit || (got.RouteContract != nil) != tc.wantEOfRule { t.Fatalf("profile model %q: split=%v contract=%v, want split=%v contract=%v", tc.model, got.ReasoningSplit, got.RouteContract != nil, tc.wantSplit, tc.wantEOfRule) } } }