package deepseek import ( "context" "strings" "testing" "git.flytoex.net/yuanwei/flyto-agent/pkg/flyto" ) func TestNew_DefaultsModeOpenAI(t *testing.T) { p := New(Config{APIKey: "sk-test"}) if p.cfg.Mode != ModeOpenAI { t.Errorf("default Mode = %q, want %q", p.cfg.Mode, ModeOpenAI) } if p.baseURL != "https://api.deepseek.com" { t.Errorf("default baseURL = %q, want https://api.deepseek.com", p.baseURL) } if p.wireClient == nil { t.Error("ModeOpenAI must populate wireClient") } if p.anthroClient != nil { t.Error("ModeOpenAI must NOT populate anthroClient") } } func TestNew_ModeAnthropic(t *testing.T) { p := New(Config{APIKey: "sk-test", Mode: ModeAnthropic}) if p.anthroClient == nil { t.Error("ModeAnthropic must populate anthroClient") } if p.wireClient != nil { t.Error("ModeAnthropic must NOT populate wireClient") } } func TestProvider_Name(t *testing.T) { p := New(Config{APIKey: "sk-test"}) if got := p.Name(); got != "deepseek" { t.Errorf("Name() = %q, want %q", got, "deepseek") } } func TestProvider_Models_StaticTable(t *testing.T) { p := New(Config{APIKey: "sk-test"}) models, err := p.Models(context.Background()) if err != nil { t.Fatalf("Models err: %v", err) } if len(models) != 2 { t.Fatalf("Models len = %d, want 2", len(models)) } wantIDs := map[string]bool{"deepseek-v4-flash": true, "deepseek-v4-pro": true} for _, m := range models { if !wantIDs[m.ID] { t.Errorf("unexpected model ID %q", m.ID) } } } func TestProvider_Models_OverridesWin(t *testing.T) { custom := []flyto.ModelInfo{{ID: "custom-1", Provider: "deepseek"}} p := New(Config{APIKey: "sk-test", ModelOverrides: custom}) models, _ := p.Models(context.Background()) if len(models) != 1 || models[0].ID != "custom-1" { t.Errorf("ModelOverrides not applied: got %+v", models) } } func TestModelInfo_ADR0007Capabilities(t *testing.T) { for _, m := range deepseekModels { if m.ProviderKind != "direct" { t.Errorf("%s: ProviderKind = %q, want direct", m.ID, m.ProviderKind) } if m.ToolNameRegex != `^[a-zA-Z0-9_-]+$` { t.Errorf("%s: ToolNameRegex = %q, want ^[a-zA-Z0-9_-]+$", m.ID, m.ToolNameRegex) } // r24 真因 + 官方文档双确认: deepseek 必须 string mode passback. // r24 root cause + official docs: deepseek requires string-mode passback. if m.ReasoningPassbackMode != "string" { t.Errorf("%s: ReasoningPassbackMode = %q, want string", m.ID, m.ReasoningPassbackMode) } if !m.SupportsCaching { t.Errorf("%s: SupportsCaching = false, want true", m.ID) } if !m.SupportsThinking { t.Errorf("%s: SupportsThinking = false, want true", m.ID) } if m.SupportsVision { t.Errorf("%s: SupportsVision = true, want false (V4 文档无 image 支持)", m.ID) } if m.ContextWindow != 1_000_000 { t.Errorf("%s: ContextWindow = %d, want 1M", m.ID, m.ContextWindow) } if m.MaxOutputTokens != 384_000 { t.Errorf("%s: MaxOutputTokens = %d, want 384K", m.ID, m.MaxOutputTokens) } } } // TestModelInfo_Pricing verifies static price fields are populated to // non-zero with documented values from the official pricing page. cost=$0 // observability bug fix: pre-2026-05-02, all four price fields were 0 // per "TBD when consumers need it" comment, so EstimateCost returned 0 // even after engine auto-registers ModelInfo to ModelRegistry. // // Reference: https://api-docs.deepseek.com/quick_start/pricing (USD per // 1M tokens, list price; Pro 75% off promotion 2026-05-31 不入静态值). // // TestModelInfo_Pricing 验静态表价格字段非零, 修 cost=$0 显示 bug // (修前 4 字段全 0 注释明说 "TBD when consumers need it"). 数值取自 // 官方文档原价, Pro 当前 75% 折扣不入表 (静态表跨时间稳定). func TestModelInfo_Pricing(t *testing.T) { wantPrices := map[string]struct { input, output, cacheRead, cacheWrite float64 }{ "deepseek-v4-flash": {input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0}, "deepseek-v4-pro": {input: 0.435, output: 0.87, cacheRead: 0.003625, cacheWrite: 0}, } for _, m := range deepseekModels { want, ok := wantPrices[m.ID] if !ok { t.Errorf("unexpected model %q in static table (test needs update?)", m.ID) continue } if m.InputPricePer1M != want.input { t.Errorf("%s: InputPricePer1M = %v, want %v", m.ID, m.InputPricePer1M, want.input) } if m.OutputPricePer1M != want.output { t.Errorf("%s: OutputPricePer1M = %v, want %v", m.ID, m.OutputPricePer1M, want.output) } if m.CacheReadPricePer1M != want.cacheRead { t.Errorf("%s: CacheReadPricePer1M = %v, want %v", m.ID, m.CacheReadPricePer1M, want.cacheRead) } if m.CacheWritePricePer1M != want.cacheWrite { t.Errorf("%s: CacheWritePricePer1M = %v, want %v (DeepSeek auto-cache, no separate write fee)", m.ID, m.CacheWritePricePer1M, want.cacheWrite) } // Sanity: cache hit price must be substantially cheaper than miss // (DeepSeek doc: cache hit reduced to 1/10 of launch price, then // further to ~1/50 for Flash and ~1/120 for Pro). if m.SupportsCaching && m.CacheReadPricePer1M >= m.InputPricePer1M { t.Errorf("%s: cache read price (%v) should be much cheaper than input miss (%v)", m.ID, m.CacheReadPricePer1M, m.InputPricePer1M) } } } func TestResolveCapabilities_RegistryWins(t *testing.T) { caps := &flyto.ModelInfo{ ID: "deepseek-v4-flash", ReasoningPassbackMode: "details_array", ToolNameRegex: `^custom$`, } mode, regex := resolveCapabilities(&flyto.Request{Model: "deepseek-v4-flash", Capabilities: caps}) if mode != "details_array" { t.Errorf("registry mode lost: got %q, want details_array", mode) } if regex != `^custom$` { t.Errorf("registry regex lost: got %q, want ^custom$", regex) } } func TestResolveCapabilities_StaticFallback(t *testing.T) { mode, regex := resolveCapabilities(&flyto.Request{Model: "deepseek-v4-pro"}) if mode != "string" { t.Errorf("static fallback mode lost: got %q, want string (r24 真因)", mode) } if regex != `^[a-zA-Z0-9_-]+$` { t.Errorf("static fallback regex lost: got %q", regex) } } func TestResolveCapabilities_PartialRegistryFallback(t *testing.T) { // Registry only provides ReasoningPassbackMode; ToolNameRegex must // fall back to static table. caps := &flyto.ModelInfo{ ID: "deepseek-v4-flash", ReasoningPassbackMode: "none", } mode, regex := resolveCapabilities(&flyto.Request{Model: "deepseek-v4-flash", Capabilities: caps}) if mode != "none" { t.Errorf("registry override lost: got %q, want none", mode) } if regex != `^[a-zA-Z0-9_-]+$` { t.Errorf("regex fallback failed: got %q", regex) } } func TestResolveCapabilities_UnknownModelZeroValues(t *testing.T) { mode, regex := resolveCapabilities(&flyto.Request{Model: "deepseek-unknown-9000"}) if mode != "" || regex != "" { t.Errorf("unknown model should yield zero values, got mode=%q regex=%q", mode, regex) } } func TestDetectFeatureWarnings_NoWantNoWarning(t *testing.T) { p := New(Config{APIKey: "sk-test"}) if got := p.detectFeatureWarnings(&flyto.Request{Model: "deepseek-v4-flash"}); len(got) != 0 { t.Errorf("no thinking opt-in should yield zero warnings, got %d", len(got)) } } func TestDetectFeatureWarnings_SupportedNoWarning(t *testing.T) { p := New(Config{APIKey: "sk-test", ThinkingBudget: 1024}) if got := p.detectFeatureWarnings(&flyto.Request{Model: "deepseek-v4-flash"}); len(got) != 0 { t.Errorf("supported model should yield zero warnings, got %d", len(got)) } } func TestDetectFeatureWarnings_UnsupportedEmits(t *testing.T) { p := New(Config{APIKey: "sk-test", ThinkingBudget: 1024}) got := p.detectFeatureWarnings(&flyto.Request{Model: "deepseek-not-real"}) if len(got) != 1 { t.Fatalf("unsupported model should emit 1 warning, got %d", len(got)) } if got[0].Code != "feature_unsupported" { t.Errorf("warning code = %q, want feature_unsupported", got[0].Code) } } func TestConvertBlocks_AllSupportedTypes(t *testing.T) { blocks := []flyto.Block{ {Type: flyto.BlockText, Text: "hello"}, {Type: flyto.BlockThinking, ThinkingText: "reasoning", ProviderMetadata: map[string]string{"thinking_signature": "sig-1"}}, {Type: flyto.BlockToolUse, ToolUseID: "id-1", ToolName: "tool", ToolInput: map[string]any{"k": "v"}}, {Type: flyto.BlockToolResult, ToolUseID: "id-1", ResultText: "ok"}, } out, err := convertBlocks(blocks) if err != nil { t.Fatalf("convertBlocks err: %v", err) } if len(out) != 4 { t.Fatalf("len = %d, want 4", len(out)) } if out[0].Type != "text" || out[0].Text != "hello" { t.Errorf("text block wrong: %+v", out[0]) } if out[1].Type != "thinking" || out[1].Text != "reasoning" || out[1].Signature != "sig-1" { t.Errorf("thinking block wrong: %+v", out[1]) } if out[2].Type != "tool_use" || out[2].ID != "id-1" || out[2].Name != "tool" { t.Errorf("tool_use block wrong: %+v", out[2]) } if out[3].Type != "tool_result" || out[3].ToolUseID != "id-1" || out[3].Content != "ok" { t.Errorf("tool_result block wrong: %+v", out[3]) } } func TestConvertBlocks_UnknownTypeErrors(t *testing.T) { _, err := convertBlocks([]flyto.Block{{Type: "garbage"}}) if err == nil { t.Fatal("convertBlocks must error on unknown block type") } if !strings.Contains(err.Error(), "unsupported block type") { t.Errorf("err = %v, want contain 'unsupported block type'", err) } } func TestStream_RejectsImageBlock(t *testing.T) { p := New(Config{APIKey: "sk-test"}) req := &flyto.Request{ Model: "deepseek-v4-flash", Messages: []flyto.Message{{ Role: flyto.RoleUser, Blocks: []flyto.Block{ {Type: flyto.BlockImage, ImageSource: &flyto.ImageSource{SourceType: "base64", MediaType: "image/png"}}, }, }}, } _, err := p.Stream(context.Background(), req) if err == nil { t.Fatal("Stream must reject image blocks (DeepSeek V4 has no vision support)") } } func TestConfig_GoStringMasksKey(t *testing.T) { cfg := Config{APIKey: "sk-5f2da5de20ed4f6bb6e7403fa6b1333f"} out := cfg.GoString() if strings.Contains(out, "sk-5f2da5de20ed4f6bb6e7403fa6b1333f") { t.Errorf("GoString leaked full key: %s", out) } if !strings.Contains(out, "deepseek.Config") { t.Errorf("GoString missing type label: %s", out) } }