返回 DeepSeek-Reasonix
opencode_go_upgrade.go
根目录 / internal / config / opencode_go_upgrade.go
1 package config
2
3 import (
4 "crypto/rand"
5 "crypto/sha256"
6 "encoding/hex"
7 "encoding/json"
8 "fmt"
9 "log/slog"
10 "maps"
11 "os"
12 "reflect"
13 "slices"
14 "strings"
15
16 "github.com/BurntSushi/toml"
17 "reasonix/internal/fileutil"
18 fileencoding "reasonix/internal/fileutil/encoding"
19 "reasonix/internal/provider"
20 )
21
22 const openCodeGoUpgradeVersion = 10
23
24 type openCodeGoAlias struct {
25 Target string `json:"target"`
26 Identity string `json:"identity"`
27 }
28
29 // The journal is deliberately separate from TOML: older releases may save the
30 // configuration without understanding migration metadata. It stores identity
31 // digests, never a resolved API key.
32 type openCodeGoJournal struct {
33 Version int `json:"version"`
34 Committed bool `json:"committed"`
35 ConfigHash string `json:"config_hash"`
36 CommitID string `json:"commit_id,omitempty"`
37 Aliases map[string]openCodeGoAlias `json:"aliases"`
38 SearchAliases map[string]openCodeGoAlias `json:"search_aliases"`
39 Connections []string `json:"connections,omitempty"`
40 Skipped []string `json:"skipped,omitempty"`
41 Previous *openCodeGoJournal `json:"previous,omitempty"`
42 }
43
44 func openCodeGoDigest(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:]) }
45
46 // Identity follows the credential reference and user transport settings, never
47 // a resolved secret or the mutable display name/model list/protocol default.
48 func openCodeGoIdentity(p ProviderEntry) string {
49 b, _ := json.Marshal(struct {
50 Key string
51 Headers map[string]string
52 Body map[string]any
53 Auth, NoProxy bool
54 }{strings.TrimSpace(p.APIKeyEnv), normalizedProviderHeaders(p.Headers), p.ExtraBody, p.AuthHeader, p.NoProxy})
55 return openCodeGoDigest(b)
56 }
57
58 func openCodeGoMigrationRoute(p ProviderEntry) (string, string) {
59 route, ok := provider.OpenCodeGoRequestRoute(p.Kind, p.BaseURL, p.RequestURL, p.ChatURL)
60 if !ok {
61 return "", "effective request URL is not a standard OpenCode Go endpoint"
62 }
63 // All configured endpoints must be provably equivalent before redirecting.
64 if _, ok := provider.OpenCodeGoRequestRoute(p.Kind, p.BaseURL, "", ""); !ok {
65 return "", "custom base URL is preserved"
66 }
67 if p.ChatURL != "" {
68 if _, ok := provider.OpenCodeGoRequestRoute("openai", p.BaseURL, p.ChatURL, ""); !ok {
69 return "", "custom chat URL is preserved"
70 }
71 }
72 if len(p.ExtraBody) != 0 {
73 return "", "custom request body requires manual protocol review"
74 }
75 return route, ""
76 }
77
78 type openCodeGoGroup struct {
79 source int
80 entry ProviderEntry
81 }
82
83 func planOpenCodeGoUpgrade(c *Config) (openCodeGoJournal, []openCodeGoGroup) {
84 return planOpenCodeGoUpgradeFiltered(c, nil)
85 }
86
87 func planOpenCodeGoUpgradeFiltered(c *Config, eligible func(ProviderEntry) bool) (openCodeGoJournal, []openCodeGoGroup) {
88 j := openCodeGoJournal{Version: openCodeGoUpgradeVersion, Aliases: map[string]openCodeGoAlias{}, SearchAliases: map[string]openCodeGoAlias{}}
89 var additions []openCodeGoGroup
90 count := len(c.Providers)
91 bareOwners := map[string]string{}
92 searchConnections := map[string]bool{}
93 if c.openCodeGoJournal != nil {
94 for _, a := range c.openCodeGoJournal.SearchAliases {
95 name, _, _ := strings.Cut(a.Target, "/")
96 searchConnections[name] = true
97 }
98 }
99 for _, p := range c.Providers {
100 for _, m := range p.ModelList() {
101 if _, exists := bareOwners[m]; !exists {
102 bareOwners[m] = p.Name
103 }
104 }
105 }
106 for i := range count {
107 original := cloneProviderEntry(c.Providers[i])
108 if eligible != nil && !eligible(original) {
109 continue
110 }
111 if searchConnections[original.Name] {
112 continue
113 }
114 current, reason := openCodeGoMigrationRoute(original)
115 if current == "" {
116 if strings.Contains(original.BaseURL, "opencode.ai/zen/go") || strings.Contains(original.RequestURL, "opencode.ai/zen/go") {
117 j.Skipped = append(j.Skipped, original.Name+": "+reason)
118 }
119 continue
120 }
121 groups := map[string][]string{}
122 for _, model := range original.ModelList() {
123 route, known := provider.OpenCodeGoRecommendedRoute(model)
124 if !known {
125 route = current
126 }
127 groups[route] = append(groups[route], model)
128 }
129 if len(groups) == 0 {
130 continue
131 }
132 keep := current
133 if len(groups[keep]) == 0 {
134 keep, _ = provider.OpenCodeGoRecommendedRoute(original.DefaultModel())
135 if len(groups[keep]) == 0 {
136 keep = firstOpenCodeGoGroup(groups)
137 }
138 }
139
140 c.Providers[i] = makeOpenCodeGoGroup(original, keep, groups[keep])
141
142 recordOpenCodeGoAliases(&j, original, c.Providers[i], groups[keep], bareOwners)
143 changed := keep != current || len(groups) > 1
144 for _, route := range []string{provider.OpenCodeGoRouteChat, provider.OpenCodeGoRouteAnthropic, provider.OpenCodeGoRouteResponses} {
145 models := groups[route]
146 if route == keep || len(models) == 0 {
147 continue
148 }
149 p := makeOpenCodeGoGroup(original, route, models)
150 p.Name = uniqueOpenCodeGoSiblingName(c, original.Name, route)
151 p = reuseOpenCodeGoGroup(c, original, p)
152 if _, exists := c.Provider(p.Name); !exists {
153 c.Providers = append(c.Providers, p)
154 additions = append(additions, openCodeGoGroup{i, p})
155 if c.Desktop.ProviderAccess != nil && slices.Contains(c.Desktop.ProviderAccess, original.Name) {
156 addOpenCodeGoAccess(c, p.Name)
157 }
158 }
159 recordOpenCodeGoAliases(&j, original, p, models, bareOwners)
160 }
161 if preserveOpenCodeGoSearch(c, &j, &additions, original, current, groups, i) {
162 changed = true
163 }
164 if changed {
165 j.Connections = append(j.Connections, original.Name)
166 }
167 }
168 placeOpenCodeGoSiblings(c, count, additions)
169 rewrite := func(ref string) string {
170 if a, ok := j.Aliases[ref]; ok {
171 return a.Target
172 }
173 return ref
174 }
175 c.DefaultModel = rewrite(c.DefaultModel)
176 c.Agent.PlannerModel = rewrite(c.Agent.PlannerModel)
177 c.Agent.VisionModel = rewrite(c.Agent.VisionModel)
178 c.Agent.GuardianModel = rewrite(c.Agent.GuardianModel)
179 c.Agent.RecoveryModel = rewrite(c.Agent.RecoveryModel)
180 c.Agent.SubagentModel = rewrite(c.Agent.SubagentModel)
181 for name, ref := range c.Agent.SubagentModels {
182 c.Agent.SubagentModels[name] = rewrite(ref)
183 }
184 c.Bot.Model = rewrite(c.Bot.Model)
185 for i := range c.Bot.Connections {
186 c.Bot.Connections[i].Model = rewrite(c.Bot.Connections[i].Model)
187 }
188 return j, additions
189 }
190
191 // placeOpenCodeGoSiblings moves each split group directly after the connection
192 // it came from. Bare model names resolve to their first owner, so a later
193 // account must not overtake a model the earlier account only moved routes for.
194 func placeOpenCodeGoSiblings(c *Config, count int, additions []openCodeGoGroup) {
195 if len(additions) == 0 {
196 return
197 }
198 ordered := make([]ProviderEntry, 0, len(c.Providers))
199 for i := range count {
200 ordered = append(ordered, c.Providers[i])
201 for k, add := range additions {
202 if add.source == i {
203 ordered = append(ordered, c.Providers[count+k])
204 }
205 }
206 }
207 c.Providers = ordered
208 }
209
210 func allOpenCodeGoDeepSeek(models []string) bool {
211 for _, m := range models {
212 if !provider.OpenCodeGoDeepSeekModel(m) {
213 return false
214 }
215 }
216 return len(models) > 0
217 }
218
219 func legacyOpenCodeGoBinary(ids []string) bool {
220 return len(ids) == 2 && slices.Contains(ids, "enabled") && slices.Contains(ids, "disabled")
221 }
222
223 func upgradeOpenCodeGoFileLocked(path string) (bool, error) {
224 return upgradeOpenCodeGoFileWithWriterLocked(path, fileutil.AtomicWriteFile)
225 }
226
227 func upgradeOpenCodeGoFileWithWriterLocked(path string, write func(string, []byte, os.FileMode) error) (bool, error) {
228 resolved, exists, err := statConfigPath(path)
229 if err != nil || !exists {
230 return false, err
231 }
232 raw, err := os.ReadFile(resolved)
233 if err != nil {
234 return false, err
235 }
236 info, err := os.Stat(resolved)
237 if err != nil {
238 return false, err
239 }
240 encoding, detected := fileencoding.Detect(raw)
241 body := string(fileencoding.Decode(detected, encoding))
242 // The lexical rewriters split on "\n" and re-parse value extents; a
243 // trailing "\r" never parses alone, so edit LF text and restore CRLF after.
244 crlf := strings.Contains(body, "\r\n")
245 if crlf {
246 body = strings.ReplaceAll(body, "\r\n", "\n")
247 }
248 var before Config
249 if _, err := toml.Decode(body, &before); err != nil {
250 return false, err
251 }
252 if before.ConfigVersion >= openCodeGoUpgradeVersion {
253 return false, nil
254 }
255 if before.ConfigVersion < deepSeekOfficialChatUpgradeConfigVersion {
256 body, _, err = rewriteDeepSeekProtocol(body, "openai", "https://api.deepseek.com", func(p *ProviderEntry, _ map[string]any) bool { return isOfficialDeepSeekChatUpgrade(p) })
257 if err != nil {
258 return false, err
259 }
260 if _, err := toml.Decode(body, &before); err != nil {
261 return false, err
262 }
263 }
264 var after Config
265 _, _ = toml.Decode(body, &after)
266 after.openCodeGoJournal = readOpenCodeGoJournal(resolved, raw)
267 j, additions := planOpenCodeGoUpgrade(&after)
268 next, err := rewriteOpenCodeGoConfig(body, &before, &after, additions)
269 if err != nil {
270 return false, fmt.Errorf("OpenCode Go upgrade: %w; original configuration retained", err)
271 }
272 if strings.EqualFold(strings.TrimSpace(before.Desktop.LayoutStyle), "classic") {
273 next, err = rawTOMLSet(next, []string{"desktop", "layout_style"}, "workbench")
274 if err != nil {
275 return false, err
276 }
277 }
278 // A committed older journal survives a downgrade/save/upgrade round trip.
279 if previous := readOpenCodeGoJournal(resolved, raw); previous != nil {
280 maps.Copy(j.Aliases, previous.Aliases)
281 maps.Copy(j.SearchAliases, previous.SearchAliases)
282 previous.Previous = nil
283 previous.Committed = true
284 j.Previous = previous
285 }
286 if len(j.Aliases) > 0 || len(j.SearchAliases) > 0 {
287 j.CommitID = rand.Text()
288 next, err = rawTOMLSet(next, []string{"opencode_go_migration_commit"}, j.CommitID)
289 if err != nil {
290 return false, err
291 }
292 }
293 if err := verifyOpenCodeGoRewrite(next, &after); err != nil {
294 return false, fmt.Errorf("OpenCode Go upgrade: %w; original configuration retained", err)
295 }
296 if crlf {
297 next = strings.ReplaceAll(next, "\n", "\r\n")
298 }
299 encoded := fileencoding.Encode(next, encoding)
300 j.ConfigHash = openCodeGoDigest(encoded)
301 backup := resolved + ".opencode-go-v10.backup"
302 if _, err := os.Stat(backup); os.IsNotExist(err) {
303 if err := write(backup, raw, 0600); err != nil {
304 return false, fmt.Errorf("save migration backup: %w", err)
305 }
306 } else if err != nil {
307 return false, err
308 }
309 journalPath := resolved + ".opencode-go-v10.json"
310 data, _ := json.MarshalIndent(j, "", " ")
311 if err := write(journalPath, data, 0600); err != nil {
312 return false, fmt.Errorf("prepare migration journal: %w", err)
313 }
314 if err := write(resolved, encoded, info.Mode().Perm()); err != nil {
315 return false, fmt.Errorf("commit migration: %w", err)
316 }
317 j.Committed = true
318 j.Previous = nil
319 data, _ = json.MarshalIndent(j, "", " ")
320 // If this last write fails, the exact committed config hash still activates
321 // the prepared journal. No rollback can lose an already committed config.
322 if err := write(journalPath, data, 0600); err != nil {
323 slog.Warn("config: OpenCode Go migration journal acknowledgement deferred to next startup", "path", journalPath, "err", err)
324 }
325 return true, nil
326 }
327
328 func readOpenCodeGoJournal(path string, raw []byte) *openCodeGoJournal {
329 data, err := os.ReadFile(path + ".opencode-go-v10.json")
330 if err != nil {
331 return nil
332 }
333 var j openCodeGoJournal
334 if json.Unmarshal(data, &j) != nil || j.Version != openCodeGoUpgradeVersion {
335 return nil
336 }
337 if !j.Committed && (len(raw) == 0 || j.ConfigHash != openCodeGoDigest(raw)) {
338 var marker struct {
339 CommitID string `toml:"opencode_go_migration_commit"`
340 }
341 if _, err := decodeTOMLBytes(raw, &marker); err != nil || j.CommitID == "" || marker.CommitID != j.CommitID {
342 if j.Previous != nil && j.Previous.Committed && j.Previous.Version == openCodeGoUpgradeVersion {
343 return j.Previous
344 }
345 return nil
346 }
347 }
348 return &j
349 }
350
351 // Before a renderer can drop the unknown TOML commit marker, acknowledge any
352 // configuration commit whose final journal write was interrupted.
353 func finalizeOpenCodeGoJournal(path string) error {
354 if _, err := os.Stat(path + ".opencode-go-v10.json"); os.IsNotExist(err) {
355 return nil
356 } else if err != nil {
357 return err
358 }
359 raw, err := os.ReadFile(path)
360 if os.IsNotExist(err) {
361 return nil
362 }
363 if err != nil {
364 return err
365 }
366 j := readOpenCodeGoJournal(path, raw)
367 if j == nil || j.Committed {
368 return nil
369 }
370 j.Committed = true
371 j.Previous = nil
372 data, err := json.MarshalIndent(j, "", " ")
373 if err != nil {
374 return err
375 }
376 if err := fileutil.AtomicWriteFile(path+".opencode-go-v10.json", data, 0600); err != nil {
377 return fmt.Errorf("complete OpenCode Go migration journal before saving config: %w", err)
378 }
379 return nil
380 }
381
382 func makeOpenCodeGoGroup(original ProviderEntry, route string, models []string) ProviderEntry {
383 p := cloneProviderEntry(original)
384 setOpenCodeGoRoute(&p, route)
385 p.RequestURL, p.ChatURL = "", ""
386 applyOpenCodeGoModelGroup(&p, models)
387 p.PresetID = openCodeGoPresetIDForRoute(route)
388 p.PresetVersion = ProviderPresetVersion
389 for _, model := range models {
390 contract, ok := provider.OpenCodeGoContractForRoute(route, model)
391 if !ok {
392 continue
393 }
394 if p.ModelOverrides == nil {
395 p.ModelOverrides = map[string]ProviderModelOverride{}
396 }
397 o := p.ModelOverrides[model]
398 if o.ReasoningProtocol == "" || o.ReasoningProtocol == "auto" {
399 o.ReasoningProtocol = contract.ReasoningProtocol
400 }
401 if len(o.SupportedEfforts) == 0 && len(p.SupportedEfforts) == 0 {
402 o.SupportedEfforts = contract.Reasoning.IDs()
403 o.DefaultEffort = contract.Reasoning.Default
404 }
405 // Only the old binary switch has a proven equivalent. Depths and
406 // explicit custom vocabularies remain user-owned and get validated.
407 if provider.OpenCodeGoDeepSeekModel(model) && ((len(o.SupportedEfforts) == 0 && (len(p.SupportedEfforts) == 0 || legacyOpenCodeGoBinary(p.SupportedEfforts))) || legacyOpenCodeGoBinary(o.SupportedEfforts)) {
408 o.SupportedEfforts = contract.Reasoning.IDs()
409 o.DefaultEffort = contract.Reasoning.Default
410 }
411 p.ModelOverrides[model] = o
412 }
413 if p.Effort == "enabled" && allOpenCodeGoDeepSeek(models) {
414 p.Effort = "high"
415 }
416 if route != provider.OpenCodeGoRouteResponses && p.Effort == "none" && allOpenCodeGoDeepSeek(models) {
417 p.Effort = "disabled"
418 }
419 return p
420 }
421
422 func recordOpenCodeGoAliases(j *openCodeGoJournal, original, p ProviderEntry, models []string, bareOwners map[string]string) {
423 for _, model := range models {
424 if _, known := provider.OpenCodeGoRecommendedRoute(model); !known {
425 continue
426 }
427 alias := openCodeGoAlias{p.Name + "/" + model, openCodeGoIdentity(p)}
428 j.Aliases[original.Name+"/"+model] = alias
429 if model == original.DefaultModel() {
430 j.Aliases[original.Name] = alias
431 }
432 if bareOwners[model] == original.Name {
433 j.Aliases[model] = alias
434 }
435 }
436 }
437
438 func reuseOpenCodeGoGroup(c *Config, original, p ProviderEntry) ProviderEntry {
439 // Reuse requires complete user settings to agree, including per-model
440 // prices and overrides. A same-name different account never matches.
441 for _, candidate := range c.Providers {
442 if candidate.Name == original.Name || !reflect.DeepEqual(candidate.ModelList(), p.ModelList()) {
443 continue
444 }
445 a, b := cloneProviderEntry(candidate), cloneProviderEntry(p)
446 a.Name, b.Name, a.DisplayName, b.DisplayName = "", "", "", ""
447 if reflect.DeepEqual(a, b) {
448 p.Name = candidate.Name
449 break
450 }
451 }
452 return p
453 }
454
455 func preserveOpenCodeGoSearch(c *Config, j *openCodeGoJournal, additions *[]openCodeGoGroup, original ProviderEntry, current string, groups map[string][]string, i int) bool {
456 // Preserve an enabled search on the original wire route. Search aliases
457 // are purpose-specific: history continues through the recommended route.
458 if original.WebSearch != nil && *original.WebSearch && current != provider.OpenCodeGoRouteChat && len(groups[provider.OpenCodeGoRouteChat]) > 0 {
459 var models []string
460 for _, m := range groups[provider.OpenCodeGoRouteChat] {
461 if provider.OpenCodeGoDeepSeekModel(m) {
462 models = append(models, m)
463 }
464 }
465 if len(models) > 0 {
466 p := makeOpenCodeGoGroup(original, current, models)
467 p.Name = uniqueOpenCodeGoSiblingName(c, original.Name, "search")
468 c.Providers = append(c.Providers, p)
469 *additions = append(*additions, openCodeGoGroup{i, p})
470 if c.Desktop.ProviderAccess != nil && slices.Contains(c.Desktop.ProviderAccess, original.Name) {
471 addOpenCodeGoAccess(c, p.Name)
472 }
473 for _, model := range models {
474 old := original.Name + "/" + model
475 j.SearchAliases[old] = openCodeGoAlias{p.Name + "/" + model, openCodeGoIdentity(p)}
476 if c.Agent.WebSearchModel == old {
477 c.Agent.WebSearchModel = p.Name + "/" + model
478 }
479 }
480 return true
481 }
482 }
483 return false
484 }
485
485 lines GO