| 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 |