941 lines
32 KiB
Go
941 lines
32 KiB
Go
package config
|
|
|
|
import (
|
|
"path/filepath"
|
|
"reflect"
|
|
"testing"
|
|
|
|
"github.com/BurntSushi/toml"
|
|
|
|
"reasonix/internal/provider"
|
|
)
|
|
|
|
func hasModel(c *Config, model string) *ProviderEntry {
|
|
for i := range c.Providers {
|
|
for _, m := range c.Providers[i].ModelList() {
|
|
if m == model {
|
|
return &c.Providers[i]
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func TestBackfillDeepSeekProRestoresPro(t *testing.T) {
|
|
c := &Config{Providers: []ProviderEntry{
|
|
{Name: "deepseek-flash", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY"},
|
|
}}
|
|
backfillDeepSeekPro(c)
|
|
pro := hasModel(c, "deepseek-v4-pro")
|
|
if pro == nil {
|
|
t.Fatal("deepseek-v4-pro not restored")
|
|
} else if pro.Price == nil || pro.Price.Output != 6 || pro.Price.Currency != "¥" {
|
|
t.Errorf("pro price not the preset: %+v", pro.Price)
|
|
}
|
|
}
|
|
|
|
func TestBackfillDeepSeekProUsesConfiguredLanguage(t *testing.T) {
|
|
c := &Config{Language: "zh", Providers: []ProviderEntry{
|
|
{Name: "deepseek-flash", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY"},
|
|
}}
|
|
backfillDeepSeekPro(c)
|
|
pro := hasModel(c, "deepseek-v4-pro")
|
|
if pro == nil {
|
|
t.Fatal("deepseek-v4-pro not restored")
|
|
} else if pro.Price == nil || pro.Price.Output != 6 || pro.Price.Currency != "¥" {
|
|
t.Errorf("pro price = %+v, want CNY preset", pro.Price)
|
|
}
|
|
}
|
|
|
|
func TestBackfillDeepSeekProInheritsKeyEnv(t *testing.T) {
|
|
c := &Config{Providers: []ProviderEntry{
|
|
{Name: "deepseek-flash", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", APIKeyEnv: "MY_DS_KEY"},
|
|
}}
|
|
backfillDeepSeekPro(c)
|
|
if pro := hasModel(c, "deepseek-v4-pro"); pro == nil || pro.APIKeyEnv != "MY_DS_KEY" {
|
|
t.Errorf("pro should inherit the flash key env, got %+v", pro)
|
|
}
|
|
}
|
|
|
|
func TestBackfillDeepSeekProNoopWhenProPresent(t *testing.T) {
|
|
c := &Config{Providers: []ProviderEntry{
|
|
{Name: "deepseek-flash", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash"},
|
|
{Name: "deepseek-pro", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-pro"},
|
|
}}
|
|
backfillDeepSeekPro(c)
|
|
if n := len(c.Providers); n != 2 {
|
|
t.Errorf("providers grew to %d; should be a no-op when pro is present", n)
|
|
}
|
|
}
|
|
|
|
func TestBackfillDeepSeekProSkipsCustomEndpoint(t *testing.T) {
|
|
c := &Config{Providers: []ProviderEntry{
|
|
{Name: "myproxy", BaseURL: "https://proxy.example.com/v1", Model: "deepseek-v4-flash"},
|
|
}}
|
|
backfillDeepSeekPro(c)
|
|
if hasModel(c, "deepseek-v4-pro") != nil {
|
|
t.Error("must not add pro for a non-official endpoint that may not serve it")
|
|
}
|
|
}
|
|
|
|
func TestBackfillDeepSeekProSkipsNonDeepSeek(t *testing.T) {
|
|
c := &Config{Providers: []ProviderEntry{
|
|
{Name: "mimo-flash", BaseURL: "https://token-plan-cn.xiaomimimo.com/v1", Model: "mimo-v2.5"},
|
|
}}
|
|
backfillDeepSeekPro(c)
|
|
if len(c.Providers) != 1 {
|
|
t.Error("unrelated config must be untouched")
|
|
}
|
|
}
|
|
|
|
func TestNormalizeLegacyProviderModelsRepairsOfficialProvider(t *testing.T) {
|
|
c := &Config{Providers: []ProviderEntry{{
|
|
Name: "deepseek-flash",
|
|
Kind: "openai",
|
|
BaseURL: "https://api.deepseek.com",
|
|
APIKeyEnv: "DEEPSEEK_API_KEY",
|
|
}}}
|
|
normalizeLegacyProviderModels(c)
|
|
if got := c.Providers[0].Model; got != "deepseek-v4-flash" {
|
|
t.Fatalf("deepseek-flash model = %q, want deepseek-v4-flash", got)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeLegacyProviderModelsLeavesCustomProviderUntouched(t *testing.T) {
|
|
c := &Config{Providers: []ProviderEntry{{
|
|
Name: "custom",
|
|
Kind: "openai",
|
|
BaseURL: "https://proxy.example.com/v1",
|
|
}}}
|
|
normalizeLegacyProviderModels(c)
|
|
if got := c.Providers[0].Model; got != "" {
|
|
t.Fatalf("custom provider model = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeLegacyStepFunBaseURLsMigratesPresetProviders(t *testing.T) {
|
|
c := &Config{Providers: []ProviderEntry{
|
|
{
|
|
Name: "stepfun",
|
|
Kind: "openai",
|
|
BaseURL: legacyStepFunOpenAIBaseURL,
|
|
APIKeyEnv: "STEPFUN_API_KEY",
|
|
PresetID: "stepfun",
|
|
},
|
|
{
|
|
Name: "stepfun-anthropic",
|
|
Kind: "anthropic",
|
|
BaseURL: legacyStepFunAnthropicBaseURL + "/",
|
|
APIKeyEnv: "STEPFUN_API_KEY",
|
|
PresetID: "stepfun-anthropic",
|
|
},
|
|
{
|
|
Name: "custom-stepfun",
|
|
Kind: "openai",
|
|
BaseURL: legacyStepFunOpenAIBaseURL,
|
|
APIKeyEnv: "STEPFUN_API_KEY",
|
|
},
|
|
}}
|
|
|
|
if !normalizeLegacyStepFunBaseURLs(c) {
|
|
t.Fatal("legacy StepFun preset URL migration did not report a change")
|
|
}
|
|
if got := c.Providers[0].BaseURL; got != officialStepFunOpenAIBaseURL {
|
|
t.Fatalf("stepfun base_url = %q, want %q", got, officialStepFunOpenAIBaseURL)
|
|
}
|
|
if got := c.Providers[1].BaseURL; got != officialStepFunAnthropicBaseURL {
|
|
t.Fatalf("stepfun-anthropic base_url = %q, want %q", got, officialStepFunAnthropicBaseURL)
|
|
}
|
|
if got := c.Providers[2].BaseURL; got != legacyStepFunOpenAIBaseURL {
|
|
t.Fatalf("custom provider base_url = %q, want untouched legacy URL", got)
|
|
}
|
|
}
|
|
|
|
func TestLoadForEditPersistsLegacyStepFunBaseURLMigration(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "config.toml")
|
|
cfg := Default()
|
|
stepfun, ok := CuratedProviderPreset("stepfun")
|
|
if !ok || len(stepfun.Entries) != 1 {
|
|
t.Fatal("missing stepfun preset")
|
|
}
|
|
stepfunEntry := stepfun.Entries[0]
|
|
stepfunEntry.BaseURL = legacyStepFunOpenAIBaseURL
|
|
stepfunAnthropic, ok := CuratedProviderPreset("stepfun-anthropic")
|
|
if !ok || len(stepfunAnthropic.Entries) != 1 {
|
|
t.Fatal("missing stepfun-anthropic preset")
|
|
}
|
|
stepfunAnthropicEntry := stepfunAnthropic.Entries[0]
|
|
stepfunAnthropicEntry.BaseURL = legacyStepFunAnthropicBaseURL
|
|
cfg.Providers = append(cfg.Providers, stepfunEntry, stepfunAnthropicEntry)
|
|
cfg.Desktop.ProviderAccess = []string{"stepfun", "stepfun-anthropic"}
|
|
if err := cfg.SaveTo(path); err != nil {
|
|
t.Fatalf("SaveTo: %v", err)
|
|
}
|
|
|
|
loaded := LoadForEdit(path)
|
|
if got, _ := loaded.Provider("stepfun"); got == nil || got.BaseURL != officialStepFunOpenAIBaseURL {
|
|
t.Fatalf("loaded stepfun = %+v, want official base URL", got)
|
|
}
|
|
if got, _ := loaded.Provider("stepfun-anthropic"); got == nil || got.BaseURL != officialStepFunAnthropicBaseURL {
|
|
t.Fatalf("loaded stepfun-anthropic = %+v, want official base URL", got)
|
|
}
|
|
|
|
var disk Config
|
|
if _, err := toml.DecodeFile(path, &disk); err != nil {
|
|
t.Fatalf("decode persisted config: %v", err)
|
|
}
|
|
if got, _ := disk.Provider("stepfun"); got == nil || got.BaseURL != officialStepFunOpenAIBaseURL {
|
|
t.Fatalf("persisted stepfun = %+v, want official base URL", got)
|
|
}
|
|
if got, _ := disk.Provider("stepfun-anthropic"); got == nil || got.BaseURL != officialStepFunAnthropicBaseURL {
|
|
t.Fatalf("persisted stepfun-anthropic = %+v, want official base URL", got)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeDesktopOfficialProviderAccessCanonicalizesOnlyDeepSeekIDs(t *testing.T) {
|
|
c := Default()
|
|
c.DefaultModel = "deepseek-flash/deepseek-v4-pro"
|
|
c.Desktop.ProviderAccess = []string{"deepseek-flash", "mimo-pro"}
|
|
normalizeDesktopOfficialProviderAccess(c)
|
|
if len(c.Desktop.ProviderAccess) != 2 || c.Desktop.ProviderAccess[0] != "deepseek" || c.Desktop.ProviderAccess[1] != "mimo-pro" {
|
|
t.Fatalf("provider_access = %+v, want only DeepSeek canonicalized", c.Desktop.ProviderAccess)
|
|
}
|
|
if c.DefaultModel != "deepseek/deepseek-v4-pro" {
|
|
t.Fatalf("default_model = %q, want deepseek/deepseek-v4-pro", c.DefaultModel)
|
|
}
|
|
if _, ok := c.Provider("deepseek"); !ok {
|
|
t.Fatal("canonical deepseek provider missing")
|
|
}
|
|
if _, ok := c.Provider("mimo-token-plan"); ok {
|
|
t.Fatal("mimo-token-plan should not be injected as an official provider")
|
|
}
|
|
}
|
|
|
|
func TestNormalizeDesktopOfficialProviderAccessBackfillsOfficialContextWindow(t *testing.T) {
|
|
c := &Config{
|
|
Desktop: DesktopConfig{ProviderAccess: []string{"deepseek-flash", "mimo-api", "mimo-token-plan"}},
|
|
Providers: []ProviderEntry{
|
|
{
|
|
Name: "deepseek-flash",
|
|
Kind: "openai",
|
|
BaseURL: "https://api.deepseek.com",
|
|
Model: "deepseek-v4-flash",
|
|
APIKeyEnv: "DEEPSEEK_API_KEY",
|
|
},
|
|
{
|
|
Name: "mimo-api",
|
|
Kind: "openai",
|
|
BaseURL: "https://api.xiaomimimo.com/v1",
|
|
Model: "mimo-v2.5-pro",
|
|
APIKeyEnv: "MIMO_API_KEY",
|
|
},
|
|
{
|
|
Name: "mimo-token-plan",
|
|
Kind: "openai",
|
|
BaseURL: "https://token-plan-cn.xiaomimimo.com/v1",
|
|
Model: "mimo-v2.5-pro",
|
|
APIKeyEnv: "MIMO_API_KEY",
|
|
},
|
|
},
|
|
}
|
|
|
|
normalizeDesktopOfficialProviderAccess(c)
|
|
|
|
deepseek, ok := c.Provider("deepseek")
|
|
if !ok {
|
|
t.Fatal("deepseek provider missing")
|
|
}
|
|
if deepseek.ContextWindow != 1_000_000 {
|
|
t.Fatalf("deepseek context_window = %d, want official default", deepseek.ContextWindow)
|
|
}
|
|
mimoAPI, ok := c.Provider("mimo-api")
|
|
if !ok {
|
|
t.Fatal("mimo-api provider missing")
|
|
}
|
|
if mimoAPI.ContextWindow != 1_048_576 {
|
|
t.Fatalf("mimo-api context_window = %d, want migrated MiMo default", mimoAPI.ContextWindow)
|
|
}
|
|
mimoTokenPlan, ok := c.Provider("mimo-token-plan")
|
|
if !ok {
|
|
t.Fatal("mimo-token-plan provider missing")
|
|
}
|
|
if mimoTokenPlan.ContextWindow != 1_048_576 {
|
|
t.Fatalf("mimo-token-plan context_window = %d, want migrated MiMo default", mimoTokenPlan.ContextWindow)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeDesktopOfficialProviderAccessKeepsCustomAlias(t *testing.T) {
|
|
c := &Config{
|
|
Desktop: DesktopConfig{ProviderAccess: []string{"deepseek-flash"}},
|
|
Providers: []ProviderEntry{{
|
|
Name: "deepseek-flash",
|
|
Kind: "openai",
|
|
BaseURL: "https://proxy.example/v1",
|
|
Model: "deepseek-v4-flash",
|
|
}},
|
|
}
|
|
|
|
normalizeDesktopOfficialProviderAccess(c)
|
|
|
|
if len(c.Desktop.ProviderAccess) != 1 || c.Desktop.ProviderAccess[0] != "deepseek-flash" {
|
|
t.Fatalf("provider_access = %+v, want custom alias preserved", c.Desktop.ProviderAccess)
|
|
}
|
|
if _, ok := c.Provider("deepseek"); ok {
|
|
t.Fatal("custom deepseek-flash proxy should not create canonical deepseek provider")
|
|
}
|
|
}
|
|
|
|
func TestNormalizeOfficialDeepSeekModelsRepairsCanonicalProvider(t *testing.T) {
|
|
c := &Config{
|
|
DefaultModel: "deepseek-flash/deepseek-v4-flash",
|
|
Desktop: DesktopConfig{ProviderAccess: []string{"deepseek"}},
|
|
Providers: []ProviderEntry{{
|
|
Name: "deepseek",
|
|
Kind: "openai",
|
|
BaseURL: "https://api.deepseek.com",
|
|
Model: "glm-5",
|
|
APIKeyEnv: "DEEPSEEK_API_KEY",
|
|
}},
|
|
}
|
|
normalizeDesktopOfficialProviderAccess(c)
|
|
normalizeOfficialDeepSeekModels(c)
|
|
|
|
p, ok := c.Provider("deepseek")
|
|
if !ok {
|
|
t.Fatal("deepseek provider missing")
|
|
}
|
|
if !p.HasModel("deepseek-v4-flash") || !p.HasModel("deepseek-v4-pro") || !p.HasModel("glm-5") {
|
|
t.Fatalf("deepseek models = %+v, want official models plus existing model", p.ModelList())
|
|
}
|
|
if c.DefaultModel != "deepseek/deepseek-v4-flash" {
|
|
t.Fatalf("default_model = %q, want retargeted official ref", c.DefaultModel)
|
|
}
|
|
if _, ok := c.ResolveModel(c.DefaultModel); !ok {
|
|
t.Fatalf("retargeted default_model %q should resolve", c.DefaultModel)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeOfficialDeepSeekModelsLeavesExternalEndpointUntouched(t *testing.T) {
|
|
c := &Config{Providers: []ProviderEntry{{
|
|
Name: "deepseek",
|
|
Kind: "openai",
|
|
BaseURL: "https://proxy.example.com/v1",
|
|
Model: "glm-5",
|
|
}}}
|
|
normalizeOfficialDeepSeekModels(c)
|
|
|
|
p, ok := c.Provider("deepseek")
|
|
if !ok {
|
|
t.Fatal("deepseek provider missing")
|
|
}
|
|
if p.HasModel("deepseek-v4-flash") || p.HasModel("deepseek-v4-pro") {
|
|
t.Fatalf("external endpoint models = %+v, want untouched custom list", p.ModelList())
|
|
}
|
|
}
|
|
|
|
func TestNormalizeLegacyMimoCustomProvidersEnsuresReferencedMimoAPI(t *testing.T) {
|
|
c := Default()
|
|
c.DefaultModel = "mimo-api/mimo-v2.5-pro"
|
|
c.Desktop.ProviderAccess = []string{"mimo-api"}
|
|
if !normalizeLegacyMimoCustomProviders(c) {
|
|
t.Fatal("legacy MiMo migration did not report a change")
|
|
}
|
|
p, ok := c.Provider("mimo-api")
|
|
if !ok {
|
|
t.Fatal("mimo-api paid provider missing")
|
|
}
|
|
if !p.HasModel("mimo-v2.5") || !p.HasModel("mimo-v2-omni") {
|
|
t.Fatalf("mimo-api models = %v, want vision-capable MiMo models", p.ModelList())
|
|
}
|
|
normalizeDesktopOfficialProviderAccess(c)
|
|
if got := c.Desktop.ProviderAccess; len(got) != 1 || got[0] != "mimo-api" {
|
|
t.Fatalf("provider_access = %+v, want mimo-api", got)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeLegacyMimoCustomProvidersRecognizesBareModelRefs(t *testing.T) {
|
|
c := Default()
|
|
c.DefaultModel = "mimo-v2.5-pro"
|
|
if !normalizeLegacyMimoCustomProviders(c) {
|
|
t.Fatal("legacy bare MiMo model migration did not report a change")
|
|
}
|
|
p, ok := c.Provider("mimo-pro")
|
|
if !ok {
|
|
t.Fatal("mimo-pro provider missing")
|
|
}
|
|
if p.Model != "mimo-v2.5-pro" {
|
|
t.Fatalf("mimo-pro model = %q, want mimo-v2.5-pro", p.Model)
|
|
}
|
|
if e, ok := c.ResolveModel("mimo-v2.5-pro"); !ok || e.Name != "mimo-pro" {
|
|
t.Fatalf("bare MiMo model resolved to %+v/%v, want mimo-pro", e, ok)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeLegacyMimoCustomProvidersScansBotRefs(t *testing.T) {
|
|
c := Default()
|
|
c.Bot.Model = "mimo-pro"
|
|
c.Bot.Connections = []BotConnectionConfig{{Model: "mimo-flash"}}
|
|
if !normalizeLegacyMimoCustomProviders(c) {
|
|
t.Fatal("legacy bot MiMo migration did not report a change")
|
|
}
|
|
if _, ok := c.Provider("mimo-pro"); !ok {
|
|
t.Fatal("mimo-pro provider missing")
|
|
}
|
|
if _, ok := c.Provider("mimo-flash"); !ok {
|
|
t.Fatal("mimo-flash provider missing")
|
|
}
|
|
}
|
|
|
|
func TestNormalizeLegacyDesktopProviderAccessIncludesUnconfiguredMimoRefs(t *testing.T) {
|
|
c := Default()
|
|
c.DefaultModel = "mimo-pro"
|
|
c.Bot.Connections = []BotConnectionConfig{{Model: "mimo-flash"}}
|
|
normalizeLegacyMimoCustomProviders(c)
|
|
NormalizeLegacyDesktopProviderAccess(c)
|
|
access := desktopProviderAccessMap(c.Desktop.ProviderAccess)
|
|
if !access["mimo-pro"] || !access["mimo-flash"] {
|
|
t.Fatalf("provider_access = %+v, want unconfigured migrated MiMo refs visible", c.Desktop.ProviderAccess)
|
|
}
|
|
}
|
|
|
|
func TestBackfillDeepSeekOfficialPrices(t *testing.T) {
|
|
c := &Config{Providers: []ProviderEntry{{
|
|
Name: "deepseek",
|
|
Kind: "openai",
|
|
BaseURL: "https://api.deepseek.com",
|
|
Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"},
|
|
Default: "deepseek-v4-flash",
|
|
}}}
|
|
backfillDeepSeekOfficialPrices(c)
|
|
p, ok := c.Provider("deepseek")
|
|
if !ok {
|
|
t.Fatal("deepseek provider missing")
|
|
}
|
|
if p.Prices["deepseek-v4-flash"].Output != 2 || p.Prices["deepseek-v4-pro"].Output != 6 {
|
|
t.Fatalf("deepseek prices = %+v, want current V4 flash/pro prices", p.Prices)
|
|
}
|
|
}
|
|
|
|
func TestBackfillDeepSeekOfficialPricesUsesConfiguredLanguage(t *testing.T) {
|
|
c := &Config{Language: "zh", Providers: []ProviderEntry{{
|
|
Name: "deepseek",
|
|
Kind: "openai",
|
|
BaseURL: "https://api.deepseek.com",
|
|
Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"},
|
|
Default: "deepseek-v4-flash",
|
|
}}}
|
|
backfillDeepSeekOfficialPrices(c)
|
|
p, ok := c.Provider("deepseek")
|
|
if !ok {
|
|
t.Fatal("deepseek provider missing")
|
|
}
|
|
if p.Prices["deepseek-v4-flash"].Output != 2 || p.Prices["deepseek-v4-flash"].Currency != "¥" || p.Prices["deepseek-v4-pro"].Output != 6 || p.Prices["deepseek-v4-pro"].Currency != "¥" {
|
|
t.Fatalf("deepseek prices = %+v, want CNY flash/pro prices", p.Prices)
|
|
}
|
|
}
|
|
|
|
func TestBackfillDeepSeekOfficialPricesKeepsProviderWidePrice(t *testing.T) {
|
|
custom := &provider.Pricing{CacheHit: 9, Input: 9, Output: 9, Currency: "$"}
|
|
c := &Config{Providers: []ProviderEntry{{
|
|
Name: "deepseek",
|
|
Kind: "openai",
|
|
BaseURL: "https://api.deepseek.com",
|
|
Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"},
|
|
Default: "deepseek-v4-flash",
|
|
Price: custom,
|
|
Prices: map[string]*provider.Pricing{
|
|
"deepseek-v4-flash": {CacheHit: 1, Input: 1, Output: 1, Currency: "$"},
|
|
},
|
|
}}}
|
|
backfillDeepSeekOfficialPrices(c)
|
|
p, ok := c.Provider("deepseek")
|
|
if !ok {
|
|
t.Fatal("deepseek provider missing")
|
|
}
|
|
if len(p.Prices) != 1 {
|
|
t.Fatalf("deepseek prices = %+v, want existing per-model prices only", p.Prices)
|
|
}
|
|
pro, ok := c.ResolveModel("deepseek/deepseek-v4-pro")
|
|
if !ok {
|
|
t.Fatal("deepseek pro did not resolve")
|
|
}
|
|
if pro.Price == nil || pro.Price.Output != 9 {
|
|
t.Fatalf("pro price = %+v, want provider-wide custom price", pro.Price)
|
|
}
|
|
flash, ok := c.ResolveModel("deepseek")
|
|
if !ok {
|
|
t.Fatal("deepseek default did not resolve")
|
|
}
|
|
if flash.Price == nil || flash.Price.Output != 1 {
|
|
t.Fatalf("flash price = %+v, want existing per-model custom price", flash.Price)
|
|
}
|
|
}
|
|
|
|
func TestApplyDeepSeekOfficialDefaultPricingUsesConfiguredLanguage(t *testing.T) {
|
|
c := Default()
|
|
c.Language = "zh"
|
|
applyDeepSeekOfficialDefaultPricing(c)
|
|
flash, ok := c.Provider("deepseek-flash")
|
|
if !ok {
|
|
t.Fatal("deepseek-flash provider missing")
|
|
}
|
|
if flash.Price == nil || flash.Price.Output != 2 || flash.Price.Currency != "¥" {
|
|
t.Fatalf("flash price = %+v, want CNY preset", flash.Price)
|
|
}
|
|
pro, ok := c.Provider("deepseek-pro")
|
|
if !ok {
|
|
t.Fatal("deepseek-pro provider missing")
|
|
}
|
|
if pro.Price == nil || pro.Price.Output != 6 || pro.Price.Currency != "¥" {
|
|
t.Fatalf("pro price = %+v, want CNY preset", pro.Price)
|
|
}
|
|
}
|
|
|
|
func TestApplyDeepSeekOfficialDefaultPricingKeepsCustomPrice(t *testing.T) {
|
|
c := &Config{Language: "zh", Providers: []ProviderEntry{{
|
|
Name: "deepseek-flash",
|
|
Kind: "openai",
|
|
BaseURL: "https://api.deepseek.com",
|
|
Model: "deepseek-v4-flash",
|
|
Price: &provider.Pricing{CacheHit: 9, Input: 9, Output: 9, Currency: "$"},
|
|
}}}
|
|
applyDeepSeekOfficialDefaultPricing(c)
|
|
p, ok := c.Provider("deepseek-flash")
|
|
if !ok {
|
|
t.Fatal("deepseek-flash provider missing")
|
|
}
|
|
if p.Price == nil || p.Price.Output != 9 || p.Price.Currency != "$" {
|
|
t.Fatalf("custom price = %+v, want unchanged", p.Price)
|
|
}
|
|
}
|
|
|
|
func TestResetOfficialProviderPricingOnUpgradeRunsOnce(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "reasonix.toml")
|
|
c := &Config{
|
|
ConfigVersion: 2,
|
|
Providers: []ProviderEntry{
|
|
{
|
|
Name: "deepseek",
|
|
Kind: "openai",
|
|
BaseURL: "https://api.deepseek.com",
|
|
Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"},
|
|
Default: "deepseek-v4-flash",
|
|
Price: &provider.Pricing{CacheHit: 9, Input: 9, Output: 9, Currency: "$"},
|
|
Prices: map[string]*provider.Pricing{
|
|
"deepseek-v4-flash": {CacheHit: 8, Input: 8, Output: 8, Currency: "$"},
|
|
},
|
|
},
|
|
{
|
|
Name: "mimo-api",
|
|
Kind: "openai",
|
|
BaseURL: "https://api.xiaomimimo.com/v1",
|
|
Models: []string{"mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-omni"},
|
|
Default: "mimo-v2.5-pro",
|
|
Price: &provider.Pricing{CacheHit: 7, Input: 7, Output: 7, Currency: "$"},
|
|
},
|
|
},
|
|
}
|
|
if err := c.SaveTo(path); err != nil {
|
|
t.Fatalf("SaveTo: %v", err)
|
|
}
|
|
|
|
changed, err := ResetOfficialProviderPricingOnUpgrade(path)
|
|
if err != nil {
|
|
t.Fatalf("ResetOfficialProviderPricingOnUpgrade: %v", err)
|
|
}
|
|
if !changed {
|
|
t.Fatal("upgrade reset did not run for config_version 2")
|
|
}
|
|
var got Config
|
|
if _, err := toml.DecodeFile(path, &got); err != nil {
|
|
t.Fatalf("decode migrated config: %v", err)
|
|
}
|
|
if got.ConfigVersion != Default().ConfigVersion {
|
|
t.Fatalf("config_version = %d, want %d", got.ConfigVersion, Default().ConfigVersion)
|
|
}
|
|
deepseek, ok := got.Provider("deepseek")
|
|
if !ok {
|
|
t.Fatal("deepseek provider missing")
|
|
}
|
|
if deepseek.Price != nil {
|
|
t.Fatalf("deepseek provider-wide price = %+v, want nil after reset", deepseek.Price)
|
|
}
|
|
if p := deepseek.Prices["deepseek-v4-flash"]; p == nil || p.Currency != "¥" || p.Output != 2 {
|
|
t.Fatalf("deepseek flash price = %+v, want RMB default", p)
|
|
}
|
|
if p := deepseek.Prices["deepseek-v4-pro"]; p == nil || p.Currency != "¥" || p.Output != 6 {
|
|
t.Fatalf("deepseek pro price = %+v, want RMB default", p)
|
|
}
|
|
mimo, ok := got.Provider("mimo-api")
|
|
if !ok {
|
|
t.Fatal("mimo-api provider missing")
|
|
}
|
|
if mimo.Price == nil || mimo.Price.Currency != "$" || mimo.Price.Output != 7 {
|
|
t.Fatalf("mimo provider-wide price = %+v, want custom price preserved", mimo.Price)
|
|
}
|
|
|
|
deepseek.Prices["deepseek-v4-flash"] = &provider.Pricing{CacheHit: 4, Input: 4, Output: 4, Currency: "$"}
|
|
if err := got.SaveTo(path); err != nil {
|
|
t.Fatalf("SaveTo after custom edit: %v", err)
|
|
}
|
|
changed, err = ResetOfficialProviderPricingOnUpgrade(path)
|
|
if err != nil {
|
|
t.Fatalf("second ResetOfficialProviderPricingOnUpgrade: %v", err)
|
|
}
|
|
if changed {
|
|
t.Fatal("upgrade reset ran again after config_version was updated")
|
|
}
|
|
got = Config{}
|
|
if _, err := toml.DecodeFile(path, &got); err != nil {
|
|
t.Fatalf("decode custom config: %v", err)
|
|
}
|
|
deepseek, _ = got.Provider("deepseek")
|
|
if p := deepseek.Prices["deepseek-v4-flash"]; p == nil || p.Output != 4 || p.Currency != "$" {
|
|
t.Fatalf("post-upgrade custom flash price = %+v, want preserved", p)
|
|
}
|
|
}
|
|
|
|
func TestApplyUserConfigUpgradesOnStartupVersion3NonWindowsNoop(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "config.toml")
|
|
setRuntimeGOOS(t, "darwin")
|
|
|
|
c := &Config{
|
|
ConfigVersion: 3,
|
|
Providers: []ProviderEntry{{
|
|
Name: "deepseek",
|
|
Kind: "openai",
|
|
BaseURL: "https://api.deepseek.com",
|
|
Models: []string{"deepseek-v4-flash"},
|
|
Prices: map[string]*provider.Pricing{
|
|
"deepseek-v4-flash": {CacheHit: 4, Input: 4, Output: 4, Currency: "$"},
|
|
},
|
|
}},
|
|
}
|
|
if err := c.SaveTo(path); err != nil {
|
|
t.Fatalf("SaveTo: %v", err)
|
|
}
|
|
|
|
changed, err := ApplyUserConfigUpgradesOnStartup(path)
|
|
if err != nil {
|
|
t.Fatalf("ApplyUserConfigUpgradesOnStartup: %v", err)
|
|
}
|
|
if changed {
|
|
t.Fatal("v3 non-Windows config should not be rewritten by the Windows bash sandbox migration")
|
|
}
|
|
var got Config
|
|
if _, err := toml.DecodeFile(path, &got); err != nil {
|
|
t.Fatalf("decode migrated config: %v", err)
|
|
}
|
|
if got.ConfigVersion != 3 {
|
|
t.Fatalf("config_version = %d, want 3", got.ConfigVersion)
|
|
}
|
|
deepseek, _ := got.Provider("deepseek")
|
|
if p := deepseek.Prices["deepseek-v4-flash"]; p == nil || p.Output != 4 || p.Currency != "$" {
|
|
t.Fatalf("custom flash price = %+v, want preserved", p)
|
|
}
|
|
}
|
|
|
|
func TestApplyUserConfigUpgradesOnStartupWindowsBashEnforceDefaultsOffOnce(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "config.toml")
|
|
setRuntimeGOOS(t, "windows")
|
|
|
|
c := Default()
|
|
c.ConfigVersion = 3
|
|
c.Sandbox.Bash = "enforce"
|
|
if err := c.SaveTo(path); err != nil {
|
|
t.Fatalf("SaveTo: %v", err)
|
|
}
|
|
|
|
changed, err := ApplyUserConfigUpgradesOnStartup(path)
|
|
if err != nil {
|
|
t.Fatalf("ApplyUserConfigUpgradesOnStartup: %v", err)
|
|
}
|
|
if !changed {
|
|
t.Fatal("upgrade should migrate Windows bash sandbox default")
|
|
}
|
|
got := LoadForEdit(path)
|
|
if got.ConfigVersion != Default().ConfigVersion {
|
|
t.Fatalf("config_version = %d, want %d", got.ConfigVersion, Default().ConfigVersion)
|
|
}
|
|
if got.Sandbox.Bash != "off" || got.BashMode() != "off" {
|
|
t.Fatalf("Windows bash mode after migration = raw %q effective %q, want off/off", got.Sandbox.Bash, got.BashMode())
|
|
}
|
|
|
|
got.Sandbox.Bash = "enforce"
|
|
if got.BashMode() != "off" {
|
|
t.Fatalf("manual Windows enforce should still resolve off before save, got %q", got.BashMode())
|
|
}
|
|
if err := got.SaveTo(path); err != nil {
|
|
t.Fatalf("SaveTo manual enforce: %v", err)
|
|
}
|
|
changed, err = ApplyUserConfigUpgradesOnStartup(path)
|
|
if err != nil {
|
|
t.Fatalf("second ApplyUserConfigUpgradesOnStartup: %v", err)
|
|
}
|
|
if changed {
|
|
t.Fatal("v4 config should not be migrated again after user attempts to re-enable enforce")
|
|
}
|
|
got = LoadForEdit(path)
|
|
if got.Sandbox.Bash != "off" || got.BashMode() != "off" {
|
|
t.Fatalf("manual Windows enforce after save = raw %q effective %q, want off/off", got.Sandbox.Bash, got.BashMode())
|
|
}
|
|
}
|
|
|
|
func TestApplyUserConfigUpgradesOnStartupWindowsBashOffOnlyMarksVersion(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "config.toml")
|
|
setRuntimeGOOS(t, "windows")
|
|
|
|
c := Default()
|
|
c.ConfigVersion = 3
|
|
c.Sandbox.Bash = "off"
|
|
if err := c.SaveTo(path); err != nil {
|
|
t.Fatalf("SaveTo: %v", err)
|
|
}
|
|
|
|
changed, err := ApplyUserConfigUpgradesOnStartup(path)
|
|
if err != nil {
|
|
t.Fatalf("ApplyUserConfigUpgradesOnStartup: %v", err)
|
|
}
|
|
if !changed {
|
|
t.Fatal("Windows v3 config should be marked as migrated")
|
|
}
|
|
got := LoadForEdit(path)
|
|
if got.ConfigVersion != Default().ConfigVersion {
|
|
t.Fatalf("config_version = %d, want %d", got.ConfigVersion, Default().ConfigVersion)
|
|
}
|
|
if got.Sandbox.Bash != "off" || got.BashMode() != "off" {
|
|
t.Fatalf("Windows bash mode after marker migration = raw %q effective %q, want off/off", got.Sandbox.Bash, got.BashMode())
|
|
}
|
|
}
|
|
|
|
func TestResolveModelUsesPerModelPricing(t *testing.T) {
|
|
c := &Config{Providers: []ProviderEntry{{
|
|
Name: "deepseek",
|
|
Kind: "openai",
|
|
BaseURL: "https://api.deepseek.com",
|
|
Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"},
|
|
Default: "deepseek-v4-flash",
|
|
Price: &provider.Pricing{CacheHit: 9, Input: 9, Output: 9, Currency: "$"},
|
|
Prices: map[string]*provider.Pricing{
|
|
"deepseek-v4-flash": &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: "¥"},
|
|
"deepseek-v4-pro": &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: "¥"},
|
|
},
|
|
}}}
|
|
pro, ok := c.ResolveModel("deepseek/deepseek-v4-pro")
|
|
if !ok {
|
|
t.Fatal("deepseek pro did not resolve")
|
|
}
|
|
if pro.Price == nil || pro.Price.Output != 6 {
|
|
t.Fatalf("pro price = %+v, want model-specific pro price", pro.Price)
|
|
}
|
|
flash, ok := c.ResolveModel("deepseek")
|
|
if !ok {
|
|
t.Fatal("deepseek default did not resolve")
|
|
}
|
|
if flash.Price == nil || flash.Price.Output != 2 {
|
|
t.Fatalf("flash price = %+v, want model-specific flash price", flash.Price)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeLegacyMimoProviderCatalogsBackfillsOfficialMimoAPIStub(t *testing.T) {
|
|
c := &Config{
|
|
DefaultModel: "mimo-api/mimo-v2.5-pro",
|
|
Desktop: DesktopConfig{ProviderAccess: []string{"mimo-api"}},
|
|
Providers: []ProviderEntry{{
|
|
Name: "mimo-api",
|
|
Kind: "openai",
|
|
BaseURL: "https://api.xiaomimimo.com/v1",
|
|
Model: "mimo-v2.5-pro",
|
|
APIKeyEnv: "MIMO_API_KEY",
|
|
}},
|
|
}
|
|
|
|
normalizeDesktopOfficialProviderAccess(c)
|
|
|
|
p, ok := c.Provider("mimo-api")
|
|
if !ok {
|
|
t.Fatal("mimo-api provider missing")
|
|
}
|
|
wantModels := []string{"mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-omni"}
|
|
if !reflect.DeepEqual(p.ModelList(), wantModels) {
|
|
t.Fatalf("mimo-api models = %v, want %v", p.ModelList(), wantModels)
|
|
}
|
|
if p.Default != "mimo-v2.5-pro" {
|
|
t.Fatalf("mimo-api default = %q, want mimo-v2.5-pro", p.Default)
|
|
}
|
|
if !reflect.DeepEqual(p.VisionModels, []string{"mimo-v2.5", "mimo-v2-omni"}) {
|
|
t.Fatalf("mimo-api vision_models = %v, want vision-capable MiMo models", p.VisionModels)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeDesktopOfficialProviderAccessDoesNotBackfillCustomNamedMimoAPI(t *testing.T) {
|
|
c := &Config{
|
|
Desktop: DesktopConfig{ProviderAccess: []string{"mimo-api"}},
|
|
Providers: []ProviderEntry{{
|
|
Name: "mimo-api",
|
|
Kind: "openai",
|
|
BaseURL: "https://proxy.example.com/v1",
|
|
Model: "mimo-v2.5-pro",
|
|
}},
|
|
}
|
|
|
|
normalizeDesktopOfficialProviderAccess(c)
|
|
|
|
p, ok := c.Provider("mimo-api")
|
|
if !ok {
|
|
t.Fatal("mimo-api provider missing")
|
|
}
|
|
if p.HasModel("mimo-v2.5") || p.HasModel("mimo-v2-omni") {
|
|
t.Fatalf("custom mimo-api models = %v, want original custom list", p.ModelList())
|
|
}
|
|
}
|
|
|
|
func TestNormalizeLegacyMimoProviderCatalogsBackfillsOfficialMimoTokenPlanStub(t *testing.T) {
|
|
c := &Config{
|
|
Desktop: DesktopConfig{ProviderAccess: []string{"mimo-token-plan"}},
|
|
Providers: []ProviderEntry{{
|
|
Name: "mimo-token-plan",
|
|
Kind: "openai",
|
|
BaseURL: "https://token-plan-cn.xiaomimimo.com/v1",
|
|
Model: "mimo-v2.5-pro",
|
|
APIKeyEnv: "MIMO_API_KEY",
|
|
Price: &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: "CNY"},
|
|
}},
|
|
}
|
|
|
|
normalizeDesktopOfficialProviderAccess(c)
|
|
|
|
p, ok := c.Provider("mimo-token-plan")
|
|
if !ok {
|
|
t.Fatal("mimo-token-plan provider missing")
|
|
}
|
|
if !reflect.DeepEqual(p.ModelList(), []string{"mimo-v2.5-pro", "mimo-v2.5"}) {
|
|
t.Fatalf("mimo-token-plan models = %v, want token plan catalog", p.ModelList())
|
|
}
|
|
if p.Price == nil || p.Price.Currency != "CNY" {
|
|
t.Fatalf("mimo-token-plan price = %+v, want preserved custom provider-wide price", p.Price)
|
|
}
|
|
if !reflect.DeepEqual(p.VisionModels, []string{"mimo-v2.5"}) {
|
|
t.Fatalf("mimo-token-plan vision_models = %v, want token plan vision metadata", p.VisionModels)
|
|
}
|
|
}
|
|
|
|
// ── Explicit model list: normalization must not override user selection ───────
|
|
|
|
func TestBackfillDeepSeekProSkipsWhenExplicitModelList(t *testing.T) {
|
|
// User saved with only flash via Settings → Models = ["deepseek-v4-flash"].
|
|
c := &Config{Providers: []ProviderEntry{
|
|
{Name: "deepseek", Kind: "openai", BaseURL: "https://api.deepseek.com", Models: []string{"deepseek-v4-flash"}, Default: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY"},
|
|
}}
|
|
backfillDeepSeekPro(c)
|
|
if hasModel(c, "deepseek-v4-pro") != nil {
|
|
t.Fatal("deepseek-v4-pro must not be added when user has an explicit model list")
|
|
}
|
|
if len(c.Providers) != 1 {
|
|
t.Fatalf("providers = %d, want 1 (no new entry should be added)", len(c.Providers))
|
|
}
|
|
}
|
|
|
|
func TestEnsureProviderModelsSkipsWhenExplicitModelList(t *testing.T) {
|
|
// User saved with only flash via Settings → Models = ["deepseek-v4-flash"].
|
|
p := &ProviderEntry{
|
|
Name: "deepseek",
|
|
BaseURL: "https://api.deepseek.com",
|
|
Models: []string{"deepseek-v4-flash"},
|
|
Default: "deepseek-v4-flash",
|
|
}
|
|
ensureProviderModels(p, []string{"deepseek-v4-flash", "deepseek-v4-pro"}, "deepseek-v4-flash")
|
|
if p.HasModel("deepseek-v4-pro") {
|
|
t.Fatal("ensureProviderModels must not merge required models when Models is explicitly set")
|
|
}
|
|
if len(p.Models) != 1 || p.Models[0] != "deepseek-v4-flash" {
|
|
t.Fatalf("models = %v, want [deepseek-v4-flash]", p.Models)
|
|
}
|
|
}
|
|
|
|
func TestMergeCuratedModelsIntoProviderSkipsWhenExplicitModelList(t *testing.T) {
|
|
// User saved with only two mimo models via Settings.
|
|
p := &ProviderEntry{
|
|
Name: "mimo-api",
|
|
BaseURL: "https://api.xiaomimimo.com/v1",
|
|
Models: []string{"mimo-v2.5-pro", "mimo-v2.5"},
|
|
Default: "mimo-v2.5-pro",
|
|
}
|
|
mergeCuratedModelsIntoProvider(p, []string{"mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-omni"}, "mimo-v2.5-pro")
|
|
if p.HasModel("mimo-v2-omni") {
|
|
t.Fatal("mergeCuratedModelsIntoProvider must not add mimo-v2-omni when Models is explicitly set")
|
|
}
|
|
if len(p.Models) != 2 {
|
|
t.Fatalf("models = %v, want 2 selected models", p.Models)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeOfficialMimoVisionModelsSkipsExplicitModelList(t *testing.T) {
|
|
// User saved with only pro via Settings → Models = ["mimo-v2.5-pro"].
|
|
c := &Config{
|
|
Desktop: DesktopConfig{ProviderAccess: []string{"mimo-api"}},
|
|
Providers: []ProviderEntry{{
|
|
Name: "mimo-api",
|
|
Kind: "openai",
|
|
BaseURL: "https://api.xiaomimimo.com/v1",
|
|
Models: []string{"mimo-v2.5-pro"},
|
|
Default: "mimo-v2.5-pro",
|
|
APIKeyEnv: "MIMO_API_KEY",
|
|
}},
|
|
}
|
|
normalizeDesktopOfficialProviderAccess(c)
|
|
p, ok := c.Provider("mimo-api")
|
|
if !ok {
|
|
t.Fatal("mimo-api provider missing")
|
|
}
|
|
if p.HasModel("mimo-v2.5") || p.HasModel("mimo-v2-omni") {
|
|
t.Fatalf("mimo-api models = %v, want only explicitly selected pro model", p.ModelList())
|
|
}
|
|
if len(p.VisionModels) != 0 {
|
|
t.Fatalf("mimo-api vision_models = %v, want empty for pro-only explicit model list", p.VisionModels)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeOfficialMimoVisionModelsPreservesExplicitEmptyList(t *testing.T) {
|
|
c := &Config{
|
|
Desktop: DesktopConfig{ProviderAccess: []string{"mimo-api"}},
|
|
Providers: []ProviderEntry{{
|
|
Name: "mimo-api",
|
|
Kind: "openai",
|
|
BaseURL: "https://api.xiaomimimo.com/v1",
|
|
Models: []string{"mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-omni"},
|
|
Default: "mimo-v2.5-pro",
|
|
APIKeyEnv: "MIMO_API_KEY",
|
|
VisionModels: []string{},
|
|
}},
|
|
}
|
|
normalizeDesktopOfficialProviderAccess(c)
|
|
p, ok := c.Provider("mimo-api")
|
|
if !ok {
|
|
t.Fatal("mimo-api provider missing")
|
|
}
|
|
if p.VisionModels == nil || len(p.VisionModels) != 0 {
|
|
t.Fatalf("mimo-api vision_models = %#v, want explicit empty list", p.VisionModels)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeOfficialDeepSeekModelsSkipsExplicitModelList(t *testing.T) {
|
|
// User saved with only flash via Settings → Models = ["deepseek-v4-flash"].
|
|
c := &Config{Providers: []ProviderEntry{{
|
|
Name: "deepseek",
|
|
Kind: "openai",
|
|
BaseURL: "https://api.deepseek.com",
|
|
Models: []string{"deepseek-v4-flash"},
|
|
Default: "deepseek-v4-flash",
|
|
APIKeyEnv: "DEEPSEEK_API_KEY",
|
|
}}}
|
|
normalizeOfficialDeepSeekModels(c)
|
|
p, ok := c.Provider("deepseek")
|
|
if !ok {
|
|
t.Fatal("deepseek provider missing")
|
|
}
|
|
if p.HasModel("deepseek-v4-pro") {
|
|
t.Fatal("normalizeOfficialDeepSeekModels must not add pro when Models is explicitly set")
|
|
}
|
|
}
|