Files
ollama/cmd/launch/codex_app.go

2691 lines
83 KiB
Go

package launch
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strconv"
"strings"
"syscall"
"time"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/cmd/config"
"github.com/ollama/ollama/cmd/internal/fileutil"
"github.com/ollama/ollama/envconfig"
"github.com/ollama/ollama/internal/proxy"
modelpkg "github.com/ollama/ollama/types/model"
)
const (
chatGPTIntegrationName = "chatgpt"
codexAppIntegrationName = "codex-app"
codexAppProfileName = "ollama-launch-codex-app"
codexAppBundleID = "com.openai.codex"
codexAppLaunchURL = "codex://threads/new?mode=codex"
codexAppModelCatalogFilename = proxy.CodexDesktopModelCatalogFilename
codexAppRoutingCatalogFilename = proxy.CodexDesktopRoutingCatalogFilename
codexAppAutoReviewModelEnv = "OLLAMA_CODEX_AUTO_REVIEW_MODEL"
codexAppOllamaProfileDirName = "chatgpt-ollama"
codexAppOllamaUserDataName = "electron-data"
codexAppOllamaPIDFilename = "chatgpt.pid"
codexAppSingletonLockName = "SingletonLock"
codexAppSingletonSocketName = "SingletonSocket"
codexAppSingletonCookieName = "SingletonCookie"
codexAppDesktopTableName = "desktop"
codexAppReasoningEffortsKey = "enabled-reasoning-efforts"
codexAppRestoreHint = "To remove Ollama models from ChatGPT, run: ollama launch chatgpt --restore"
codexAppConfigurationSuccess = "Ollama models added to ChatGPT."
codexAppRestoreSuccess = "Ollama models removed from ChatGPT."
)
var (
codexAppGOOS = runtime.GOOS
codexAppStat = os.Stat
codexAppGlob = filepath.Glob
codexAppOpenApp = defaultCodexAppOpenApp
codexAppOpenPath = defaultCodexAppOpenAppPath
codexAppOpenStart = defaultCodexAppOpenStartAppID
codexAppQuitApp = defaultCodexAppQuitApp
codexAppIsRunning = defaultCodexAppIsRunning
codexAppRunPath = defaultCodexAppRunningAppPath
codexAppStartID = defaultCodexAppStartAppID
codexAppCanOpenID = defaultCodexAppCanOpenBundleID
codexAppSleep = time.Sleep
codexAppNativeCatalog = defaultCodexAppNativeModelCatalog
codexAppCodexExecutable = defaultCodexAppCodexExecutable
codexAppRunDebugModels = defaultCodexAppRunDebugModels
codexAppRouterHealth = defaultCodexAppRouterHealth
codexAppProfileApplication = defaultCodexAppOllamaProfileApplication
codexAppProfileExecutable = defaultCodexAppOllamaProfileExecutable
codexAppStopProfile = defaultCodexAppStopOllamaProfile
codexAppProfileIsRunning = defaultCodexAppOllamaProfileIsRunning
codexAppProcessCommand = defaultCodexAppProcessCommand
codexAppExitTimeout = 5 * time.Second
)
// CodexApp adds Ollama models to ChatGPT's native catalog using a loopback router.
type CodexApp struct{}
// ErrCodexAppRestartConfirmationRequired reports that changing the regular
// ChatGPT profile would interrupt a running task.
var ErrCodexAppRestartConfirmationRequired = errors.New("ChatGPT restart confirmation is required before changing its profile")
func (c *CodexApp) String() string { return "ChatGPT" }
func (c *CodexApp) Supported() error { return codexAppSupported() }
func (c *CodexApp) Paths() []string {
configPath, err := codexConfigPath()
if err != nil {
return nil
}
return []string{configPath}
}
func (c *CodexApp) Configure(model string) error {
return c.ConfigureWithModels(model, launchModelsFromNames([]string{model}))
}
func (c *CodexApp) ConfigureWithModels(primary string, models []LaunchModel) error {
primary = strings.TrimSpace(primary)
if primary == "" {
return fmt.Errorf("chatgpt requires a model")
}
models = codexAppCatalogModels(primary, models)
autoReview, err := codexAppConfiguredAutoReviewModel(primary, models)
if err != nil {
return err
}
configPath, err := codexConfigPath()
if err != nil {
return err
}
if err := saveCodexAppRestoreState(configPath); err != nil {
return err
}
nativeCatalog, err := codexAppNativeCatalog(configPath)
if err != nil {
return fmt.Errorf("read native Codex model catalog: %w", err)
}
catalogPath, err := codexAppModelCatalogPath()
if err != nil {
return err
}
routingCatalogPath := codexAppRoutingCatalogPathForConfig(configPath)
if err := writeCodexAppRoutingCatalog(routingCatalogPath, models, autoReview); err != nil {
return err
}
if err := writeCodexAppCombinedModelCatalog(catalogPath, models, nativeCatalog); err != nil {
return err
}
createdAuth, err := ensureCodexAppManagedAuth(configPath)
if err != nil {
return err
}
if err := writeCodexAppConfig(configPath, primary, catalogPath, models); err != nil {
if createdAuth {
if removeErr := removeCodexAppManagedAuth(configPath); removeErr != nil {
return errors.Join(err, fmt.Errorf("remove ChatGPT local auth after failed configuration: %w", removeErr))
}
}
return err
}
return nil
}
func (c *CodexApp) CurrentModel() string {
configPath, err := codexConfigPath()
if err != nil {
return ""
}
data, err := os.ReadFile(configPath)
if err != nil {
return ""
}
text := string(data)
parsed, err := codexParseConfig(text)
if err != nil {
return ""
}
if codexAppRootUsesProxy(parsed) &&
codexNormalizeURL(parsed.RootString(codexRootOpenAIBaseURLKey)) == codexNormalizeURL(codexAppProxyBaseURL()) &&
codexAppCatalogHealthy(parsed, "") {
model := strings.TrimSpace(parsed.RootString(codexRootModelKey))
if codexAppCatalogContainsModel(model) {
return model
}
return codexAppFirstRoutingModel()
}
// Recognize legacy provider layouts without losing restore state.
for _, profileName := range codexAppManagedProfileNames() {
if parsed.RootString(codexRootModelProviderKey) == profileName {
baseURL := parsed.ProviderString(profileName, "base_url")
if codexAppManagedProviderURL(profileName, baseURL) && codexAppCatalogHealthy(parsed, profileName) {
model := strings.TrimSpace(parsed.RootString(codexRootModelKey))
if codexAppCatalogContainsModel(model) {
return model
}
}
}
}
profileName := parsed.RootString(codexRootProfileKey)
if !codexAppIsManagedProfileName(profileName) {
return ""
}
if parsed.ProfileString(profileName, codexRootModelProviderKey) != profileName {
return ""
}
baseURL := parsed.ProviderString(profileName, "base_url")
if codexNormalizeURL(baseURL) != codexNormalizeURL(codexBaseURL()) {
return ""
}
if !codexAppCatalogHealthy(parsed, profileName) {
return ""
}
model := strings.TrimSpace(parsed.ProfileString(profileName, codexRootModelKey))
if !codexAppCatalogContainsModel(model) {
return ""
}
return model
}
func codexAppManagedProfileNames() []string {
return []string{codexAppProfileName, codexProfileName}
}
func codexAppIsManagedProfileName(profileName string) bool {
for _, candidate := range codexAppManagedProfileNames() {
if profileName == candidate {
return true
}
}
return false
}
func codexAppIsOwnedProfileName(profileName string) bool {
return profileName == codexAppProfileName
}
func codexAppManagedProviderURL(profileName, baseURL string) bool {
if profileName == codexAppProfileName &&
codexNormalizeURL(baseURL) == codexNormalizeURL(codexAppProxyBaseURL()) {
return true
}
return codexNormalizeURL(baseURL) == codexNormalizeURL(codexBaseURL())
}
func codexAppCatalogHealthy(config codexParsedConfig, profileName string) bool {
catalogPath, err := codexAppModelCatalogPath()
if err != nil {
return false
}
if config.RootString(codexRootModelCatalogJSONKey) != catalogPath {
return false
}
if profileName != "" && config.Exists("profiles", profileName) && config.ProfileString(profileName, codexRootModelCatalogJSONKey) != catalogPath {
return false
}
data, err := os.ReadFile(catalogPath)
if err != nil {
return false
}
var catalog struct {
Models []json.RawMessage `json:"models"`
}
if err := json.Unmarshal(data, &catalog); err != nil {
return false
}
return len(catalog.Models) > 0
}
// Check the routing allow-list, not the combined picker catalog.
func codexAppCatalogContainsModel(model string) bool {
if strings.TrimSpace(model) == "" {
return false
}
models, err := codexAppRoutingModels()
if err != nil {
return false
}
target := codexAppCatalogModelKey(model)
for _, candidate := range models {
if codexAppCatalogModelKey(candidate) == target {
return true
}
}
return false
}
func codexAppRoutingModels() ([]string, error) {
configPath, err := codexConfigPath()
if err != nil {
return nil, err
}
catalogPath := codexAppRoutingCatalogPathForConfig(configPath)
data, err := os.ReadFile(catalogPath)
if err != nil {
return nil, err
}
var catalog struct {
Models []struct {
Slug string `json:"slug"`
} `json:"models"`
}
if err := json.Unmarshal(data, &catalog); err != nil {
return nil, err
}
models := make([]string, 0, len(catalog.Models))
for _, m := range catalog.Models {
if slug := strings.TrimSpace(m.Slug); slug != "" {
models = append(models, slug)
}
}
if len(models) == 0 {
return nil, errors.New("ChatGPT Ollama routing catalog is empty")
}
return models, nil
}
func codexAppFirstRoutingModel() string {
models, err := codexAppRoutingModels()
if err != nil {
return ""
}
return models[0]
}
func writeCodexAppConfig(configPath, model, modelCatalogPath string, models []LaunchModel) error {
baseURL := codexAppProxyBaseURL()
content, readErr := os.ReadFile(configPath)
text := ""
if readErr == nil {
text = string(content)
} else if !os.IsNotExist(readErr) {
return readErr
}
if _, err := codexParseConfig(text); err != nil {
return err
}
text = codexRemoveRootValue(text, codexRootProfileKey)
text = codexAppRemoveOwnedSections(text)
text = codexSetRootStringValue(text, codexRootModelKey, model)
// Leave model_provider unset to preserve ChatGPT's native account controls.
text = codexRemoveRootValue(text, codexRootModelProviderKey)
text = codexSetRootStringValue(text, codexRootModelCatalogJSONKey, modelCatalogPath)
text = codexSetRootStringValue(text, codexRootOpenAIBaseURLKey, baseURL)
efforts := codexAppReasoningEffortsForConfig(text)
for _, selected := range models {
for _, level := range codexAppThinkingContractForModel(selected).levels {
if !slices.Contains(efforts, level) {
efforts = append(efforts, level)
}
}
}
text = codexAppSetReasoningEfforts(text, efforts)
parsed, err := codexParseConfig(text)
if err != nil {
return err
}
if err := codexValidateAppConfigText(parsed, model, modelCatalogPath, baseURL); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
return err
}
return fileutil.WriteWithBackup(configPath, []byte(text), codexAppIntegrationName)
}
func codexValidateAppConfigText(config codexParsedConfig, model, modelCatalogPath, baseURL string) error {
if got, ok := config.RootStringOK(codexRootProfileKey); ok {
return fmt.Errorf("generated ChatGPT config still contains legacy profile = %q", got)
}
if config.Exists("profiles", codexAppProfileName) {
return fmt.Errorf("generated ChatGPT config still contains legacy profiles.%s table", codexAppProfileName)
}
if config.Exists("model_providers", codexAppProfileName) {
return fmt.Errorf("generated ChatGPT config still contains legacy model_providers.%s table", codexAppProfileName)
}
if got, ok := config.RootStringOK(codexRootModelProviderKey); ok {
return fmt.Errorf("generated ChatGPT config still contains explicit model_provider = %q", got)
}
efforts, ok := codexAppConfigReasoningEfforts(config)
if !ok || !slices.Contains(efforts, "none") || !slices.Contains(efforts, "max") {
return fmt.Errorf("generated ChatGPT config does not enable none and max thinking controls")
}
for _, check := range []struct {
path []string
want string
}{
{[]string{codexRootModelKey}, model},
{[]string{codexRootModelCatalogJSONKey}, modelCatalogPath},
{[]string{codexRootOpenAIBaseURLKey}, baseURL},
} {
if got, ok := config.String(check.path...); !ok || got != check.want {
return fmt.Errorf("generated ChatGPT config missing %s = %q", strings.Join(check.path, "."), check.want)
}
}
return nil
}
func codexAppReasoningEffortsForConfig(text string) []string {
config, err := codexParseConfig(text)
if err == nil {
if existing, ok := codexAppConfigReasoningEfforts(config); ok {
return codexAppMergeReasoningEfforts(existing)
}
}
// Add missing thinking levels without replacing native choices.
return []string{"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"}
}
func codexAppConfigReasoningEfforts(config codexParsedConfig) ([]string, bool) {
desktop, ok := config.values[codexAppDesktopTableName].(map[string]any)
if !ok {
return nil, false
}
value, ok := desktop[codexAppReasoningEffortsKey]
if !ok {
return nil, false
}
items, ok := value.([]any)
if !ok {
if stringItems, stringsOK := value.([]string); stringsOK {
return append([]string(nil), stringItems...), true
}
return nil, false
}
efforts := make([]string, 0, len(items))
for _, item := range items {
effort, ok := item.(string)
if !ok {
return nil, false
}
efforts = append(efforts, effort)
}
return efforts, true
}
func codexAppMergeReasoningEfforts(existing []string) []string {
efforts := append([]string(nil), existing...)
if !slices.Contains(efforts, "none") {
efforts = append([]string{"none"}, efforts...)
}
if !slices.Contains(efforts, "max") {
insertAt := len(efforts)
for i, effort := range efforts {
if effort == "ultra" || effort == "persistent" {
insertAt = i
break
}
}
efforts = append(efforts, "")
copy(efforts[insertAt+1:], efforts[insertAt:])
efforts[insertAt] = "max"
}
return efforts
}
func codexAppSetReasoningEfforts(text string, efforts []string) string {
assignment := codexAppStringArrayAssignment(codexAppReasoningEffortsKey, efforts)
if start, end, found := codexSectionRange(text, []string{codexAppDesktopTableName}); found {
if assignmentStart, assignmentEnd, assignmentFound := codexAppSectionAssignmentRange(text, start, end, codexAppReasoningEffortsKey); assignmentFound {
return text[:assignmentStart] + assignment + codexAppReplacementLineEnding(text[assignmentStart:assignmentEnd]) + text[assignmentEnd:]
}
insert := assignment + "\n"
if end > start && !strings.HasSuffix(text[:end], "\n") {
insert = "\n" + insert
}
return text[:end] + insert + text[end:]
}
dottedKey := codexAppDesktopTableName + "." + codexAppReasoningEffortsKey
if start, end, found := codexAppRootAssignmentRange(text, dottedKey); found {
dottedAssignment := codexAppStringArrayAssignment(dottedKey, efforts)
return text[:start] + dottedAssignment + codexAppReplacementLineEnding(text[start:end]) + text[end:]
}
// A dotted root assignment is equivalent to a [desktop] table entry and can
// be removed cleanly when the user did not already have this setting.
dottedAssignment := codexAppStringArrayAssignment(dottedKey, efforts)
return codexAppInsertRootAssignment(text, dottedAssignment)
}
func codexAppRemoveReasoningEfforts(text string) string {
if start, end, found := codexSectionRange(text, []string{codexAppDesktopTableName}); found {
if assignmentStart, assignmentEnd, assignmentFound := codexAppSectionAssignmentRange(text, start, end, codexAppReasoningEffortsKey); assignmentFound {
return text[:assignmentStart] + text[assignmentEnd:]
}
}
dottedKey := codexAppDesktopTableName + "." + codexAppReasoningEffortsKey
if start, end, found := codexAppRootAssignmentRange(text, dottedKey); found {
return text[:start] + text[end:]
}
return text
}
func codexAppStringArrayAssignment(key string, values []string) string {
data, _ := json.Marshal(values)
return key + " = " + string(data)
}
func codexAppSectionAssignmentRange(text string, sectionStart, sectionEnd int, key string) (int, int, bool) {
return codexAppAssignmentRange(text, sectionStart, sectionEnd, key, "[desktop]\n")
}
func codexAppRootAssignmentRange(text, key string) (int, int, bool) {
rootEnd := len(text)
if index := strings.Index(text, "\n["); index >= 0 {
rootEnd = index + 1
} else if strings.HasPrefix(strings.TrimSpace(text), "[") {
rootEnd = 0
}
return codexAppAssignmentRange(text, 0, rootEnd, key, "")
}
func codexAppInsertRootAssignment(text, assignment string) string {
offset := 0
for _, line := range strings.SplitAfter(text, "\n") {
if strings.HasPrefix(strings.TrimSpace(line), "[") {
return text[:offset] + assignment + "\n" + text[offset:]
}
offset += len(line)
}
if text != "" && !strings.HasSuffix(text, "\n") {
text += "\n"
}
return text + assignment + "\n"
}
func codexAppAssignmentRange(text string, start, end int, key, parsePrefix string) (int, int, bool) {
section := text[start:end]
lines := strings.SplitAfter(section, "\n")
offset := 0
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if !codexAppAssignmentStartsWithKey(trimmed, key) {
offset += len(line)
continue
}
assignmentStart := start + offset
candidate := ""
candidateEnd := assignmentStart
for _, candidateLine := range lines[i:] {
candidate += candidateLine
candidateEnd += len(candidateLine)
if _, err := codexParseConfigText(parsePrefix + candidate); err == nil {
return assignmentStart, candidateEnd, true
}
}
return 0, 0, false
}
return 0, 0, false
}
func codexAppAssignmentStartsWithKey(line, key string) bool {
if !strings.HasPrefix(line, key) {
return false
}
rest := strings.TrimSpace(strings.TrimPrefix(line, key))
return strings.HasPrefix(rest, "=")
}
func codexAppReplacementLineEnding(replaced string) string {
if strings.HasSuffix(replaced, "\n") {
return "\n"
}
return ""
}
func codexAppProxyBaseURL() string {
return strings.TrimRight(envconfig.ConnectableHost().String(), "/") + proxy.CodexDesktopPathPrefix + "/v1"
}
type codexAppManagedAuth struct {
OpenAIAPIKey string `json:"OPENAI_API_KEY"`
AuthMode string `json:"auth_mode"`
}
// Create local auth only when absent; never read or replace an existing auth file.
func ensureCodexAppManagedAuth(configPath string) (bool, error) {
authPath := filepath.Join(filepath.Dir(configPath), "auth.json")
if err := os.MkdirAll(filepath.Dir(authPath), 0o700); err != nil {
return false, err
}
data, err := json.MarshalIndent(codexAppManagedAuth{
OpenAIAPIKey: proxy.CodexDesktopManagedAPIKey,
AuthMode: "apikey",
}, "", " ")
if err != nil {
return false, err
}
data = append(data, '\n')
file, err := os.OpenFile(authPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if errors.Is(err, os.ErrExist) {
return false, nil
}
if err != nil {
return false, err
}
cleanup := true
defer func() {
if cleanup {
_ = file.Close()
_ = os.Remove(authPath)
}
}()
if _, err := file.Write(data); err != nil {
return false, err
}
if err := file.Close(); err != nil {
return false, err
}
cleanup = false
return true, nil
}
// Remove only Ollama's exact sentinel, never a user login or API key.
func removeCodexAppManagedAuth(configPath string) error {
authPath := filepath.Join(filepath.Dir(configPath), "auth.json")
data, err := os.ReadFile(authPath)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
if !isCodexAppManagedAuth(data) {
return nil
}
if err := os.Remove(authPath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func isCodexAppManagedAuth(data []byte) bool {
var auth codexAppManagedAuth
if json.Unmarshal(data, &auth) != nil {
return false
}
return auth.AuthMode == "apikey" && auth.OpenAIAPIKey == proxy.CodexDesktopManagedAPIKey
}
func (c *CodexApp) Onboard() error {
return config.MarkIntegrationOnboarded(chatGPTIntegrationName)
}
func (c *CodexApp) RequiresInteractiveOnboarding() bool {
return false
}
func (c *CodexApp) RestoreHint() string {
return codexAppRestoreHint
}
func (c *CodexApp) ConfigurationSuccessMessage() string {
return codexAppConfigurationSuccess + "\n" + codexAppRestoreHint
}
func (c *CodexApp) RestoreSuccessMessage() string {
return codexAppRestoreSuccess
}
func (c *CodexApp) Run(_ string, _ []LaunchModel, args []string) error {
if err := codexAppSupported(); err != nil {
return err
}
if len(args) > 0 {
return fmt.Errorf("chatgpt does not accept extra arguments")
}
return codexAppLaunchOrRestart("Restart ChatGPT to add Ollama models?", nil)
}
// Installed reports whether ChatGPT can be opened on this host.
func (c *CodexApp) Installed() bool {
return codexAppInstalled()
}
// OllamaConfigured reports whether the regular ChatGPT profile has the
// additive Ollama catalog and loopback router enabled.
func (c *CodexApp) OllamaConfigured() bool {
configPath, err := codexConfigPath()
if err == nil {
if data, readErr := os.ReadFile(configPath); readErr == nil {
if parsed, parseErr := codexParseConfig(string(data)); parseErr == nil && codexAppRootUsesProxy(parsed) {
// A damaged catalog must not hide the off-switch.
return true
}
}
}
return c.CurrentModel() != ""
}
// RestorePending recognizes saved restore state and abandoned Ollama catalogs,
// including legacy connections that do not have a restore record.
func (c *CodexApp) RestorePending() bool {
if _, err := os.Lstat(codexAppRestoreStatePath()); !os.IsNotExist(err) {
return true
}
configPath, err := codexConfigPath()
if err != nil {
return false
}
if data, err := os.ReadFile(configPath); err == nil && codexAppRootReferencesCatalog(string(data)) {
return false
}
for _, path := range []string{
codexAppModelCatalogPathForConfig(configPath),
codexAppRoutingCatalogPathForConfig(configPath),
} {
if _, err := os.Lstat(path); !os.IsNotExist(err) {
return true
}
}
return false
}
// Running reports whether the user's regular ChatGPT app is open.
func (c *CodexApp) Running() bool {
return codexAppIsRunning()
}
// UseOllamaFromDesktop adds Ollama models to the regular ChatGPT profile.
// Its startup-only catalog requires a confirmed restart when running.
func (c *CodexApp) UseOllamaFromDesktop(primary string, models []LaunchModel, restartConfirmed bool) error {
return c.updateOllamaModelsFromDesktop(primary, models, true, restartConfirmed)
}
// UpdateOllamaModelsFromDesktop changes the Ollama catalog without opening a
// stopped ChatGPT app. A running app still restarts after confirmation.
func (c *CodexApp) UpdateOllamaModelsFromDesktop(primary string, models []LaunchModel, restartConfirmed bool) error {
return c.updateOllamaModelsFromDesktop(primary, models, false, restartConfirmed)
}
func (c *CodexApp) updateOllamaModelsFromDesktop(primary string, models []LaunchModel, openWhenStopped, restartConfirmed bool) error {
if err := codexAppSupported(); err != nil {
return err
}
if !codexAppInstalled() {
return fmt.Errorf("ChatGPT is not installed")
}
if err := codexAppRouterHealth(); err != nil {
return err
}
// Check before cleanup so cancellation is a no-op; check again when applying the profile.
if (codexAppIsRunning() || codexAppProfileIsRunning()) && !restartConfirmed {
return ErrCodexAppRestartConfirmationRequired
}
if err := stopLegacyCodexAppOllamaProfile(); err != nil {
return err
}
return codexAppApplyProfileFromDesktop(func() error {
if err := resetCodexAppRegularProfileRequestCount(); err != nil {
return err
}
return c.ConfigureWithModels(primary, models)
}, openWhenStopped, restartConfirmed)
}
func defaultCodexAppRouterHealth() error {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
endpoint := strings.TrimRight(envconfig.ConnectableHost().String(), "/") + proxy.CodexDesktopPathPrefix + "/_health"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return fmt.Errorf("create ChatGPT router health check: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("ChatGPT routing is unavailable at %s; restart Ollama and try again: %w", endpoint, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return fmt.Errorf("the running Ollama server does not include ChatGPT routing; restart Ollama using this build and try again")
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("ChatGPT router health check returned %s", resp.Status)
}
var status struct {
OK bool `json:"ok"`
}
if err := json.NewDecoder(resp.Body).Decode(&status); err != nil {
return fmt.Errorf("read ChatGPT router health check: %w", err)
}
if !status.OK {
return errors.New("ChatGPT router is not ready; restart Ollama and try again")
}
return nil
}
// RestoreFromDesktop removes the additive Ollama catalog and restores the
// prior base URL and model settings. A stopped ChatGPT app remains stopped.
func (c *CodexApp) RestoreFromDesktop(restartConfirmed bool) error {
if err := codexAppSupported(); err != nil {
return err
}
if (codexAppIsRunning() || codexAppProfileIsRunning()) && !restartConfirmed {
return ErrCodexAppRestartConfirmationRequired
}
if err := stopLegacyCodexAppOllamaProfile(); err != nil {
return err
}
return codexAppApplyProfileFromDesktop(restoreCodexAppProfile, false, restartConfirmed)
}
// RestartFromDesktop repairs and restarts the saved profile without requiring live inventory.
func (c *CodexApp) RestartFromDesktop(restartConfirmed bool) error {
if err := codexAppSupported(); err != nil {
return err
}
if !codexAppInstalled() {
return errors.New("ChatGPT is not installed")
}
if !c.OllamaConfigured() {
return errors.New("ChatGPT is not configured to use Ollama")
}
return codexAppApplyProfileFromDesktop(repairCodexAppCatalogAuthVisibility, true, restartConfirmed)
}
func (c *CodexApp) OllamaRequestCount() uint64 {
return codexAppRegularProfileRequestCount()
}
func stopLegacyCodexAppOllamaProfile() error {
if codexAppGOOS != "darwin" || !codexAppProfileIsRunning() {
return nil
}
if err := codexAppStopProfile(); err != nil {
return fmt.Errorf("close the previous ChatGPT · Ollama profile: %w", err)
}
return nil
}
func (c *CodexApp) Restore() error {
if err := codexAppSupported(); err != nil {
return err
}
if err := restoreCodexAppProfile(); err != nil {
return err
}
return codexAppLaunchOrRestart("Restart ChatGPT to use your usual profile?", nil)
}
func restoreCodexAppProfile() error {
configPath, err := codexConfigPath()
if err != nil {
return err
}
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
if _, err := os.Lstat(codexAppRestoreStatePath()); os.IsNotExist(err) {
if err := writeCodexAppRestoreState(codexAppRestoreState{}); err != nil {
return codexAppRestoreFailure(configPath, err)
}
}
if err := removeCodexAppManagedAuth(configPath); err != nil {
return codexAppRestoreFailure(configPath, err)
}
if err := removeCodexAppProfileConfig(); err != nil {
return codexAppRestoreFailure(configPath, err)
}
if err := codexAppRemoveOwnedCatalog(); err != nil {
return codexAppRestoreFailure(configPath, err)
}
if err := removeCodexAppRestoreState(); err != nil {
return codexAppRestoreFailure(configPath, err)
}
return nil
}
return codexAppRestoreFailure(configPath, err)
}
text := string(data)
if err := codexValidateConfigText(text); err != nil {
return codexAppRestoreFailure(configPath, err)
}
state, stateErr := loadCodexAppRestoreState()
if stateErr == nil {
text = codexAppRestoreRootValues(text, state)
} else if os.IsNotExist(stateErr) {
text = codexAppRemoveOwnedRootValues(text)
} else {
return codexAppRestoreFailure(configPath, stateErr)
}
if !codexAppRootReferencesOwnedConfig(text) {
text = codexAppRemoveOwnedSections(text)
}
if err := codexValidateConfigText(text); err != nil {
return codexAppRestoreFailure(configPath, err)
}
// Keep a durable retry record before changing settings, including legacy
// connections whose catalogs and original restore record are already gone.
if os.IsNotExist(stateErr) {
if err := writeCodexAppRestoreState(codexAppRestoreStateFromText(text)); err != nil {
return codexAppRestoreFailure(configPath, err)
}
}
if err := fileutil.WriteWithBackup(configPath, []byte(text), codexAppIntegrationName); err != nil {
return codexAppRestoreFailure(configPath, err)
}
if err := removeCodexAppProfileConfig(); err != nil {
return codexAppRestoreFailure(configPath, err)
}
if err := removeCodexAppManagedAuth(configPath); err != nil {
return codexAppRestoreFailure(configPath, err)
}
if err := codexAppRemoveOwnedCatalogIfUnused(text); err != nil {
return codexAppRestoreFailure(configPath, err)
}
if err := removeCodexAppRestoreState(); err != nil {
return codexAppRestoreFailure(configPath, err)
}
return nil
}
func codexAppRestoreFailure(configPath string, err error) error {
return fmt.Errorf("restore ChatGPT config: %w\n\nRestore did not complete. Check these files before retrying:\n Codex config: %s\n Restore state: %s\n Model catalog: %s\n Backups: %s",
err,
configPath,
codexAppRestoreStatePath(),
codexAppModelCatalogPathForConfig(configPath),
filepath.Join(fileutil.BackupDir(), codexAppIntegrationName),
)
}
func codexAppSupported() error {
switch codexAppGOOS {
case "darwin", "windows":
return nil
default:
return fmt.Errorf("ChatGPT launch is only supported on macOS and Windows")
}
}
func codexAppInstalled() bool {
if codexAppAppPath() != "" {
return true
}
switch codexAppGOOS {
case "darwin":
return codexAppCanOpenID()
case "windows":
return codexAppIsRunning() || codexAppStartID() != ""
default:
return false
}
}
func codexAppModelCatalogPath() (string, error) {
configPath, err := codexConfigPath()
if err != nil {
return "", err
}
return codexAppModelCatalogPathForConfig(configPath), nil
}
func codexAppOllamaProfileRoot() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".ollama", codexAppOllamaProfileDirName), nil
}
func codexAppOllamaProfileUserDataDir() (string, error) {
root, err := codexAppOllamaProfileRoot()
if err != nil {
return "", err
}
return filepath.Join(root, codexAppOllamaUserDataName), nil
}
func codexAppOllamaProfilePIDPath() (string, error) {
root, err := codexAppOllamaProfileRoot()
if err != nil {
return "", err
}
return filepath.Join(root, codexAppOllamaPIDFilename), nil
}
func codexAppProfileConfigPath() (string, error) {
configPath, err := codexConfigPath()
if err != nil {
return "", err
}
return codexAppProfileConfigPathForConfig(configPath), nil
}
func codexAppProfileConfigPathForConfig(configPath string) string {
return codexNamedProfileConfigPathForConfig(configPath, codexAppProfileName)
}
func codexAppModelCatalogPathForConfig(configPath string) string {
return filepath.Join(filepath.Dir(configPath), codexAppModelCatalogFilename)
}
func codexAppRoutingCatalogPathForConfig(configPath string) string {
return filepath.Join(filepath.Dir(configPath), codexAppRoutingCatalogFilename)
}
type codexAppRawModelCatalog struct {
Models []json.RawMessage `json:"models"`
}
// supported_in_api controls visibility outside ChatGPT-account sessions.
// The separate routing catalog, not this combined picker, determines upstreams.
func writeCodexAppCombinedModelCatalog(path string, models []LaunchModel, nativeCatalogData []byte) error {
if len(models) == 0 {
return fmt.Errorf("chatgpt model catalog cannot be empty")
}
nativeCatalog, err := parseCodexAppModelCatalog(nativeCatalogData)
if err != nil {
return fmt.Errorf("parse native Codex model catalog: %w", err)
}
baseInstructions := codexAppBaseInstructionsFromCatalog(nativeCatalog)
ollamaPriorityStart := codexAppOllamaPriorityStart(nativeCatalog, len(models))
entries := make([]json.RawMessage, 0, len(models)+len(nativeCatalog.Models))
seen := make(map[string]bool, len(models)+len(nativeCatalog.Models))
for i, model := range models {
entry, err := json.Marshal(codexAppCatalogEntry(model.Name, codexAppModelMetadataFromLaunchModel(model), ollamaPriorityStart+i, baseInstructions))
if err != nil {
return err
}
entries = append(entries, entry)
seen[codexAppCatalogModelKey(model.Name)] = true
}
for _, entry := range nativeCatalog.Models {
slug, err := codexAppRawCatalogSlug(entry)
if err != nil {
return fmt.Errorf("parse native Codex model: %w", err)
}
if seen[codexAppCatalogModelKey(slug)] {
continue
}
entry, err = codexAppCatalogEntryWithAPISupport(entry, false)
if err != nil {
return fmt.Errorf("mark native Codex model %q as ChatGPT-only: %w", slug, err)
}
seen[codexAppCatalogModelKey(slug)] = true
entries = append(entries, entry)
}
data, err := json.MarshalIndent(codexAppRawModelCatalog{Models: entries}, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return fileutil.WriteWithBackup(path, append(data, '\n'), codexAppIntegrationName)
}
func codexAppCatalogEntryWithAPISupport(entry json.RawMessage, supported bool) (json.RawMessage, error) {
var fields map[string]json.RawMessage
if err := json.Unmarshal(entry, &fields); err != nil {
return nil, err
}
if supported {
fields["supported_in_api"] = json.RawMessage("true")
} else {
fields["supported_in_api"] = json.RawMessage("false")
}
return json.Marshal(fields)
}
// Repair legacy visibility using the routing catalog without contacting Ollama.
func repairCodexAppCatalogAuthVisibility() error {
configPath, err := codexConfigPath()
if err != nil {
return fmt.Errorf("find ChatGPT configuration: %w", err)
}
routingModels, err := codexAppRoutingModels()
if err != nil {
return fmt.Errorf("read ChatGPT Ollama routing catalog: %w", err)
}
routed := make(map[string]bool, len(routingModels))
for _, model := range routingModels {
routed[codexAppCatalogModelKey(model)] = true
}
catalogPath := codexAppModelCatalogPathForConfig(configPath)
data, err := os.ReadFile(catalogPath)
if err != nil {
return fmt.Errorf("read ChatGPT model catalog: %w", err)
}
catalog, err := parseCodexAppModelCatalog(data)
if err != nil {
return fmt.Errorf("parse ChatGPT model catalog: %w", err)
}
changed := false
for i, entry := range catalog.Models {
slug, err := codexAppRawCatalogSlug(entry)
if err != nil {
return fmt.Errorf("parse ChatGPT model: %w", err)
}
want := routed[codexAppCatalogModelKey(slug)]
var metadata struct {
SupportedInAPI *bool `json:"supported_in_api"`
}
if err := json.Unmarshal(entry, &metadata); err != nil {
return fmt.Errorf("parse ChatGPT model %q: %w", slug, err)
}
if metadata.SupportedInAPI != nil && *metadata.SupportedInAPI == want {
continue
}
catalog.Models[i], err = codexAppCatalogEntryWithAPISupport(entry, want)
if err != nil {
return fmt.Errorf("update ChatGPT model %q: %w", slug, err)
}
changed = true
}
if !changed {
return nil
}
data, err = json.MarshalIndent(catalog, "", " ")
if err != nil {
return fmt.Errorf("encode ChatGPT model catalog: %w", err)
}
if err := fileutil.WriteWithBackup(catalogPath, append(data, '\n'), codexAppIntegrationName); err != nil {
return fmt.Errorf("update ChatGPT model catalog: %w", err)
}
return nil
}
func codexAppOllamaPriorityStart(nativeCatalog codexAppRawModelCatalog, ollamaModelCount int) int {
lowestNativePriority := 0
found := false
for _, entry := range nativeCatalog.Models {
var model struct {
Priority *int `json:"priority"`
}
if json.Unmarshal(entry, &model) != nil || model.Priority == nil {
continue
}
if !found || *model.Priority < lowestNativePriority {
lowestNativePriority = *model.Priority
found = true
}
}
if !found {
return -ollamaModelCount
}
return lowestNativePriority - ollamaModelCount
}
type codexAppAutoReviewRoute struct {
Model string
FallbackModel string
}
func writeCodexAppRoutingCatalog(path string, models []LaunchModel, autoReview codexAppAutoReviewRoute) error {
if len(models) == 0 {
return fmt.Errorf("chatgpt routing catalog cannot be empty")
}
type thinkingMetadata struct {
Supported bool `json:"supported"`
Levels []string `json:"levels,omitempty"`
Values map[string]any `json:"values,omitempty"`
Controls *modelpkg.Thinking `json:"controls,omitempty"`
}
type routingEntry struct {
Slug string `json:"slug"`
Thinking thinkingMetadata `json:"thinking"`
}
entries := make([]routingEntry, 0, len(models))
for _, model := range models {
metadata := codexAppModelMetadataFromLaunchModel(model)
entries = append(entries, routingEntry{
Slug: model.Name,
Thinking: thinkingMetadata{
Supported: len(metadata.thinking.levels) > 0,
Levels: metadata.thinking.levels,
Values: metadata.thinking.values,
Controls: metadata.thinking.controls,
},
})
}
catalog := struct {
Models []routingEntry `json:"models"`
AutoReviewModel string `json:"auto_review_model,omitempty"`
AutoReviewFallbackModel string `json:"auto_review_fallback_model,omitempty"`
}{
Models: entries,
AutoReviewModel: autoReview.Model,
AutoReviewFallbackModel: autoReview.FallbackModel,
}
data, err := json.MarshalIndent(catalog, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return fileutil.WriteWithBackup(path, append(data, '\n'), codexAppIntegrationName)
}
func codexAppConfiguredAutoReviewModel(primary string, models []LaunchModel) (codexAppAutoReviewRoute, error) {
configured := strings.TrimSpace(os.Getenv(codexAppAutoReviewModelEnv))
switch strings.ToLower(configured) {
case "", "selected":
return codexAppAutoReviewRoute{Model: "selected", FallbackModel: primary}, nil
case "ollama":
return codexAppAutoReviewRoute{Model: primary}, nil
case "native", "chatgpt":
return codexAppAutoReviewRoute{}, nil
}
target := codexAppCatalogModelKey(configured)
for _, model := range models {
if codexAppCatalogModelKey(model.Name) == target {
return codexAppAutoReviewRoute{Model: model.Name}, nil
}
}
return codexAppAutoReviewRoute{}, fmt.Errorf("%s=%q is not one of the configured Ollama models", codexAppAutoReviewModelEnv, configured)
}
func parseCodexAppModelCatalog(data []byte) (codexAppRawModelCatalog, error) {
var catalog codexAppRawModelCatalog
if err := json.Unmarshal(data, &catalog); err != nil {
return catalog, err
}
if len(catalog.Models) == 0 {
return catalog, fmt.Errorf("model catalog is empty")
}
for _, entry := range catalog.Models {
if _, err := codexAppRawCatalogSlug(entry); err != nil {
return catalog, err
}
}
return catalog, nil
}
func codexAppRawCatalogSlug(entry json.RawMessage) (string, error) {
var model struct {
Slug string `json:"slug"`
}
if err := json.Unmarshal(entry, &model); err != nil {
return "", err
}
model.Slug = strings.TrimSpace(model.Slug)
if model.Slug == "" {
return "", fmt.Errorf("model catalog entry has no slug")
}
return model.Slug, nil
}
func codexAppCatalogModels(primary string, models []LaunchModel) []LaunchModel {
seen := make(map[string]bool, len(models)+1)
out := make([]LaunchModel, 0, len(models)+1)
add := func(model LaunchModel) {
model.Name = strings.TrimSpace(model.Name)
if model.Name == "" {
return
}
key := codexAppCatalogModelKey(model.Name)
if seen[key] {
return
}
seen[key] = true
out = append(out, model)
}
if _, ok := findLaunchModel(models, primary); !ok {
add(fallbackLaunchModel(primary))
}
for _, model := range models {
if launchModelMatches(model.Name, primary) {
model.Name = primary
}
add(model)
}
return out
}
func codexAppCatalogModelKey(name string) string {
return strings.TrimSuffix(name, ":latest")
}
type codexAppModelMetadata struct {
contextWindow int
inputModalities []string
thinking codexAppThinkingContract
toolCapable bool
}
type codexAppThinkingContract struct {
defaultLevel string
levels []string
values map[string]any
controls *modelpkg.Thinking
}
func codexAppDefaultModelMetadata() codexAppModelMetadata {
return codexAppModelMetadata{
contextWindow: 128_000,
inputModalities: []string{"text"},
}
}
func codexAppModelMetadataFromLaunchModel(model LaunchModel) codexAppModelMetadata {
metadata := codexAppDefaultModelMetadata()
if model.ContextLength > 0 {
metadata.contextWindow = model.ContextLength
}
if model.HasCapability("vision") {
metadata.inputModalities = []string{"text", "image"}
}
metadata.thinking = codexAppThinkingContractForModel(model)
metadata.toolCapable = model.ToolCapable || model.HasCapability(modelpkg.CapabilityTools)
return metadata
}
// Retain raw thinking values so the proxy can round-trip Codex levels, including booleans.
func codexAppThinkingContractForModel(model LaunchModel) codexAppThinkingContract {
if contract, ok := codexAppThinkingContractFromRecommendation(model.Thinking); ok {
return contract
}
if !model.HasCapability(modelpkg.CapabilityThinking) {
return codexAppThinkingContract{}
}
families := append([]string{model.Details.Family}, model.Details.Families...)
for _, family := range families {
normalized := strings.NewReplacer("-", "", "_", "", ".", "").Replace(strings.ToLower(strings.TrimSpace(family)))
switch normalized {
case "glm5next", "glmdsamoe":
// These family identifiers share a thinking contract.
return codexAppStringThinkingContract("max", "low", "high", "max")
case "gptoss":
return codexAppStringThinkingContract("medium", "low", "medium", "high")
}
}
// Binary thinking maps "none" to off and "high" to on.
return codexAppThinkingContract{
defaultLevel: "high",
levels: []string{"none", "high"},
values: map[string]any{"none": false, "high": true},
}
}
func codexAppThinkingContractFromRecommendation(thinking *api.ModelRecommendationThinking) (codexAppThinkingContract, bool) {
if !thinking.Valid() {
return codexAppThinkingContract{}, false
}
contract := codexAppThinkingContract{values: make(map[string]any, len(thinking.Values)), controls: thinking.Clone()}
for _, value := range thinking.Values {
if value == true && thinking.Supports("high") {
continue
}
level, ok := codexAppThinkingLevelForOllamaValue(value)
if !ok {
continue
}
if _, duplicate := contract.values[level]; duplicate {
continue
}
contract.levels = append(contract.levels, level)
contract.values[level] = value
}
defaultLevel, ok := codexAppThinkingLevelForOllamaValue(thinking.Default)
advertisedDefault, advertised := contract.values[defaultLevel]
if !ok || !advertised || advertisedDefault != thinking.Default {
return contract, true
}
if len(thinking.Values) == 1 && thinking.Supports(false) {
contract.levels = nil
contract.values = nil
return contract, true
}
contract.defaultLevel = defaultLevel
return contract, true
}
func codexAppThinkingLevelForOllamaValue(value any) (string, bool) {
switch value := value.(type) {
case bool:
if value {
return "high", true
}
return "none", true
case string:
switch value {
case "minimal", "low", "medium", "high", "xhigh", "max", "ultra":
return value, true
}
return "", false
}
return "", false
}
func codexAppStringThinkingContract(defaultLevel string, levels ...string) codexAppThinkingContract {
values := make(map[string]any, len(levels))
for _, level := range levels {
values[level] = level
}
return codexAppThinkingContract{defaultLevel: defaultLevel, levels: levels, values: values}
}
func codexAppThinkingLevelDescription(level string) string {
switch level {
case "none":
return "Turn thinking off"
case "low":
return "Fast responses with lighter thinking"
case "medium":
return "Balances speed and thinking depth for everyday tasks"
case "high":
return "Greater thinking depth for complex tasks"
case "max":
return "Maximum thinking depth for the hardest tasks"
default:
return "Thinking effort"
}
}
func codexAppCatalogEntry(model string, metadata codexAppModelMetadata, priority int, baseInstructions string) map[string]any {
var defaultReasoningLevel any
if metadata.thinking.defaultLevel != "" {
defaultReasoningLevel = metadata.thinking.defaultLevel
}
supportedReasoningLevels := make([]any, 0, len(metadata.thinking.levels))
for _, level := range metadata.thinking.levels {
supportedReasoningLevels = append(supportedReasoningLevels, map[string]any{
"effort": level,
"description": codexAppThinkingLevelDescription(level),
})
}
// Keep Ollama models visible in API-key sessions.
return map[string]any{
"slug": model,
"display_name": model,
"description": "Ollama model",
"default_reasoning_level": defaultReasoningLevel,
"supported_reasoning_levels": supportedReasoningLevels,
"shell_type": "unified_exec",
"visibility": "list",
"supported_in_api": true,
"priority": priority,
"additional_speed_tiers": []any{},
"service_tiers": []any{},
"default_service_tier": nil,
"availability_nux": nil,
"upgrade": nil,
"base_instructions": codexAppInstructionsForModel(baseInstructions, model),
"model_messages": nil,
"include_skills_usage_instructions": true,
"include_plugin_usage_instructions": true,
"include_apps_usage_instructions": true,
"supports_reasoning_summary_parameter": false,
"supports_reasoning_summaries": false,
"default_reasoning_summary": "auto",
"support_verbosity": false,
"default_verbosity": nil,
"apply_patch_tool_type": nil,
"web_search_tool_type": "text",
"truncation_policy": map[string]any{"mode": "tokens", "limit": 10_000},
"supports_parallel_tool_calls": metadata.toolCapable,
"supports_image_detail_original": false,
"context_window": metadata.contextWindow,
"max_context_window": metadata.contextWindow,
"auto_compact_token_limit": nil,
"effective_context_window_percent": 95,
"experimental_supported_tools": []any{},
"input_modalities": metadata.inputModalities,
"supports_search_tool": metadata.toolCapable,
}
}
func codexAppInstructionsForModel(baseInstructions, model string) string {
replacement := fmt.Sprintf("You are Codex, a coding agent powered by %s through Ollama.", model)
for _, identity := range []string{
"You are Codex, an agent based on GPT-5.",
"You are Codex, a coding agent based on GPT-5.",
} {
if strings.Contains(baseInstructions, identity) {
return strings.Replace(baseInstructions, identity, replacement, 1)
}
}
return baseInstructions
}
func codexAppBaseInstructions() string {
path, err := codexModelCachePath()
if err == nil {
var cached struct {
Models []struct {
BaseInstructions string `json:"base_instructions"`
} `json:"models"`
}
if data, readErr := os.ReadFile(path); readErr == nil {
if json.Unmarshal(data, &cached) == nil {
for _, model := range cached.Models {
if strings.TrimSpace(model.BaseInstructions) != "" {
return model.BaseInstructions
}
}
}
}
}
return "You are Codex, a coding agent. You and the user share the same workspace and collaborate to achieve the user's goals."
}
func codexAppBaseInstructionsFromCatalog(catalog codexAppRawModelCatalog) string {
for _, entry := range catalog.Models {
var model struct {
BaseInstructions string `json:"base_instructions"`
}
if json.Unmarshal(entry, &model) == nil && strings.TrimSpace(model.BaseInstructions) != "" {
return model.BaseInstructions
}
}
return codexAppBaseInstructions()
}
// Probe the native catalog in a scratch CODEX_HOME to avoid loading the generated catalog.
func defaultCodexAppNativeModelCatalog(configPath string) ([]byte, error) {
var attempts []error
executable, executableErr := codexAppCodexExecutable()
if executableErr == nil {
scratchHome, err := os.MkdirTemp("", "ollama-codex-models-")
if err != nil {
attempts = append(attempts, fmt.Errorf("create scratch CODEX_HOME: %w", err))
} else {
defer os.RemoveAll(scratchHome)
for _, name := range []string{"auth.json", "models_cache.json"} {
if err := copyCodexAppScratchFile(filepath.Join(filepath.Dir(configPath), name), filepath.Join(scratchHome, name)); err != nil && !os.IsNotExist(err) {
attempts = append(attempts, fmt.Errorf("copy %s to scratch CODEX_HOME: %w", name, err))
}
}
data, err := codexAppRunDebugModels(executable, scratchHome, false)
if err == nil {
if normalized, normalizeErr := normalizeCodexAppModelCatalog(data); normalizeErr == nil {
return normalized, nil
} else {
attempts = append(attempts, fmt.Errorf("validate codex debug models output: %w", normalizeErr))
}
} else {
attempts = append(attempts, fmt.Errorf("run codex debug models: %w", err))
}
}
} else {
attempts = append(attempts, executableErr)
}
cachePath := filepath.Join(filepath.Dir(configPath), "models_cache.json")
if data, err := os.ReadFile(cachePath); err == nil {
if normalized, normalizeErr := normalizeCodexAppModelCatalog(data); normalizeErr == nil {
return normalized, nil
} else {
attempts = append(attempts, fmt.Errorf("validate cached Codex models: %w", normalizeErr))
}
} else {
attempts = append(attempts, fmt.Errorf("read cached Codex models: %w", err))
}
if executableErr == nil {
data, err := codexAppRunDebugModels(executable, "", true)
if err == nil {
if normalized, normalizeErr := normalizeCodexAppModelCatalog(data); normalizeErr == nil {
return normalized, nil
} else {
attempts = append(attempts, fmt.Errorf("validate codex debug models --bundled output: %w", normalizeErr))
}
} else {
attempts = append(attempts, fmt.Errorf("run codex debug models --bundled: %w", err))
}
}
return nil, errors.Join(attempts...)
}
func normalizeCodexAppModelCatalog(data []byte) ([]byte, error) {
catalog, err := parseCodexAppModelCatalog(data)
if err != nil {
return nil, err
}
return json.Marshal(catalog)
}
func copyCodexAppScratchFile(source, destination string) error {
data, err := os.ReadFile(source)
if err != nil {
return err
}
return os.WriteFile(destination, data, 0o600)
}
func defaultCodexAppCodexExecutable() (string, error) {
appPath := codexAppAppPath()
if appPath == "" && codexAppGOOS == "windows" {
appPath = codexAppRunPath()
}
var candidates []string
if appPath != "" {
switch codexAppGOOS {
case "darwin":
candidates = append(candidates, filepath.Join(appPath, "Contents", "Resources", "codex"))
case "windows":
appDir := filepath.Dir(appPath)
candidates = append(candidates,
filepath.Join(appDir, "resources", "codex.exe"),
filepath.Join(appDir, "Resources", "codex.exe"),
)
}
}
for _, candidate := range candidates {
if info, err := codexAppStat(candidate); err == nil && !info.IsDir() {
return candidate, nil
}
}
if path, err := exec.LookPath("codex"); err == nil {
return path, nil
}
return "", fmt.Errorf("could not find the Codex executable used by the desktop app")
}
func defaultCodexAppRunDebugModels(executable, codexHome string, bundled bool) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
args := []string{"debug", "models"}
if bundled {
args = append(args, "--bundled")
}
cmd := exec.CommandContext(ctx, executable, args...)
cmd.Env = codexAppDebugModelsEnvironment(codexHome)
// Avoid inheriting Documents as cwd and triggering a Files & Folders prompt.
if codexHome != "" {
cmd.Dir = codexHome
} else {
cmd.Dir = os.TempDir()
}
data, err := cmd.Output()
if ctx.Err() != nil {
return nil, ctx.Err()
}
if err != nil {
return nil, err
}
return data, nil
}
func codexAppDebugModelsEnvironment(codexHome string) []string {
env := make([]string, 0, len(os.Environ())+1)
for _, item := range os.Environ() {
name, _, _ := strings.Cut(item, "=")
switch name {
case "CODEX_HOME", "OPENAI_API_KEY", "CODEX_API_KEY":
continue
}
env = append(env, item)
}
if codexHome != "" {
env = append(env, "CODEX_HOME="+codexHome)
}
return env
}
func codexModelCachePath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".codex", "models_cache.json"), nil
}
func codexAppAppPath() string {
var candidates []string
switch codexAppGOOS {
case "darwin":
candidates = codexAppDarwinAppCandidates()
case "windows":
candidates = codexAppWindowsAppCandidates()
default:
return ""
}
for _, candidate := range candidates {
if info, err := codexAppStat(candidate); err == nil {
if codexAppGOOS == "darwin" && !info.IsDir() {
continue
}
if codexAppGOOS == "windows" && info.IsDir() {
continue
}
return candidate
}
}
return ""
}
func codexAppDarwinAppCandidates() []string {
candidates := []string{"/Applications/ChatGPT.app", "/Applications/Codex.app"}
if home, err := os.UserHomeDir(); err == nil {
candidates = append(candidates,
filepath.Join(home, "Applications", "ChatGPT.app"),
filepath.Join(home, "Applications", "Codex.app"),
)
}
return candidates
}
func codexAppWindowsAppCandidates() []string {
local, err := codexAppLocalAppData()
if err != nil {
return nil
}
candidates := []string{
filepath.Join(local, "Programs", "ChatGPT", "ChatGPT.exe"),
filepath.Join(local, "Programs", "OpenAI ChatGPT", "ChatGPT.exe"),
filepath.Join(local, "ChatGPT", "ChatGPT.exe"),
filepath.Join(local, "OpenAI ChatGPT", "ChatGPT.exe"),
filepath.Join(local, "OpenAI", "ChatGPT", "ChatGPT.exe"),
filepath.Join(local, "Programs", "Codex", "Codex.exe"),
filepath.Join(local, "Programs", "OpenAI Codex", "Codex.exe"),
filepath.Join(local, "Codex", "Codex.exe"),
filepath.Join(local, "OpenAI Codex", "Codex.exe"),
filepath.Join(local, "OpenAI", "Codex", "Codex.exe"),
filepath.Join(local, "openai-codex-electron", "Codex.exe"),
}
for _, pattern := range []string{
filepath.Join(local, "Programs", "ChatGPT", "app-*", "ChatGPT.exe"),
filepath.Join(local, "Programs", "OpenAI ChatGPT", "app-*", "ChatGPT.exe"),
filepath.Join(local, "ChatGPT", "app-*", "ChatGPT.exe"),
filepath.Join(local, "OpenAI ChatGPT", "app-*", "ChatGPT.exe"),
filepath.Join(local, "OpenAI", "ChatGPT", "app-*", "ChatGPT.exe"),
filepath.Join(local, "Programs", "Codex", "app-*", "Codex.exe"),
filepath.Join(local, "Programs", "OpenAI Codex", "app-*", "Codex.exe"),
filepath.Join(local, "Codex", "app-*", "Codex.exe"),
filepath.Join(local, "OpenAI Codex", "app-*", "Codex.exe"),
filepath.Join(local, "OpenAI", "Codex", "app-*", "Codex.exe"),
filepath.Join(local, "openai-codex-electron", "app-*", "Codex.exe"),
} {
matches, _ := codexAppGlob(pattern)
candidates = append(candidates, matches...)
}
return codexAppDedupePaths(candidates)
}
func codexAppDedupePaths(paths []string) []string {
out := make([]string, 0, len(paths))
seen := make(map[string]bool, len(paths))
for _, path := range paths {
if strings.TrimSpace(path) == "" {
continue
}
key := strings.ToLower(path)
if seen[key] {
continue
}
seen[key] = true
out = append(out, path)
}
return out
}
func codexAppLocalAppData() (string, error) {
if local := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); local != "" {
return local, nil
}
if home := strings.TrimSpace(os.Getenv("USERPROFILE")); home != "" {
return filepath.Join(home, "AppData", "Local"), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, "AppData", "Local"), nil
}
func codexAppLaunchOrRestart(prompt string, launchArgs []string) error {
if !codexAppIsRunning() {
return codexAppOpenApp(launchArgs)
}
restartAppID := ""
restartAppPath := ""
if len(launchArgs) == 0 && codexAppGOOS == "windows" {
restartAppID = codexAppStartID()
if restartAppID == "" {
restartAppPath = codexAppRunPath()
}
}
restart, err := ConfirmPrompt(prompt)
if err != nil {
return err
}
if !restart {
fmt.Fprintln(os.Stderr, "\nQuit and reopen ChatGPT when you're ready for the profile change to take effect.")
return nil
}
// A single spinner and cancellation channel span the entire restart flow
// (quit, wait, reopen) so that one Ctrl+C aborts the whole sequence rather
// than just the currently-active wait. The bubbletea
// spinner closes Cancelled() from its raw-mode Ctrl+C handler; the ANSI
// fallback relies on SIGINT terminating the process directly.
sp := StartSpinner(codexAppRestartMessage)
defer sp.Stop()
cancelled := sp.Cancelled()
isCancelled := func() bool {
if cancelled == nil {
return false
}
select {
case <-cancelled:
return true
default:
return false
}
}
if err := codexAppQuitApp(); err != nil {
return fmt.Errorf("quit ChatGPT: %w", err)
}
if isCancelled() {
return ErrCancelled
}
if err := waitForCodexAppGracefulExit(codexAppExitTimeout, cancelled); err != nil {
return err
}
if isCancelled() {
return ErrCancelled
}
if sp != nil {
sp.Stop()
}
return codexAppOpenAfterRestart(restartAppID, restartAppPath, launchArgs)
}
func codexAppApplyProfileFromDesktop(change func() error, openWhenStopped, restartConfirmed bool) error {
if !codexAppIsRunning() {
if err := change(); err != nil {
return err
}
if openWhenStopped {
return codexAppOpenApp(nil)
}
return nil
}
if !restartConfirmed {
return ErrCodexAppRestartConfirmationRequired
}
restartAppID, restartAppPath := codexAppRestartTarget()
if err := codexAppStopForRestart(nil); err != nil {
return err
}
if err := change(); err != nil {
changeErr := fmt.Errorf("change ChatGPT profile: %w", err)
if openErr := codexAppOpenAfterRestart(restartAppID, restartAppPath, nil); openErr != nil {
return errors.Join(changeErr, fmt.Errorf("reopen ChatGPT after profile failure: %w", openErr))
}
return changeErr
}
return codexAppOpenAfterRestart(restartAppID, restartAppPath, nil)
}
func codexAppRestartTarget() (appID, appPath string) {
if codexAppGOOS != "windows" {
return "", ""
}
if appID = codexAppStartID(); appID == "" {
appPath = codexAppRunPath()
}
return appID, appPath
}
func codexAppStopForRestart(cancel <-chan struct{}) error {
if err := codexAppQuitApp(); err != nil {
return fmt.Errorf("quit ChatGPT: %w", err)
}
return waitForCodexAppGracefulExit(codexAppExitTimeout, cancel)
}
func codexAppOpenAfterRestart(appID, appPath string, launchArgs []string) error {
if appID != "" {
return codexAppOpenStart(appID)
}
if appPath != "" {
return codexAppOpenPath(appPath)
}
return codexAppOpenApp(launchArgs)
}
func waitForCodexAppGracefulExit(timeout time.Duration, cancel <-chan struct{}) error {
return waitForCodexAppCondition(timeout, cancel, func() bool {
return !codexAppIsRunning()
})
}
// codexAppRestartMessage is the label shown next to the animated spinner while
// the ChatGPT desktop app is quitting before being reopened.
const codexAppRestartMessage = "Restarting ChatGPT..."
// waitForCodexAppCondition polls done at a 200ms cadence until it reports the
// app has exited or timeout elapses. It watches cancel (closed by the spinner
// when the user hits Ctrl+C) and returns ErrCancelled if the flow is aborted.
// The spinner itself is owned by the caller so a single spinner spans the
// whole restart sequence. Timing out never escalates to a forced termination;
// the profile remains unchanged and the user can retry after closing ChatGPT.
func waitForCodexAppCondition(timeout time.Duration, cancel <-chan struct{}, done func() bool) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if cancel != nil {
select {
case <-cancel:
return ErrCancelled
default:
}
}
if done() {
return nil
}
codexAppSleep(200 * time.Millisecond)
}
if done() {
return nil
}
return fmt.Errorf("ChatGPT did not quit. Close it manually, then try again")
}
func defaultCodexAppOllamaProfileApplication() (string, error) {
appPath := codexAppAppPath()
if appPath == "" {
return "", fmt.Errorf("ChatGPT was not found; install it from https://chatgpt.com/download")
}
return appPath, nil
}
func defaultCodexAppOllamaProfileExecutable() (string, error) {
appPath, err := codexAppProfileApplication()
if err != nil {
return "", err
}
executable := filepath.Join(appPath, "Contents", "MacOS", "ChatGPT")
if info, err := codexAppStat(executable); err != nil || info.IsDir() {
return "", fmt.Errorf("could not find the ChatGPT desktop executable at %s", executable)
}
return executable, nil
}
func prepareCodexAppOllamaProfileUserData(userDataDir string) (bool, error) {
if err := os.MkdirAll(userDataDir, 0o700); err != nil {
return false, err
}
if adoptCodexAppOllamaProfileSingleton(userDataDir) {
return true, nil
}
for _, name := range []string{
codexAppSingletonLockName,
codexAppSingletonSocketName,
codexAppSingletonCookieName,
} {
if err := os.Remove(filepath.Join(userDataDir, name)); err != nil && !os.IsNotExist(err) {
return false, fmt.Errorf("clear stale ChatGPT · Ollama %s: %w", name, err)
}
}
return false, nil
}
func adoptCodexAppOllamaProfileSingleton(userDataDir string) bool {
pid, ok := codexAppOllamaProfileSingletonPID(userDataDir)
if !ok || !codexAppOllamaProfileProcessMatches(pid) {
return false
}
return writeCodexAppOllamaProfilePID(pid) == nil
}
func codexAppOllamaProfileSingletonPID(userDataDir string) (int, bool) {
target, err := os.Readlink(filepath.Join(userDataDir, codexAppSingletonLockName))
if err != nil {
return 0, false
}
base := filepath.Base(strings.TrimSpace(target))
separator := strings.LastIndexByte(base, '-')
if separator < 0 || separator == len(base)-1 {
return 0, false
}
pid, err := strconv.Atoi(base[separator+1:])
if err != nil || pid <= 1 || pid == os.Getpid() {
return 0, false
}
return pid, true
}
// Detect isolated legacy processes for cleanup; this integration uses one profile.
func defaultCodexAppOllamaProfileIsRunning() bool {
pid, ok := codexAppOllamaProfilePID()
if !ok {
return reconcileCodexAppOllamaProfileSingleton()
}
running, matches := codexAppOllamaProfileProcessIdentity(pid)
if !running || !matches {
_ = removeCodexAppOllamaProfilePID()
return reconcileCodexAppOllamaProfileSingleton()
}
return true
}
func reconcileCodexAppOllamaProfileSingleton() bool {
userDataDir, err := codexAppOllamaProfileUserDataDir()
if err != nil {
return false
}
running, err := prepareCodexAppOllamaProfileUserData(userDataDir)
return err == nil && running
}
func writeCodexAppOllamaProfilePID(pid int) error {
if pid <= 1 || pid == os.Getpid() {
return fmt.Errorf("invalid ChatGPT · Ollama process ID %d", pid)
}
pidPath, err := codexAppOllamaProfilePIDPath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(pidPath), 0o700); err != nil {
return err
}
return os.WriteFile(pidPath, []byte(strconv.Itoa(pid)+"\n"), 0o600)
}
func codexAppOllamaProfilePID() (int, bool) {
pidPath, err := codexAppOllamaProfilePIDPath()
if err != nil {
return 0, false
}
data, err := os.ReadFile(pidPath)
if err != nil {
return 0, false
}
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
if err != nil || pid <= 1 || pid == os.Getpid() {
_ = os.Remove(pidPath)
return 0, false
}
return pid, true
}
func codexAppOllamaProfileProcessMatches(pid int) bool {
_, matches := codexAppOllamaProfileProcessIdentity(pid)
return matches
}
func codexAppOllamaProfileProcessIdentity(pid int) (bool, bool) {
if pid <= 1 || pid == os.Getpid() {
return false, false
}
executable, err := codexAppProfileExecutable()
if err != nil {
return false, false
}
userDataDir, err := codexAppOllamaProfileUserDataDir()
if err != nil {
return false, false
}
command, err := codexAppProcessCommand(pid)
if err != nil {
return false, false
}
command = strings.TrimSpace(command)
return true, strings.HasPrefix(command, executable+" ") &&
strings.Contains(command, "--user-data-dir="+userDataDir)
}
func defaultCodexAppProcessCommand(pid int) (string, error) {
out, err := exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "command=").Output()
return string(out), err
}
func removeCodexAppOllamaProfilePID() error {
pidPath, err := codexAppOllamaProfilePIDPath()
if err != nil {
return err
}
if err := os.Remove(pidPath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func defaultCodexAppStopOllamaProfile() error {
pid, ok := codexAppOllamaProfilePID()
if !ok {
return removeCodexAppOllamaProfilePID()
}
running, matches := codexAppOllamaProfileProcessIdentity(pid)
if !running {
return removeCodexAppOllamaProfilePID()
}
if !matches {
_ = removeCodexAppOllamaProfilePID()
return fmt.Errorf("ChatGPT · Ollama process identity could not be verified")
}
process, err := os.FindProcess(pid)
if err != nil {
return err
}
if err := process.Signal(syscall.SIGTERM); err != nil {
return fmt.Errorf("stop ChatGPT · Ollama: %w", err)
}
deadline := time.Now().Add(codexAppExitTimeout)
for time.Now().Before(deadline) {
if !codexAppOllamaProfileProcessMatches(pid) {
return removeCodexAppOllamaProfilePID()
}
codexAppSleep(100 * time.Millisecond)
}
return fmt.Errorf("ChatGPT · Ollama did not close; quit that window and try again")
}
func defaultCodexAppOpenApp(args []string) error {
if len(args) > 0 {
cmd := exec.Command("codex", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = append(os.Environ(), "OPENAI_API_KEY=ollama")
return cmd.Run()
}
switch codexAppGOOS {
case "windows":
if path := codexAppAppPath(); path != "" {
return codexAppOpenPath(path)
}
if path := codexAppRunPath(); path != "" {
return codexAppOpenPath(path)
}
if appID := codexAppStartID(); appID != "" {
return codexAppOpenStart(appID)
}
return fmt.Errorf("ChatGPT was not found; install it from https://chatgpt.com/download, then re-run 'ollama launch chatgpt'")
case "darwin":
cmd := exec.Command("open", codexAppDarwinOpenArgs(codexAppAppPath())...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
default:
return codexAppSupported()
}
}
func codexAppDarwinOpenArgs(path string) []string {
args := []string{"-b", codexAppBundleID}
if path != "" {
args = []string{path}
}
if (&CodexApp{}).OllamaConfigured() {
if path != "" {
args = []string{"-a", path}
}
args = append(args, codexAppLaunchURL)
}
return args
}
func defaultCodexAppOpenAppPath(path string) error {
switch codexAppGOOS {
case "windows":
return exec.Command("powershell.exe", "-NoProfile", "-Command", "Start-Process -FilePath "+quotePowerShellString(path)).Run()
case "darwin":
cmd := exec.Command("open", codexAppDarwinOpenArgs(path)...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
default:
return codexAppSupported()
}
}
func defaultCodexAppOpenStartAppID(appID string) error {
return exec.Command("powershell.exe", "-NoProfile", "-Command", "Start-Process "+quotePowerShellString(`shell:AppsFolder\`+appID)).Run()
}
func defaultCodexAppQuitApp() error {
if codexAppGOOS == "windows" {
script := `Get-Process ChatGPT,Codex -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | ForEach-Object { [void]$_.CloseMainWindow() }`
return exec.Command("powershell.exe", "-NoProfile", "-Command", script).Run()
}
scriptErr := exec.Command("osascript", "-e", `tell application "ChatGPT" to quit`).Run()
if scriptErr != nil {
scriptErr = exec.Command("osascript", "-e", `tell application id "`+codexAppBundleID+`" to quit`).Run()
}
if scriptErr != nil {
scriptErr = exec.Command("osascript", "-e", `tell application "Codex" to quit`).Run()
}
return scriptErr
}
func defaultCodexAppIsRunning() bool {
switch codexAppGOOS {
case "windows":
return len(codexAppMatchingProcessIDs()) > 0
case "darwin":
if err := exec.Command("pgrep", "-a", "-x", "ChatGPT|Codex").Run(); err == nil {
return true
}
return len(codexAppMatchingProcessIDs()) > 0
default:
return false
}
}
func codexAppMatchingProcessIDs() []int {
if codexAppGOOS == "windows" {
return codexAppWindowsMatchingProcessIDs()
}
out, err := exec.Command("ps", "-axo", "pid=,command=").Output()
if err != nil {
return nil
}
var pids []int
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
pid, err := strconv.Atoi(fields[0])
if err != nil || pid == os.Getpid() {
continue
}
command := strings.TrimSpace(strings.TrimPrefix(line, fields[0]))
if codexAppProcessMatches(command) {
pids = append(pids, pid)
}
}
return pids
}
func codexAppWindowsMatchingProcessIDs() []int {
script := fmt.Sprintf(`$current = %d; Get-CimInstance Win32_Process -Filter "Name = 'Codex.exe' OR Name = 'codex.exe' OR Name = 'ChatGPT.exe' OR Name = 'chatgpt.exe'" | Where-Object { $_.ProcessId -ne $current -and ((($_.Name -ieq 'Codex.exe' -or $_.Name -ieq 'ChatGPT.exe') -and (($null -eq $_.CommandLine) -or ($_.CommandLine -notlike '* --type=*'))) -or ((($_.Name -ieq 'codex.exe') -or ($_.Name -ieq 'chatgpt.exe')) -and ($_.CommandLine -like '*app-server*'))) } | Select-Object -ExpandProperty ProcessId`, os.Getpid())
out, err := exec.Command("powershell.exe", "-NoProfile", "-Command", script).Output()
if err != nil {
return nil
}
var pids []int
for _, line := range strings.Split(string(out), "\n") {
pid, err := strconv.Atoi(strings.TrimSpace(line))
if err == nil && pid != os.Getpid() {
pids = append(pids, pid)
}
}
return pids
}
func defaultCodexAppRunningAppPath() string {
if codexAppGOOS != "windows" {
return ""
}
script := `(Get-Process ChatGPT,Codex -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 -and $_.Path } | Select-Object -First 1 -ExpandProperty Path)`
out, err := exec.Command("powershell.exe", "-NoProfile", "-Command", script).Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
func defaultCodexAppStartAppID() string {
if codexAppGOOS != "windows" {
return ""
}
script := `(Get-StartApps | Where-Object { $_.Name -eq 'ChatGPT' -or $_.Name -like 'ChatGPT*' -or $_.Name -eq 'Codex' -or $_.Name -like 'Codex*' } | Select-Object -First 1 -ExpandProperty AppID)`
out, err := exec.Command("powershell.exe", "-NoProfile", "-Command", script).Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
func defaultCodexAppCanOpenBundleID() bool {
if codexAppGOOS != "darwin" {
return false
}
query := fmt.Sprintf("kMDItemCFBundleIdentifier == %q", codexAppBundleID)
out, err := exec.Command("mdfind", query).Output()
return err == nil && strings.TrimSpace(string(out)) != ""
}
func codexAppProcessMatches(command string) bool {
if (strings.Contains(command, `\Codex.exe`) || strings.Contains(command, `\ChatGPT.exe`)) && strings.Contains(command, " --type=") {
return false
}
for _, pattern := range codexAppProcessPatterns() {
if strings.Contains(command, pattern) {
return true
}
}
return false
}
func codexAppProcessPatterns() []string {
return []string{
"ChatGPT.app/Contents/MacOS/ChatGPT",
"ChatGPT.app/Contents/Resources/codex app-server",
"Codex.app/Contents/MacOS/Codex",
"Codex.app/Contents/Resources/codex app-server",
`\ChatGPT.exe`,
`resources\chatgpt.exe app-server`,
`resources\chatgpt.exe" app-server`,
`resources\chatgpt.exe" "app-server`,
`\Codex.exe`,
`resources\codex.exe app-server`,
`resources\codex.exe" app-server`,
`resources\codex.exe" "app-server`,
}
}
func codexNormalizeURL(raw string) string {
return strings.TrimRight(strings.TrimSpace(raw), "/")
}
func codexAppRootStillManaged(text string) bool {
config, err := codexParseConfig(text)
if err != nil {
return false
}
return codexAppIsOwnedProfileName(config.RootString(codexRootProfileKey)) ||
codexAppIsOwnedProfileName(config.RootString(codexRootModelProviderKey)) ||
codexAppRootUsesProxy(config)
}
func codexAppRootReferencesOwnedConfig(text string) bool {
config, err := codexParseConfig(text)
if err != nil {
return false
}
return config.RootString(codexRootProfileKey) == codexAppProfileName ||
config.RootString(codexRootModelProviderKey) == codexAppProfileName ||
codexAppRootUsesProxy(config)
}
func codexAppRootUsesProxy(config codexParsedConfig) bool {
catalogPath, err := codexAppModelCatalogPath()
if err != nil {
return false
}
// Older managed configs explicitly selected the built-in OpenAI provider.
// Accept both forms so they remain detectable and restorable after upgrade.
if modelProvider, ok := config.RootStringOK(codexRootModelProviderKey); ok && modelProvider != "openai" {
return false
}
return codexAppManagedProxyURL(config.RootString(codexRootOpenAIBaseURLKey)) &&
config.RootString(codexRootModelCatalogJSONKey) == catalogPath
}
func codexAppManagedProxyURL(raw string) bool {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u.Scheme == "" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
return false
}
if u.Scheme != "http" && u.Scheme != "https" {
return false
}
host := u.Hostname()
if !strings.EqualFold(host, "localhost") {
ip := net.ParseIP(host)
if ip == nil || !ip.IsLoopback() {
return false
}
}
// ConnectableHost preserves proxy path prefixes from OLLAMA_HOST.
return strings.HasSuffix(strings.TrimSuffix(u.Path, "/"), proxy.CodexDesktopPathPrefix+"/v1")
}
func codexAppRootReferencesCatalog(text string) bool {
catalogPath, err := codexAppModelCatalogPath()
if err != nil {
return false
}
config, err := codexParseConfig(text)
if err != nil {
return false
}
return config.RootString(codexRootModelCatalogJSONKey) == catalogPath
}
func codexAppRemoveOwnedSections(text string) string {
text = codexAppRemoveOwnedSection(text, codexProfileHeaderFor(codexAppProfileName))
text = codexAppRemoveOwnedSection(text, codexProviderHeaderFor(codexAppProfileName))
return text
}
func codexAppRemoveOwnedSection(text, header string) string {
targetPath, ok := codexTableHeaderPath(header)
if !ok {
return text
}
start, end, found := codexSectionRange(text, targetPath)
if !found {
return text
}
if start > 0 && strings.HasSuffix(text[:start], "\n\n") {
start--
}
return text[:start] + text[end:]
}
func codexAppRemoveOwnedCatalogIfUnused(text string) error {
if codexAppRootReferencesCatalog(text) {
return nil
}
return codexAppRemoveOwnedCatalog()
}
func codexAppRemoveOwnedCatalog() error {
configPath, err := codexConfigPath()
if err != nil {
return err
}
for _, path := range []string{
codexAppModelCatalogPathForConfig(configPath),
codexAppRoutingCatalogPathForConfig(configPath),
} {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
}
return nil
}
func removeCodexAppProfileConfig() error {
profilePath, err := codexAppProfileConfigPath()
if err != nil {
return err
}
if err := os.Remove(profilePath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func codexAppRemoveOwnedRootValues(text string) string {
config, err := codexParseConfig(text)
if err != nil {
return text
}
modelProvider := config.RootString(codexRootModelProviderKey)
modelCatalogJSON := config.RootString(codexRootModelCatalogJSONKey)
usesProxy := codexAppRootUsesProxy(config)
if !codexAppIsOwnedProfileName(config.RootString(codexRootProfileKey)) && !codexAppIsOwnedProfileName(modelProvider) && !usesProxy {
return text
}
text = codexRemoveRootValue(text, codexRootProfileKey)
text = codexRemoveRootValue(text, codexRootModelKey)
if codexAppIsOwnedProfileName(modelProvider) || usesProxy {
text = codexRemoveRootValue(text, codexRootModelProviderKey)
}
if catalogPath, err := codexAppModelCatalogPath(); err == nil && modelCatalogJSON == catalogPath {
text = codexRemoveRootValue(text, codexRootModelCatalogJSONKey)
}
if usesProxy {
text = codexRemoveRootValue(text, codexRootOpenAIBaseURLKey)
}
return text
}
func codexAppRestoreRootValues(text string, state codexAppRestoreState) string {
if !codexAppRootStillManaged(text) {
return text
}
preserveCurrentModel := codexAppShouldPreserveCurrentModel(text)
text = codexRestoreRootStringValue(text, codexRootProfileKey, state.HadProfile, state.Profile)
if !preserveCurrentModel {
text = codexRestoreRootStringValue(text, codexRootModelKey, state.HadModel, state.Model)
}
text = codexRestoreRootStringValue(text, codexRootModelProviderKey, state.HadModelProvider, state.ModelProvider)
text = codexRestoreRootStringValue(text, codexRootModelCatalogJSONKey, state.HadModelCatalogJSON, state.ModelCatalogJSON)
text = codexRestoreRootStringValue(text, codexRootOpenAIBaseURLKey, state.HadOpenAIBaseURL, state.OpenAIBaseURL)
if state.HadDesktopReasoningEfforts {
text = codexAppSetReasoningEfforts(text, state.DesktopReasoningEfforts)
} else {
text = codexAppRemoveReasoningEfforts(text)
}
return text
}
// codexAppShouldPreserveCurrentModel keeps a native model the user selected
// while Ollama routing was active. Selected Ollama slugs are launch-owned and
// must still be replaced with the pre-integration model during restore.
func codexAppShouldPreserveCurrentModel(text string) bool {
config, err := codexParseConfig(text)
if err != nil {
return false
}
current, ok := config.RootStringOK(codexRootModelKey)
if !ok || strings.TrimSpace(current) == "" {
return false
}
models, err := codexAppRoutingModels()
if err != nil {
return false
}
target := codexAppCatalogModelKey(current)
for _, model := range models {
if codexAppCatalogModelKey(model) == target {
return false
}
}
return true
}
type codexAppRestoreState struct {
HadProfile bool `json:"had_profile"`
Profile string `json:"profile,omitempty"`
HadModel bool `json:"had_model"`
Model string `json:"model,omitempty"`
HadModelProvider bool `json:"had_model_provider"`
ModelProvider string `json:"model_provider,omitempty"`
HadModelCatalogJSON bool `json:"had_model_catalog_json"`
ModelCatalogJSON string `json:"model_catalog_json,omitempty"`
HadOpenAIBaseURL bool `json:"had_openai_base_url"`
OpenAIBaseURL string `json:"openai_base_url,omitempty"`
HadDesktopReasoningEfforts bool `json:"had_desktop_reasoning_efforts"`
DesktopReasoningEfforts []string `json:"desktop_reasoning_efforts,omitempty"`
}
func saveCodexAppRestoreState(configPath string) error {
configText := ""
configExists := false
if configData, err := os.ReadFile(configPath); err == nil {
configText = string(configData)
if err := codexValidateConfigText(configText); err != nil {
return err
}
configExists = true
} else if !os.IsNotExist(err) {
return err
}
if !configExists {
return writeCodexAppRestoreState(codexAppRestoreState{})
}
statePath := codexAppRestoreStatePath()
if stateData, err := os.ReadFile(statePath); err == nil {
hasRootConfig, err := codexAppRestoreStateHasRootConfig(stateData)
if err != nil {
return err
}
if hasRootConfig {
if configExists && !codexAppRootStillManaged(configText) {
return writeCodexAppRestoreState(codexAppRestoreStateFromText(configText))
}
return nil
}
var existing codexAppRestoreState
if err := json.Unmarshal(stateData, &existing); err != nil {
return err
}
var existingFields map[string]json.RawMessage
if err := json.Unmarshal(stateData, &existingFields); err != nil {
return err
}
upgraded := codexAppRestoreStateFromText(configText)
if codexAppRootStillManaged(configText) {
// Legacy restore state did not record root model settings. If the
// current config is still ours, do not save our generated root
// values as the user's restore target. The old integration did not
// manage desktop reasoning efforts, so their current value is still
// the user's original value and must be retained during the upgrade.
upgraded = codexAppRestoreState{
HadDesktopReasoningEfforts: upgraded.HadDesktopReasoningEfforts,
DesktopReasoningEfforts: upgraded.DesktopReasoningEfforts,
}
}
upgraded.HadProfile = existing.HadProfile
upgraded.Profile = existing.Profile
if _, ok := existingFields["had_model"]; ok {
upgraded.HadModel = existing.HadModel
upgraded.Model = existing.Model
}
if _, ok := existingFields["had_model_provider"]; ok {
upgraded.HadModelProvider = existing.HadModelProvider
upgraded.ModelProvider = existing.ModelProvider
}
if _, ok := existingFields["had_model_catalog_json"]; ok {
upgraded.HadModelCatalogJSON = existing.HadModelCatalogJSON
upgraded.ModelCatalogJSON = existing.ModelCatalogJSON
}
if _, ok := existingFields["had_openai_base_url"]; ok {
upgraded.HadOpenAIBaseURL = existing.HadOpenAIBaseURL
upgraded.OpenAIBaseURL = existing.OpenAIBaseURL
}
return writeCodexAppRestoreState(upgraded)
} else if !os.IsNotExist(err) {
return err
}
state := codexAppRestoreStateFromText(configText)
if codexAppRootStillManaged(configText) {
state = codexAppRestoreState{}
}
return writeCodexAppRestoreState(state)
}
func codexAppRestoreStateHasRootConfig(data []byte) (bool, error) {
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return false, err
}
_, hasModel := raw["had_model"]
_, hasModelProvider := raw["had_model_provider"]
_, hasModelCatalogJSON := raw["had_model_catalog_json"]
_, hasOpenAIBaseURL := raw["had_openai_base_url"]
_, hasDesktopReasoningEfforts := raw["had_desktop_reasoning_efforts"]
return hasModel && hasModelProvider && hasModelCatalogJSON && hasOpenAIBaseURL && hasDesktopReasoningEfforts, nil
}
func codexAppRestoreStateFromText(text string) codexAppRestoreState {
config, err := codexParseConfig(text)
if err != nil {
return codexAppRestoreState{}
}
profile, hadProfile := config.RootStringOK(codexRootProfileKey)
model, hadModel := config.RootStringOK(codexRootModelKey)
modelProvider, hadModelProvider := config.RootStringOK(codexRootModelProviderKey)
modelCatalogJSON, hadModelCatalogJSON := config.RootStringOK(codexRootModelCatalogJSONKey)
openAIBaseURL, hadOpenAIBaseURL := config.RootStringOK(codexRootOpenAIBaseURLKey)
desktopReasoningEfforts, hadDesktopReasoningEfforts := codexAppConfigReasoningEfforts(config)
return codexAppRestoreState{
HadProfile: hadProfile,
Profile: profile,
HadModel: hadModel,
Model: model,
HadModelProvider: hadModelProvider,
ModelProvider: modelProvider,
HadModelCatalogJSON: hadModelCatalogJSON,
ModelCatalogJSON: modelCatalogJSON,
HadOpenAIBaseURL: hadOpenAIBaseURL,
OpenAIBaseURL: openAIBaseURL,
HadDesktopReasoningEfforts: hadDesktopReasoningEfforts,
DesktopReasoningEfforts: desktopReasoningEfforts,
}
}
func codexRestoreRootStringValue(text, key string, hadValue bool, value string) string {
if hadValue {
return codexSetRootStringValue(text, key, value)
}
return codexRemoveRootValue(text, key)
}
func writeCodexAppRestoreState(state codexAppRestoreState) error {
path := codexAppRestoreStatePath()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
return fileutil.WriteWithBackup(path, data, codexAppIntegrationName)
}
func loadCodexAppRestoreState() (codexAppRestoreState, error) {
data, err := os.ReadFile(codexAppRestoreStatePath())
if err != nil {
return codexAppRestoreState{}, err
}
var state codexAppRestoreState
if err := json.Unmarshal(data, &state); err != nil {
return codexAppRestoreState{}, err
}
return state, nil
}
func removeCodexAppRestoreState() error {
if err := os.Remove(codexAppRestoreStatePath()); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func codexAppRestoreStatePath() string {
home, err := os.UserHomeDir()
if err != nil {
return filepath.Join(os.TempDir(), "ollama-codex-app-restore.json")
}
return filepath.Join(home, ".ollama", "launch", "codex-app-restore.json")
}