// auto_register_models_test.go — TD-24 / P1 engine.New 自动注 // Provider.Models() 实证测试. 修 cost=$0 真因 (config/models.go:63 // 设计意图跟实装从来没串起 — 0 provider 暴露 RegisterModels, 0 消费者 // 调过 register). // // auto_register_models_test.go — TD-24 / P1 engine.New auto-registration // of Provider.Models() into ModelRegistry tests. package engine import ( "context" "testing" "time" "git.flytoex.net/yuanwei/flyto-agent/pkg/config" "git.flytoex.net/yuanwei/flyto-agent/pkg/flyto" ) // modelTableProvider returns a fixed static model table on Models(). For // auto-register tests where we need real ModelInfo fields (price / // context window) round-trip through the registry. // // modelTableProvider 在 Models() 返回固定静态模型表. 给 auto-register // 测试需要实际 ModelInfo 字段 (价格 / context window) 经 registry round- // trip 验. type modelTableProvider struct { name string models []flyto.ModelInfo // delay simulates slow Provider.Models() (e.g. OpenRouter HTTP fetch). // Zero = instant return. delay time.Duration // failErr non-nil = Models() returns this error. failErr error } func (p *modelTableProvider) Name() string { return p.name } func (p *modelTableProvider) Stream(_ context.Context, _ *flyto.Request) (<-chan flyto.Event, error) { ch := make(chan flyto.Event) close(ch) return ch, nil } func (p *modelTableProvider) Models(ctx context.Context) ([]flyto.ModelInfo, error) { if p.failErr != nil { return nil, p.failErr } if p.delay > 0 { select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(p.delay): } } return p.models, nil } // TestAutoRegisterProviderModels_StaticProvider verifies the happy path: // a provider with a static (instant-return) model table populates the // registry, and reg.ContextWindow / EstimateCost return real values // (not fallback 200K / 0). // // TestAutoRegisterProviderModels_StaticProvider 验静态 provider happy // path: 模型表注 registry 后 reg.ContextWindow / EstimateCost 返真值 // 不再走 fallback 200K / 0. func TestAutoRegisterProviderModels_StaticProvider(t *testing.T) { p := &modelTableProvider{ name: "deepseek-stub", models: []flyto.ModelInfo{ { ID: "deepseek-v4-flash", ContextWindow: 1_000_000, MaxOutputTokens: 384_000, InputPricePer1M: 0.14, OutputPricePer1M: 0.28, }, }, } cfg := &Config{Provider: p} reg := cfg.ModelRegistry() // Pre-state: registry empty, queries return fallback. if reg.ContextWindow("deepseek-v4-flash") != 200000 { t.Errorf("pre-register ContextWindow expected fallback 200000") } if reg.EstimateSimpleCost("deepseek-v4-flash", 1_000_000, 1_000_000) != 0 { t.Errorf("pre-register cost expected 0 (cfg==nil path)") } autoRegisterProviderModels(cfg, reg) if got := reg.ContextWindow("deepseek-v4-flash"); got != 1_000_000 { t.Errorf("post-register ContextWindow = %d, want 1_000_000", got) } // 1M input @ $0.14 + 1M output @ $0.28 ≈ $0.42 (IEEE 754 epsilon). got := reg.EstimateSimpleCost("deepseek-v4-flash", 1_000_000, 1_000_000) if got < 0.41 || got > 0.43 { t.Errorf("post-register cost = %v, want ~0.42", got) } } // TestAutoRegisterProviderModels_SkipsExisting verifies consumer-supplied // registrations are not overridden by Provider.Models() data — manual // registers remain sovereign for custom-pricing scenarios. // // TestAutoRegisterProviderModels_SkipsExisting 验消费者手动 register // 优先, Provider.Models() 不覆盖 (自定义价格场景保持自治). func TestAutoRegisterProviderModels_SkipsExisting(t *testing.T) { p := &modelTableProvider{ name: "p", models: []flyto.ModelInfo{ {ID: "model-x", ContextWindow: 100_000, InputPricePer1M: 5.0}, }, } cfg := &Config{Provider: p} reg := cfg.ModelRegistry() // Consumer pre-registers with custom price. custom := &config.ModelConfig{ID: "model-x", ContextWindow: 999_999, InputPricePer1M: 99.0} reg.Register("model-x", custom) autoRegisterProviderModels(cfg, reg) got := reg.GetConfig("model-x") if got == nil { t.Fatal("model-x missing after auto-register") } if got.ContextWindow != 999_999 { t.Errorf("ContextWindow overridden: got %d, want 999_999 (consumer wins)", got.ContextWindow) } if got.InputPricePer1M != 99.0 { t.Errorf("InputPricePer1M overridden: got %v, want 99.0 (consumer wins)", got.InputPricePer1M) } } // TestAutoRegisterProviderModels_TimeoutSkips verifies a slow provider // (delay > timeout) does not block engine.New — Models() returns // context.DeadlineExceeded and the helper silently skips. Status quo // before this helper was silent 0 cost; failure mode here is the same. // // TestAutoRegisterProviderModels_TimeoutSkips 验慢 provider (delay > // timeout) 不阻塞 engine.New, Models() 返 context.DeadlineExceeded // helper silent 跳. 现状是 silent 0 cost, 失败模式同档非回归. func TestAutoRegisterProviderModels_TimeoutSkips(t *testing.T) { p := &modelTableProvider{ name: "slow-provider", delay: 500 * time.Millisecond, // > 200ms timeout models: []flyto.ModelInfo{{ID: "slow-model", ContextWindow: 50_000}}, } cfg := &Config{Provider: p} reg := cfg.ModelRegistry() start := time.Now() autoRegisterProviderModels(cfg, reg) elapsed := time.Since(start) // Must return within ~timeout, not the full 500ms delay. if elapsed > 350*time.Millisecond { t.Errorf("auto-register took %v, expected ~%v timeout", elapsed, autoRegisterProviderModelsTimeout) } // Slow model not registered (timeout path). if reg.GetConfig("slow-model") != nil { t.Error("slow-model registered despite timeout") } } // TestAutoRegisterProviderModels_FailureSkips verifies a provider that // returns an error does not panic / does not pollute the registry. // // TestAutoRegisterProviderModels_FailureSkips 验 provider 返 error 时 // 不 panic 不污染 registry. func TestAutoRegisterProviderModels_FailureSkips(t *testing.T) { p := &modelTableProvider{ name: "failing-provider", failErr: context.Canceled, // arbitrary error } cfg := &Config{Provider: p} reg := cfg.ModelRegistry() autoRegisterProviderModels(cfg, reg) // must not panic if len(reg.AllModels()) != 0 { t.Errorf("registry polluted on Models() error: %v", reg.AllModels()) } } // TestAutoRegisterProviderModels_NilProvider verifies nil cfg.Provider // is a safe no-op (engine.New will reject nil Provider before calling // here, but defensive: helper called from other paths in future must // not panic). // // TestAutoRegisterProviderModels_NilProvider 验 nil cfg.Provider 安全 // no-op (engine.New 在调此 helper 前已拒 nil Provider, 但防御性: 未来 // 别处调用 helper 不能 panic). func TestAutoRegisterProviderModels_NilProvider(t *testing.T) { cfg := &Config{Provider: nil} reg := cfg.ModelRegistry() autoRegisterProviderModels(cfg, reg) // must not panic if len(reg.AllModels()) != 0 { t.Errorf("registry not empty after nil-provider call: %v", reg.AllModels()) } } // TestAutoRegisterProviderModels_EmptyIDSkipped verifies model entries // with empty ID are skipped (defensive — provider sub-packages should // never emit empty IDs but registry guards just in case). // // TestAutoRegisterProviderModels_EmptyIDSkipped 验空 ID 模型条目跳过 // (防御性 — provider 子包不应发空 ID, registry 保险). func TestAutoRegisterProviderModels_EmptyIDSkipped(t *testing.T) { p := &modelTableProvider{ name: "p", models: []flyto.ModelInfo{ {ID: "", ContextWindow: 100}, {ID: "good", ContextWindow: 200}, }, } cfg := &Config{Provider: p} reg := cfg.ModelRegistry() autoRegisterProviderModels(cfg, reg) if reg.GetConfig("") != nil { t.Error("empty-ID model registered") } if reg.GetConfig("good") == nil { t.Error("good model not registered") } } // TestNew_AutoRegistersProviderModels is the integration test: engine.New // calls autoRegisterProviderModels internally so consumers using New() // get cost / context window populated automatically without calling // reg.Register manually. End-to-end version of the helper unit tests. // // TestNew_AutoRegistersProviderModels 集成测试: engine.New 内部调 // autoRegisterProviderModels, 消费者用 New() 不必手动调 reg.Register // 也能拿到 cost / context window. 单元 helper 的端到端版本. func TestNew_AutoRegistersProviderModels(t *testing.T) { cfg := testConfig() cfg.Provider = &modelTableProvider{ name: "integration-provider", models: []flyto.ModelInfo{ { ID: "test-model", ContextWindow: 500_000, InputPricePer1M: 1.0, OutputPricePer1M: 2.0, }, }, } cfg.Model = "test-model" eng, err := New(cfg) if err != nil { t.Fatalf("New: %v", err) } if eng == nil { t.Fatal("expected non-nil engine") } reg := cfg.ModelRegistry() if got := reg.ContextWindow("test-model"); got != 500_000 { t.Errorf("ContextWindow after New: got %d, want 500_000 (auto-register failed)", got) } // 1M input @ $1.0 + 1M output @ $2.0 = $3.0 if got := reg.EstimateSimpleCost("test-model", 1_000_000, 1_000_000); got != 3.0 { t.Errorf("cost after New: got %v, want 3.0 (auto-register failed)", got) } }