返回 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 := groupOpenCodeGoModelsByRoute(original, current)
122 if len(groups) == 0 {
123 continue
124 }
125 keep := current
126 if len(groups[keep]) == 0 {
127 keep, _ = provider.OpenCodeGoRecommendedRoute(original.DefaultModel())
128 if len(groups[keep]) == 0 {
129 keep = firstOpenCodeGoGroup(groups)
130 }
131 }
132
133 c.Providers[i] = makeOpenCodeGoGroup(original, keep, groups[keep])
134
135 recordOpenCodeGoAliases(&j, original, c.Providers[i], groups[keep], bareOwners)
136 changed := keep != current || len(groups) > 1
137 for _, route := range []string{provider.OpenCodeGoRouteChat, provider.OpenCodeGoRouteAnthropic, provider.OpenCodeGoRouteResponses} {
138 models := groups[route]
139 if route == keep || len(models) == 0 {
140 continue
141 }
142 p := makeOpenCodeGoGroup(original, route, models)
143 p.Name = uniqueOpenCodeGoSiblingName(c, original.Name, route)
144 p = reuseOpenCodeGoGroup(c, original, p)
145 if _, exists := c.Provider(p.Name); !exists {
146 c.Providers = append(c.Providers, p)
147 additions = append(additions, openCodeGoGroup{i, p})
148 if c.Desktop.ProviderAccess != nil && slices.Contains(c.Desktop.ProviderAccess, original.Name) {
149 addOpenCodeGoAccess(c, p.Name)
150 }
151 }
152 recordOpenCodeGoAliases(&j, original, p, models, bareOwners)
153 }
154 if preserveOpenCodeGoSearch(c, &j, &additions, original, current, groups, i) {
155 changed = true
156 }
157 if changed {
158 j.Connections = append(j.Connections, original.Name)
159 }
160 }
161 placeOpenCodeGoSiblings(c, count, additions)
162 rewrite := func(ref string) string {
163 if a, ok := j.Aliases[ref]; ok {
164 return a.Target
165 }
166 return ref
167 }
168 c.DefaultModel = rewrite(c.DefaultModel)
169 c.Agent.PlannerModel = rewrite(c.Agent.PlannerModel)
170 c.Agent.VisionModel = rewrite(c.Agent.VisionModel)
171 c.Agent.GuardianModel = rewrite(c.Agent.GuardianModel)
172 c.Agent.RecoveryModel = rewrite(c.Agent.RecoveryModel)
173 c.Agent.SubagentModel = rewrite(c.Agent.SubagentModel)
174 for name, ref := range c.Agent.SubagentModels {
175 c.Agent.SubagentModels[name] = rewrite(ref)
176 }
177 c.Bot.Model = rewrite(c.Bot.Model)
178 for i := range c.Bot.Connections {
179 c.Bot.Connections[i].Model = rewrite(c.Bot.Connections[i].Model)
180 }
181 return j, additions
182 }
183
184 func groupOpenCodeGoModelsByRoute(original ProviderEntry, current string) map[string][]string {
185 groups := map[string][]string{}
186 for _, model := range original.ModelList() {
187 route, known := provider.OpenCodeGoRecommendedRoute(model)
188 if !known || openCodeGoKeepsCorrectedModel(original, model) {
189 route = current
190 }
191 groups[route] = append(groups[route], model)
192 }
193 return groups
194 }
195
196 // placeOpenCodeGoSiblings moves each split group directly after the connection
197 // it came from. Bare model names resolve to their first owner, so a later
198 // account must not overtake a model the earlier account only moved routes for.
199 func placeOpenCodeGoSiblings(c *Config, count int, additions []openCodeGoGroup) {
200 if len(additions) == 0 {
201 return
202 }
203 ordered := make([]ProviderEntry, 0, len(c.Providers))
204 for i := range count {
205 ordered = append(ordered, c.Providers[i])
206 for k, add := range additions {
207 if add.source == i {
208 ordered = append(ordered, c.Providers[count+k])
209 }
210 }
211 }
212 c.Providers = ordered
213 }
214
215 func allOpenCodeGoDeepSeek(models []string) bool {
216 for _, m := range models {
217 if !provider.OpenCodeGoDeepSeekModel(m) {
218 return false
219 }
220 }
221 return len(models) > 0
222 }
223
224 func legacyOpenCodeGoBinary(ids []string) bool {
225 return len(ids) == 2 && slices.Contains(ids, "enabled") && slices.Contains(ids, "disabled")
226 }
227
228 func upgradeOpenCodeGoFileLocked(path string) (bool, error) {
229 return upgradeOpenCodeGoFileWithWriterLocked(path, fileutil.AtomicWriteFile)
230 }
231
232 func upgradeOpenCodeGoFileWithWriterLocked(path string, write func(string, []byte, os.FileMode) error) (bool, error) {
233 resolved, exists, err := statConfigPath(path)
234 if err != nil || !exists {
235 return false, err
236 }
237 raw, err := os.ReadFile(resolved)
238 if err != nil {
239 return false, err
240 }
241 info, err := os.Stat(resolved)
242 if err != nil {
243 return false, err
244 }
245 encoding, detected := fileencoding.Detect(raw)
246 body := string(fileencoding.Decode(detected, encoding))
247 // The lexical rewriters split on "\n" and re-parse value extents; a
248 // trailing "\r" never parses alone, so edit LF text and restore CRLF after.
249 crlf := strings.Contains(body, "\r\n")
250 if crlf {
251 body = strings.ReplaceAll(body, "\r\n", "\n")
252 }
253 var before Config
254 if _, err := toml.Decode(body, &before); err != nil {
255 return false, err
256 }
257 if before.ConfigVersion >= openCodeGoUpgradeVersion {
258 return false, nil
259 }
260 if before.ConfigVersion < deepSeekOfficialChatUpgradeConfigVersion {
261 body, _, err = rewriteDeepSeekProtocol(body, "openai", "https://api.deepseek.com", func(p *ProviderEntry, _ map[string]any) bool { return isOfficialDeepSeekChatUpgrade(p) })
262 if err != nil {
263 return false, err
264 }
265 if _, err := toml.Decode(body, &before); err != nil {
266 return false, err
267 }
268 }
269 var after Config
270 _, _ = toml.Decode(body, &after)
271 after.openCodeGoJournal = readOpenCodeGoJournal(resolved, raw)
272 j, additions := planOpenCodeGoUpgrade(&after)
273 next, err := rewriteOpenCodeGoConfig(body, &before, &after, additions)
274 if err != nil {
275 return false, fmt.Errorf("OpenCode Go upgrade: %w; original configuration retained", err)
276 }
277 if strings.EqualFold(strings.TrimSpace(before.Desktop.LayoutStyle), "classic") {
278 next, err = rawTOMLSet(next, []string{"desktop", "layout_style"}, "workbench")
279 if err != nil {
280 return false, err
281 }
282 }
283 // A committed older journal survives a downgrade/save/upgrade round trip.
284 if previous := readOpenCodeGoJournal(resolved, raw); previous != nil {
285 maps.Copy(j.Aliases, previous.Aliases)
286 maps.Copy(j.SearchAliases, previous.SearchAliases)
287 previous.Previous = nil
288 previous.Committed = true
289 j.Previous = previous
290 }
291 if len(j.Aliases) > 0 || len(j.SearchAliases) > 0 {
292 j.CommitID = rand.Text()
293 next, err = rawTOMLSet(next, []string{"opencode_go_migration_commit"}, j.CommitID)
294 if err != nil {
295 return false, err
296 }
297 }
298 if err := verifyOpenCodeGoRewrite(next, &after); err != nil {
299 return false, fmt.Errorf("OpenCode Go upgrade: %w; original configuration retained", err)
300 }
301 if crlf {
302 next = strings.ReplaceAll(next, "\n", "\r\n")
303 }
304 encoded, err := fileencoding.Encode(next, encoding)
305 if err != nil {
306 return false, err
307 }
308 j.ConfigHash = openCodeGoDigest(encoded)
309 backup := resolved + ".opencode-go-v10.backup"
310 if _, err := os.Stat(backup); os.IsNotExist(err) {
311 if err := write(backup, raw, 0600); err != nil {
312 return false, fmt.Errorf("save migration backup: %w", err)
313 }
314 } else if err != nil {
315 return false, err
316 }
317 journalPath := resolved + ".opencode-go-v10.json"
318 data, _ := json.MarshalIndent(j, "", " ")
319 if err := write(journalPath, data, 0600); err != nil {
320 return false, fmt.Errorf("prepare migration journal: %w", err)
321 }
322 if err := write(resolved, encoded, info.Mode().Perm()); err != nil {
323 return false, fmt.Errorf("commit migration: %w", err)
324 }
325 j.Committed = true
326 j.Previous = nil
327 data, _ = json.MarshalIndent(j, "", " ")
328 // If this last write fails, the exact committed config hash still activates
329 // the prepared journal. No rollback can lose an already committed config.
330 if err := write(journalPath, data, 0600); err != nil {
331 slog.Warn("config: OpenCode Go migration journal acknowledgement deferred to next startup", "path", journalPath, "err", err)
332 }
333 return true, nil
334 }
335
336 func readOpenCodeGoJournal(path string, raw []byte) *openCodeGoJournal {
337 data, err := os.ReadFile(path + ".opencode-go-v10.json")
338 if err != nil {
339 return nil
340 }
341 var j openCodeGoJournal
342 if json.Unmarshal(data, &j) != nil || j.Version != openCodeGoUpgradeVersion {
343 return nil
344 }
345 if !j.Committed && (len(raw) == 0 || j.ConfigHash != openCodeGoDigest(raw)) {
346 var marker struct {
347 CommitID string `toml:"opencode_go_migration_commit"`
348 }
349 if _, err := decodeTOMLBytes(raw, &marker); err != nil || j.CommitID == "" || marker.CommitID != j.CommitID {
350 if j.Previous != nil && j.Previous.Committed && j.Previous.Version == openCodeGoUpgradeVersion {
351 return j.Previous
352 }
353 return nil
354 }
355 }
356 return &j
357 }
358
359 // Before a renderer can drop the unknown TOML commit marker, acknowledge any
360 // configuration commit whose final journal write was interrupted.
361 func finalizeOpenCodeGoJournal(path string) error {
362 if _, err := os.Stat(path + ".opencode-go-v10.json"); os.IsNotExist(err) {
363 return nil
364 } else if err != nil {
365 return err
366 }
367 raw, err := os.ReadFile(path)
368 if os.IsNotExist(err) {
369 return nil
370 }
371 if err != nil {
372 return err
373 }
374 j := readOpenCodeGoJournal(path, raw)
375 if j == nil || j.Committed {
376 return nil
377 }
378 j.Committed = true
379 j.Previous = nil
380 data, err := json.MarshalIndent(j, "", " ")
381 if err != nil {
382 return err
383 }
384 if err := fileutil.AtomicWriteFile(path+".opencode-go-v10.json", data, 0600); err != nil {
385 return fmt.Errorf("complete OpenCode Go migration journal before saving config: %w", err)
386 }
387 return nil
388 }
389
390 func makeOpenCodeGoGroup(original ProviderEntry, route string, models []string) ProviderEntry {
391 p := cloneProviderEntry(original)
392 setOpenCodeGoRoute(&p, route)
393 p.RequestURL, p.ChatURL = "", ""
394 applyOpenCodeGoModelGroup(&p, models)
395 p.PresetID = openCodeGoPresetIDForRoute(route)
396 p.PresetVersion = ProviderPresetVersion
397 for _, model := range models {
398 contract, ok := provider.OpenCodeGoContractForRoute(route, model)
399 if !ok {
400 continue
401 }
402 if p.ModelOverrides == nil {
403 p.ModelOverrides = map[string]ProviderModelOverride{}
404 }
405 o := p.ModelOverrides[model]
406 if o.ReasoningProtocol == "" || o.ReasoningProtocol == "auto" {
407 o.ReasoningProtocol = contract.ReasoningProtocol
408 }
409 if len(o.SupportedEfforts) == 0 && len(p.SupportedEfforts) == 0 {
410 o.SupportedEfforts = contract.Reasoning.IDs()
411 o.DefaultEffort = contract.Reasoning.Default
412 }
413 // Only the old binary switch has a proven equivalent. Depths and
414 // explicit custom vocabularies remain user-owned and get validated.
415 if provider.OpenCodeGoDeepSeekModel(model) && ((len(o.SupportedEfforts) == 0 && (len(p.SupportedEfforts) == 0 || legacyOpenCodeGoBinary(p.SupportedEfforts))) || legacyOpenCodeGoBinary(o.SupportedEfforts)) {
416 o.SupportedEfforts = contract.Reasoning.IDs()
417 o.DefaultEffort = contract.Reasoning.Default
418 }
419 p.ModelOverrides[model] = o
420 }
421 if p.Effort == "enabled" && allOpenCodeGoDeepSeek(models) {
422 p.Effort = "high"
423 }
424 if route != provider.OpenCodeGoRouteResponses && p.Effort == "none" && allOpenCodeGoDeepSeek(models) {
425 p.Effort = "disabled"
426 }
427 return p
428 }
429
430 func recordOpenCodeGoAliases(j *openCodeGoJournal, original, p ProviderEntry, models []string, bareOwners map[string]string) {
431 for _, model := range models {
432 if _, known := provider.OpenCodeGoRecommendedRoute(model); !known {
433 continue
434 }
435 alias := openCodeGoAlias{p.Name + "/" + model, openCodeGoIdentity(p)}
436 j.Aliases[original.Name+"/"+model] = alias
437 if model == original.DefaultModel() {
438 j.Aliases[original.Name] = alias
439 }
440 if bareOwners[model] == original.Name {
441 j.Aliases[model] = alias
442 }
443 }
444 }
445
446 func reuseOpenCodeGoGroup(c *Config, original, p ProviderEntry) ProviderEntry {
447 // Reuse requires complete user settings to agree, including per-model
448 // prices and overrides. A same-name different account never matches.
449 for _, candidate := range c.Providers {
450 if candidate.Name == original.Name || !reflect.DeepEqual(candidate.ModelList(), p.ModelList()) {
451 continue
452 }
453 a, b := cloneProviderEntry(candidate), cloneProviderEntry(p)
454 a.Name, b.Name, a.DisplayName, b.DisplayName = "", "", "", ""
455 if reflect.DeepEqual(a, b) {
456 p.Name = candidate.Name
457 break
458 }
459 }
460 return p
461 }
462
463 func preserveOpenCodeGoSearch(c *Config, j *openCodeGoJournal, additions *[]openCodeGoGroup, original ProviderEntry, current string, groups map[string][]string, i int) bool {
464 // Preserve an enabled search on the original wire route. Search aliases
465 // are purpose-specific: history continues through the recommended route.
466 if original.WebSearch != nil && *original.WebSearch && current != provider.OpenCodeGoRouteChat && len(groups[provider.OpenCodeGoRouteChat]) > 0 {
467 var models []string
468 for _, m := range groups[provider.OpenCodeGoRouteChat] {
469 if provider.OpenCodeGoDeepSeekModel(m) {
470 models = append(models, m)
471 }
472 }
473 if len(models) > 0 {
474 p := makeOpenCodeGoGroup(original, current, models)
475 p.Name = uniqueOpenCodeGoSiblingName(c, original.Name, "search")
476 c.Providers = append(c.Providers, p)
477 *additions = append(*additions, openCodeGoGroup{i, p})
478 if c.Desktop.ProviderAccess != nil && slices.Contains(c.Desktop.ProviderAccess, original.Name) {
479 addOpenCodeGoAccess(c, p.Name)
480 }
481 for _, model := range models {
482 old := original.Name + "/" + model
483 j.SearchAliases[old] = openCodeGoAlias{p.Name + "/" + model, openCodeGoIdentity(p)}
484 if c.Agent.WebSearchModel == old {
485 c.Agent.WebSearchModel = p.Name + "/" + model
486 }
487 }
488 return true
489 }
490 }
491 return false
492 }
493
493 lines GO