返回 DeepSeek-Reasonix
deepseek_catalog_upgrade.go
根目录 / internal / config / deepseek_catalog_upgrade.go
1 package config
2
3 import (
4 "fmt"
5 "os"
6 "reflect"
7 "slices"
8 "strings"
9
10 "github.com/BurntSushi/toml"
11
12 fileencoding "reasonix/internal/fileutil/encoding"
13 )
14
15 const deepSeekCatalogUpgradeVersion = 11
16
17 // The caller holds the config edit lock. The model addition and version marker
18 // are committed atomically, so deleting the option afterwards is permanent.
19 func upgradeDeepSeekCatalogFileLocked(path string, write func(string, []byte, os.FileMode) error) (bool, error) {
20 resolved, exists, err := statConfigPath(path)
21 if err != nil || !exists {
22 return false, err
23 }
24 raw, err := os.ReadFile(resolved)
25 if err != nil {
26 return false, err
27 }
28 info, err := os.Stat(resolved)
29 if err != nil {
30 return false, err
31 }
32 encoding, data := fileencoding.Detect(raw)
33 body := string(fileencoding.Decode(data, encoding))
34 crlf := strings.Contains(body, "\r\n")
35 if crlf {
36 body = strings.ReplaceAll(body, "\r\n", "\n")
37 }
38 next, changed, err := rewriteDeepSeekCatalogUpgrade(body)
39 if err != nil || !changed {
40 return false, err
41 }
42 if crlf {
43 next = strings.ReplaceAll(next, "\n", "\r\n")
44 }
45 encoded, err := fileencoding.Encode(next, encoding)
46 if err != nil {
47 return false, fmt.Errorf("commit DeepSeek catalog upgrade: %w", err)
48 }
49 if err := write(resolved, encoded, info.Mode().Perm()); err != nil {
50 return false, fmt.Errorf("commit DeepSeek catalog upgrade: %w", err)
51 }
52 return true, nil
53 }
54
55 func rewriteDeepSeekCatalogUpgrade(body string) (string, bool, error) {
56 var cfg Config
57 if _, err := toml.Decode(body, &cfg); err != nil {
58 return body, false, err
59 }
60 if cfg.ConfigVersion >= deepSeekCatalogUpgradeVersion {
61 return body, false, nil
62 }
63 var expected map[string]any
64 if _, err := toml.Decode(body, &expected); err != nil {
65 return body, false, err
66 }
67 expected["config_version"] = int64(deepSeekCatalogUpgradeVersion)
68 providerTables, _ := deepSeekCatalogDocumentValue(expected["providers"]).([]any)
69 updates := make(map[int][]string)
70 prices := make(map[int]map[string]any)
71 for i := range cfg.Providers {
72 p := &cfg.Providers[i]
73 if officialProviderHost(p.BaseURL) != "api.deepseek.com" || !isOfficialDeepSeekModelReferenceEndpoint(p) ||
74 len(p.ModelList()) == 0 || p.HasModel("deepseek-flash") {
75 continue
76 }
77 // Append, rather than prepend, to preserve implicit first-model defaults.
78 updates[i] = append(append([]string(nil), p.ModelList()...), "deepseek-flash")
79 fields := providerTables[i].(map[string]any)
80 models := make([]any, len(updates[i]))
81 for j, model := range updates[i] {
82 models[j] = model
83 }
84 fields["models"] = models
85 // A Pro-only legacy connection can carry a singular Pro price. The new
86 // Flash option must not inherit it; existing user rates remain untouched.
87 if p.Price != nil && p.Prices["deepseek-flash"] == nil {
88 currency := p.ProviderBillingCurrency()
89 if currency == "" {
90 currency = "USD"
91 }
92 price := deepSeekV4PriceForModel(currency, "deepseek-flash")
93 prices[i] = map[string]any{"cache_hit": price.CacheHit, "input": price.Input, "output": price.Output, "currency": price.Currency}
94 encoded, err := rawTOMLValue(prices[i])
95 if err != nil {
96 return body, false, err
97 }
98 var rendered map[string]any
99 if _, err := toml.Decode("price = "+encoded, &rendered); err != nil {
100 return body, false, err
101 }
102 table, _ := fields["prices"].(map[string]any)
103 if table == nil {
104 table = make(map[string]any)
105 fields["prices"] = table
106 }
107 table["deepseek-flash"] = rendered["price"]
108 }
109 }
110 next := body
111 var err error
112 if len(updates) > 0 {
113 next, err = rewriteDeepSeekCatalogProviders(next, len(cfg.Providers), updates, prices)
114 if err != nil {
115 return body, false, err
116 }
117 }
118 next, err = rawTOMLSet(next, []string{"config_version"}, deepSeekCatalogUpgradeVersion)
119 if err != nil {
120 return body, false, err
121 }
122 // Compare the entire generic document, including fields unknown to Config.
123 // Only the planned model/price additions and version may change.
124 var actual map[string]any
125 if _, err := toml.Decode(next, &actual); err != nil {
126 return body, false, fmt.Errorf("DeepSeek catalog upgrade readback: %w", err)
127 }
128 if !reflect.DeepEqual(deepSeekCatalogDocumentValue(actual), deepSeekCatalogDocumentValue(expected)) {
129 return body, false, fmt.Errorf("DeepSeek catalog upgrade changed unplanned fields; original configuration retained")
130 }
131 return next, true, nil
132 }
133
134 // TOML decodes inline table arrays and array-of-table sections to different Go
135 // slice types. Compare their values without losing unknown fields or numbers.
136 func deepSeekCatalogDocumentValue(value any) any {
137 switch v := value.(type) {
138 case []map[string]any:
139 out := make([]any, len(v))
140 for i, item := range v {
141 out[i] = deepSeekCatalogDocumentValue(item)
142 }
143 return out
144 case []any:
145 for i, item := range v {
146 v[i] = deepSeekCatalogDocumentValue(item)
147 }
148 case map[string]any:
149 for key, item := range v {
150 v[key] = deepSeekCatalogDocumentValue(item)
151 }
152 }
153 return value
154 }
155
156 func rewriteDeepSeekCatalogProviders(body string, count int, models map[int][]string, prices map[int]map[string]any) (string, error) {
157 lines := strings.Split(body, "\n")
158 blocks := providerTOMLBlocks(lines)
159 if len(blocks) != count {
160 // Reuse the lexical inline-table expander; it retains unknown fields,
161 // comments and string contents while changing structural separators.
162 expanded, err := expandOpenCodeGoInlineProviders(body)
163 if err != nil {
164 return body, err
165 }
166 if len(providerTOMLBlocks(strings.Split(expanded, "\n"))) != count {
167 return body, fmt.Errorf("DeepSeek catalog upgrade cannot map provider tables")
168 }
169 return rewriteDeepSeekCatalogProviders(expanded, count, models, prices)
170 }
171 for i := range slices.Backward(blocks) {
172 if models[i] == nil {
173 continue
174 }
175 b := blocks[i]
176 // Include nested provider tables, but never an unrelated root table or
177 // the next provider. rawTOMLSet preserves their existing assignments.
178 for b.end < len(lines) {
179 header := tomlSectionHeader(lines[b.end])
180 path := rawTOMLKeyPath(strings.Trim(header, "[]"))
181 if header != "" && (len(path) < 2 || path[0] != "providers") {
182 break
183 }
184 b.end++
185 }
186 part := strings.Join(lines[b.start+1:b.end], "\n")
187 part, err := rawTOMLSet(part, []string{"models"}, models[i])
188 if err != nil {
189 return body, err
190 }
191 if prices[i] != nil {
192 part, err = rawTOMLSet(part, []string{"providers", "prices", "deepseek-flash"}, prices[i])
193 if err != nil {
194 return body, err
195 }
196 }
197 lines = append(lines[:b.start+1], append(strings.Split(part, "\n"), lines[b.end:]...)...)
198 }
199 return strings.Join(lines, "\n"), nil
200 }
201
201 lines GO