From 87b9f9e95acc1b17d002edac076b4f7db7ff31c0 Mon Sep 17 00:00:00 2001 From: Eva H <63033505+hoyyeva@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:04:48 -0400 Subject: [PATCH] app: add Ollama to ChatGPT Desktop (#18236) --- api/types.go | 31 +- app/cmd/app/app_darwin.go | 66 + app/cmd/app/app_darwin.h | 7 + app/cmd/app/app_darwin.m | 432 +++- app/cmd/app/app_darwin_test.go | 16 + app/cmd/app/codex_app_bindings_darwin.go | 75 + app/cmd/app/codex_app_bindings_windows.go | 7 + app/cmd/app/codex_app_darwin.go | 979 +++++++++ app/cmd/app/codex_app_darwin_test.go | 1379 +++++++++++++ app/cmd/app/codex_desktop_installer_darwin.go | 212 ++ .../codex_desktop_installer_darwin_test.go | 120 ++ app/cmd/app/webview.go | 1 + .../Ollama.app/Contents/Resources/chatgpt.svg | 1 + app/ui/app/public/launch-icons/codex-app.png | Bin 41615 -> 0 bytes .../app/public/launch-icons/codex-color.svg | 1 + .../ClaudeDesktopModelsSettings.tsx | 23 +- .../CodexDesktopModelsSettings.test.tsx | 1094 ++++++++++ .../components/CodexDesktopModelsSettings.tsx | 645 ++++++ .../src/components/CodexDesktopRow.test.tsx | 558 ++++++ app/ui/app/src/components/CodexDesktopRow.tsx | 387 ++++ app/ui/app/src/components/Onboarding.test.tsx | 94 +- app/ui/app/src/components/Onboarding.tsx | 37 +- .../components/Settings.interaction.test.tsx | 62 +- app/ui/app/src/components/Settings.test.ts | 44 + app/ui/app/src/components/Settings.tsx | 68 +- app/ui/app/src/hooks/useUser.ts | 11 +- app/ui/app/src/lib/launchCommands.ts | 3 + app/ui/app/src/types/webview.d.ts | 72 + app/ui/ui.go | 2 +- app/ui/ui_test.go | 3 +- cmd/launch/codex.go | 21 +- cmd/launch/codex_app.go | 1732 ++++++++++++++-- cmd/launch/codex_app_profile.go | 245 +++ cmd/launch/codex_app_test.go | 1764 +++++++++++++++-- cmd/launch/integrations_test.go | 2 +- cmd/launch/launch.go | 25 +- cmd/launch/launch_test.go | 27 + cmd/launch/model_inventory.go | 2 + cmd/launch/registry.go | 2 +- cmd/launch/test_config_helpers_test.go | 7 + cmd/tui/tui_test.go | 2 +- internal/proxy/codex_desktop.go | 645 ++++++ internal/proxy/codex_desktop_autoreview.go | 623 ++++++ internal/proxy/codex_desktop_errors.go | 206 ++ internal/proxy/codex_desktop_errors_test.go | 232 +++ internal/proxy/codex_desktop_normalize.go | 333 ++++ internal/proxy/codex_desktop_test.go | 1586 +++++++++++++++ openai/responses.go | 18 +- openai/responses_compact.go | 73 +- openai/responses_compact_test.go | 134 ++ openai/responses_test.go | 27 +- server/codex_proxy.go | 34 + server/codex_proxy_test.go | 41 + server/model_recommendations.go | 5 +- server/model_recommendations_test.go | 21 + server/routes.go | 9 + 56 files changed, 13702 insertions(+), 544 deletions(-) create mode 100644 app/cmd/app/codex_app_bindings_darwin.go create mode 100644 app/cmd/app/codex_app_bindings_windows.go create mode 100644 app/cmd/app/codex_app_darwin.go create mode 100644 app/cmd/app/codex_app_darwin_test.go create mode 100644 app/cmd/app/codex_desktop_installer_darwin.go create mode 100644 app/cmd/app/codex_desktop_installer_darwin_test.go create mode 100644 app/darwin/Ollama.app/Contents/Resources/chatgpt.svg delete mode 100644 app/ui/app/public/launch-icons/codex-app.png create mode 100644 app/ui/app/public/launch-icons/codex-color.svg create mode 100644 app/ui/app/src/components/CodexDesktopModelsSettings.test.tsx create mode 100644 app/ui/app/src/components/CodexDesktopModelsSettings.tsx create mode 100644 app/ui/app/src/components/CodexDesktopRow.test.tsx create mode 100644 app/ui/app/src/components/CodexDesktopRow.tsx create mode 100644 cmd/launch/codex_app_profile.go create mode 100644 internal/proxy/codex_desktop.go create mode 100644 internal/proxy/codex_desktop_autoreview.go create mode 100644 internal/proxy/codex_desktop_errors.go create mode 100644 internal/proxy/codex_desktop_errors_test.go create mode 100644 internal/proxy/codex_desktop_normalize.go create mode 100644 internal/proxy/codex_desktop_test.go create mode 100644 server/codex_proxy.go create mode 100644 server/codex_proxy_test.go diff --git a/api/types.go b/api/types.go index 82dd9a449..7ea9022be 100644 --- a/api/types.go +++ b/api/types.go @@ -816,12 +816,31 @@ type ModelRecommendationMappings map[string]ModelRecommendationMapping // ModelRecommendation is a single recommendation entry in [ModelRecommendationsResponse]. type ModelRecommendation struct { - Model string `json:"model"` - Description string `json:"description"` - ContextLength int `json:"context_length,omitempty"` - MaxOutputTokens int `json:"max_output_tokens,omitempty"` - VRAMBytes int64 `json:"vram_bytes,omitempty"` - RequiredPlan string `json:"required_plan,omitempty"` + Model string `json:"model"` + Description string `json:"description"` + ContextLength int `json:"context_length,omitempty"` + MaxOutputTokens int `json:"max_output_tokens,omitempty"` + VRAMBytes int64 `json:"vram_bytes,omitempty"` + RequiredPlan string `json:"required_plan,omitempty"` + Thinking *ModelRecommendationThinking `json:"thinking,omitempty"` +} + +// ModelRecommendationThinking advertises the exact values accepted by +// Ollama's think field and the model's default. Values may be booleans for +// binary thinking controls or strings for adjustable effort levels. +type ModelRecommendationThinking struct { + Values []any `json:"values,omitempty"` + Default any `json:"default,omitempty"` +} + +// Clone returns an independent copy. +func (t *ModelRecommendationThinking) Clone() *ModelRecommendationThinking { + if t == nil { + return nil + } + clone := *t + clone.Values = append([]any(nil), t.Values...) + return &clone } // ProcessResponse is the response from [Client.Process]. diff --git a/app/cmd/app/app_darwin.go b/app/cmd/app/app_darwin.go index 2d78e0430..cd9250372 100644 --- a/app/cmd/app/app_darwin.go +++ b/app/cmd/app/app_darwin.go @@ -1237,6 +1237,37 @@ func IsClaudeDesktopRunning() C.bool { return C._Bool(launch.ClaudeDesktopRunning()) } +//export IsCodexDesktopInstalled +func IsCodexDesktopInstalled() C.bool { + return C._Bool(codexDesktop.Installed()) +} + +//export IsCodexDesktopConnected +func IsCodexDesktopConnected() C.bool { + return C._Bool(codexDesktop.OllamaConfigured()) +} + +//export IsCodexDesktopRunning +func IsCodexDesktopRunning() C.bool { + return C._Bool(codexDesktop.Running()) +} + +//export CodexDesktopRequestCount +func CodexDesktopRequestCount() C.ulonglong { + return C.ulonglong(codexDesktop.OllamaRequestCount()) +} + +//export SetCodexDesktopConnected +func SetCodexDesktopConnected(connected, restartConfirmed C.bool) C.bool { + shouldConnect := connected != C._Bool(false) + confirmed := restartConfirmed != C._Bool(false) + if err := setCodexDesktopConnection(shouldConnect, confirmed); err != nil { + slog.Warn("failed to change ChatGPT integration", "connected", shouldConnect, "error", err) + return C._Bool(false) + } + return C._Bool(true) +} + //export IsClaudeGatewayConfigured func IsClaudeGatewayConfigured() C.bool { return C._Bool(claudeDesktop.UsesOllamaGateway()) @@ -1725,6 +1756,21 @@ func requestClaudeDesktopInstall() claudeDesktopInstallResult { return claudeDesktopInstallResultFromCode(int(C.installClaudeDesktop())) } +func requestCodexDesktopInstall() codexDesktopInstallResult { + return codexDesktopInstallResultFromCode(int(C.installCodexDesktop())) +} + +func codexDesktopInstallResultFromCode(code int) codexDesktopInstallResult { + switch code { + case int(C.ClaudeInstallerOpened): + return codexDesktopInstallerOpened + case int(C.ClaudeInstallCancelled): + return codexDesktopInstallCancelled + default: + return codexDesktopInstallFailed + } +} + func claudeDesktopDownloadEndpoint(baseURL string) string { return strings.TrimRight(baseURL, "/") + "/download-app?app=claude-desktop&type=mac-zip" } @@ -1777,6 +1823,26 @@ func InstallClaudeDesktopArchive(path *C.cchar_t) C.bool { return C._Bool(true) } +//export InstallCodexDesktopDiskImage +func InstallCodexDesktopDiskImage(path *C.cchar_t) C.bool { + imagePath := C.GoString((*C.char)(unsafe.Pointer(path))) + installedPath, err := installCodexDesktopDiskImage(imagePath, codexDesktopInstallDestinations(), verifyCodexDesktopBundle) + if err != nil && installedPath != "" { + slog.Warn("installed ChatGPT but could not clean up its disk image", "path", installedPath, "error", err) + return C._Bool(true) + } + if errors.Is(err, errCodexDesktopDestinationExists) && codexDesktop.Installed() { + slog.Info("ChatGPT was installed while its download was in progress") + return C._Bool(true) + } + if err != nil { + slog.Warn("failed to install ChatGPT disk image", "error", err) + return C._Bool(false) + } + slog.Info("installed ChatGPT", "path", installedPath) + return C._Bool(true) +} + func getShowAppsInMenu() bool { return bool(C.ShowAppsInMenu()) } diff --git a/app/cmd/app/app_darwin.h b/app/cmd/app/app_darwin.h index 809ad4eeb..9505190ce 100644 --- a/app/cmd/app/app_darwin.h +++ b/app/cmd/app/app_darwin.h @@ -55,6 +55,11 @@ bool RestoreClaudeGatewayForShutdown(void); bool IsClaudeGatewayConfigured(void); bool IsClaudeDesktopInstalled(void); bool IsClaudeDesktopRunning(void); +bool IsCodexDesktopInstalled(void); +bool IsCodexDesktopConnected(void); +bool IsCodexDesktopRunning(void); +unsigned long long CodexDesktopRequestCount(void); +bool SetCodexDesktopConnected(bool connected, bool restartConfirmed); bool ClaudeGatewayStartFailed(void); bool ClaudeGatewayPortConflict(void); char *ClaudeGatewayErrorMessage(void); @@ -70,5 +75,7 @@ enum ClaudeInstallResult ClaudeInstallFailed, }; enum ClaudeInstallResult installClaudeDesktop(void); +enum ClaudeInstallResult installCodexDesktop(void); char *ClaudeDesktopDownloadRequest(char **authorization); bool InstallClaudeDesktopArchive(const char *archivePath); +bool InstallCodexDesktopDiskImage(const char *imagePath); diff --git a/app/cmd/app/app_darwin.m b/app/cmd/app/app_darwin.m index c34e8275b..cf376b93c 100644 --- a/app/cmd/app/app_darwin.m +++ b/app/cmd/app/app_darwin.m @@ -16,9 +16,46 @@ extern NSString *SystemWidePath; static NSString *const ClaudeDownloadPageURL = @"https://claude.com/download"; +static NSString *const ChatGPTDownloadPageURL = @"https://chatgpt.com/download"; +static NSString *const ChatGPTDiskImageURL = + @"https://persistent.oaistatic.com/codex-app-prod/Codex.dmg"; static NSString *const ShowAppsInMenuDefaultsKey = @"ShowAppsInMenu"; static NSBundle *OllamaResourceBundle(void); +typedef NS_ENUM(NSInteger, DesktopDownloadKind) { + DesktopDownloadNone, + DesktopDownloadClaude, + DesktopDownloadChatGPT, +}; + +typedef NS_ENUM(NSInteger, ChatGPTRestartAction) { + ChatGPTRestartActionAddModels, + ChatGPTRestartActionUpdateModels, + ChatGPTRestartActionRemoveModels, +}; + +static void configureChatGPTRestartAlert(NSAlert *alert, + ChatGPTRestartAction action) { + switch (action) { + case ChatGPTRestartActionAddModels: + [alert setMessageText:@"Restart ChatGPT to add Ollama models?"]; + [alert setInformativeText: + @"ChatGPT must restart to add Ollama models. Any running task will stop."]; + break; + case ChatGPTRestartActionUpdateModels: + [alert setMessageText:@"Restart ChatGPT to update Ollama models?"]; + [alert setInformativeText: + @"ChatGPT must restart to update Ollama models. Any running task will stop."]; + break; + case ChatGPTRestartActionRemoveModels: + [alert setMessageText:@"Restart ChatGPT to remove Ollama models?"]; + [alert setInformativeText: + @"ChatGPT must restart to remove Ollama models. Any running task will stop."]; + break; + } + [alert addButtonWithTitle:@"Restart ChatGPT"]; +} + static BOOL shouldShowAppsInMenu(void) { NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; if ([defaults objectForKey:ShowAppsInMenuDefaultsKey] == nil) { @@ -47,7 +84,7 @@ static NSImage *integrationAppIcon(NSString *appName, NSBundle *bundle = OllamaResourceBundle(); NSImage *bundledIcon = [bundle imageForResource:appName.lowercaseString]; if (bundledIcon != nil) { - [bundledIcon setTemplate:NO]; + [bundledIcon setTemplate:[appName isEqualToString:@"ChatGPT"]]; return bundledIcon; } return [NSImage imageWithSystemSymbolName:fallbackSymbolName @@ -328,6 +365,8 @@ static NSImage *integrationAppIcon(NSString *appName, @property(assign, nonatomic) BOOL claudeAppReady; @property(strong, nonatomic) IntegrationMenuRow *claudeAppRow; @property(strong, nonatomic) NSMenuItem *claudeMenuItem; +@property(strong, nonatomic) IntegrationMenuRow *codexAppRow; +@property(strong, nonatomic) NSMenuItem *codexMenuItem; @property(strong, nonatomic) NSMenuItem *claudeMenuSeparatorItem; @property(strong, nonatomic) NSURLSession *claudeDownloadSession; @property(strong, nonatomic) NSURLSessionDownloadTask *claudeDownloadTask; @@ -338,16 +377,24 @@ static NSImage *integrationAppIcon(NSString *appName, @property(assign, nonatomic) BOOL claudeDownloadCancelled; @property(assign, nonatomic) BOOL claudeDownloadCompleted; @property(assign, nonatomic) BOOL claudeDownloadModalRunning; +@property(assign, nonatomic) DesktopDownloadKind desktopDownloadKind; @property(assign, nonatomic) BOOL quitInProgress; @property(assign, nonatomic) BOOL systemTerminationReplyPending; @property(strong, nonatomic) NSApplication *systemTerminationApplication; - (void)openClaudeApp:(id)sender; +- (void)openChatGPTApp:(id)sender; - (enum ClaudeInstallResult)downloadClaude; -- (enum ClaudeInstallResult)finishClaudeDownload; +- (enum ClaudeInstallResult)downloadChatGPT; +- (enum ClaudeInstallResult)downloadDesktopApp:(DesktopDownloadKind)kind; +- (enum ClaudeInstallResult)finishDesktopDownload; - (void)showClaudeDownloadFailure:(NSError *)error; - (void)showClaudeInstallFailure:(NSError *)error; +- (void)showChatGPTDownloadFailure:(NSError *)error; +- (void)showChatGPTInstallFailure:(NSError *)error; - (void)toggleClaudeAppProxy:(NSButton *)sender; - (void)refreshClaudeAppState; +- (void)toggleCodexApp:(NSButton *)sender; +- (void)refreshCodexAppState; - (void)applyShowAppsInMenu:(BOOL)visible; - (void)requestQuit; - (void)completeSystemTermination; @@ -438,6 +485,21 @@ static NSImage *ollamaApplicationIcon(void) { [self.claudeMenuItem setView:self.claudeAppRow]; [menu addItem:self.claudeMenuItem]; [self refreshClaudeAppState]; + + self.codexMenuItem = [[NSMenuItem alloc] initWithTitle:@"ChatGPT" + action:nil + keyEquivalent:@""]; + [self.codexMenuItem setEnabled:YES]; + self.codexAppRow = [[IntegrationMenuRow alloc] + initWithTitle:@"ChatGPT" + symbolName:@"bubble.left.and.bubble.right" + target:self + openAction:@selector(openChatGPTApp:) + toggleAction:@selector(toggleCodexApp:)]; + [self.codexMenuItem setView:self.codexAppRow]; + [menu addItem:self.codexMenuItem]; + [self refreshCodexAppState]; + self.claudeMenuSeparatorItem = [NSMenuItem separatorItem]; [menu addItem:self.claudeMenuSeparatorItem]; [self applyShowAppsInMenu:shouldShowAppsInMenu()]; @@ -625,13 +687,10 @@ static NSImage *ollamaApplicationIcon(void) { return; } [self refreshClaudeAppState]; + [self refreshCodexAppState]; } - (void)refreshClaudeAppState { - BOOL hasUsed = HasUsedClaudeDesktopIntegration(); - BOOL visible = shouldShowAppsInMenu() && hasUsed; - [self.claudeMenuItem setHidden:!visible]; - [self.claudeMenuSeparatorItem setHidden:!visible]; BOOL installed = IsClaudeDesktopInstalled(); BOOL startFailed = ClaudeGatewayStartFailed(); BOOL portConflict = startFailed && ClaudeGatewayPortConflict(); @@ -648,17 +707,40 @@ static NSImage *ollamaApplicationIcon(void) { [self.claudeAppRow setIntegrationActive:self.claudeAppEnabled]; [self.claudeAppRow setIntegrationReady:self.claudeAppReady]; RefreshClaudeProxyMenu(); + [self applyShowAppsInMenu:shouldShowAppsInMenu()]; +} + +- (void)refreshCodexAppState { + BOOL installed = IsCodexDesktopInstalled(); + BOOL connected = IsCodexDesktopConnected(); + unsigned long long requests = connected ? CodexDesktopRequestCount() : 0; + NSString *activeStatus = requests == 1 + ? @"1 request this session" + : [NSString stringWithFormat:@"%llu requests this session", requests]; + [self.codexAppRow setActiveStatusText:connected + ? activeStatus + : nil]; + [self.codexAppRow setInactiveStatusText:installed + ? @"Use Ollama models in ChatGPT" + : @"Not installed"]; + [self.codexAppRow setIntegrationActive:connected]; + [self.codexAppRow setIntegrationReady:installed && connected]; + [self applyShowAppsInMenu:shouldShowAppsInMenu()]; } - (void)applyShowAppsInMenu:(BOOL)visible { - visible = visible && HasUsedClaudeDesktopIntegration(); - [self.claudeMenuItem setHidden:!visible]; - [self.claudeMenuSeparatorItem setHidden:!visible]; + BOOL claudeVisible = visible && HasUsedClaudeDesktopIntegration(); + // Keep the installation flow accessible from the menu. + BOOL codexVisible = visible; + [self.claudeMenuItem setHidden:!claudeVisible]; + [self.codexMenuItem setHidden:!codexVisible]; + [self.claudeMenuSeparatorItem setHidden:!(claudeVisible || codexVisible)]; } - (void)menuDidClose:(NSMenu *)menu { if (menu == self.statusItem.menu) { [self.claudeAppRow resetHover]; + [self.codexAppRow resetHover]; } } @@ -727,6 +809,49 @@ static NSImage *ollamaApplicationIcon(void) { [alert runModal]; } +- (void)openChatGPTApp:(id)sender { + (void)sender; + if (!IsCodexDesktopConnected()) { + return; + } + [self.statusItem.menu cancelTracking]; + + NSArray *candidates = @[ + @"/Applications/ChatGPT.app", + [NSHomeDirectory() stringByAppendingPathComponent:@"Applications/ChatGPT.app"], + @"/Applications/Codex.app", + [NSHomeDirectory() stringByAppendingPathComponent:@"Applications/Codex.app"], + ]; + for (NSString *path in candidates) { + if (![[NSFileManager defaultManager] fileExistsAtPath:path]) { + continue; + } + NSWorkspaceOpenConfiguration *configuration = + [NSWorkspaceOpenConfiguration configuration]; + configuration.activates = YES; + configuration.createsNewApplicationInstance = NO; + [[NSWorkspace sharedWorkspace] + openApplicationAtURL:[NSURL fileURLWithPath:path] + configuration:configuration + completionHandler:^(NSRunningApplication *application, + NSError *error) { + (void)application; + if (error != nil) { + appLogInfo([NSString stringWithFormat: + @"Unable to open ChatGPT: %@", error]); + } + }]; + return; + } + + NSAlert *alert = [[NSAlert alloc] init]; + [alert setAlertStyle:NSAlertStyleWarning]; + [alert setMessageText:@"Unable to open ChatGPT"]; + [alert setInformativeText: + @"Install ChatGPT in Applications, then try again."]; + [alert runModal]; +} + - (void)showClaudeDownloadFailure:(NSError *)error { appLogInfo([NSString stringWithFormat:@"Unable to download Claude: %@", error]); @@ -761,43 +886,102 @@ static NSImage *ollamaApplicationIcon(void) { } } +- (void)showChatGPTDownloadFailure:(NSError *)error { + appLogInfo([NSString stringWithFormat:@"Unable to download ChatGPT: %@", + error]); + NSAlert *alert = [[NSAlert alloc] init]; + [alert setAlertStyle:NSAlertStyleWarning]; + [alert setIcon:ollamaApplicationIcon()]; + [alert setMessageText:@"ChatGPT couldn’t be downloaded"]; + [alert setInformativeText: + @"Try again or download ChatGPT from its website."]; + [alert addButtonWithTitle:@"Open download page"]; + [alert addButtonWithTitle:@"Cancel"]; + if ([alert runModal] == NSAlertFirstButtonReturn) { + [[NSWorkspace sharedWorkspace] + openURL:[NSURL URLWithString:ChatGPTDownloadPageURL]]; + } +} + +- (void)showChatGPTInstallFailure:(NSError *)error { + appLogInfo([NSString stringWithFormat:@"Unable to install ChatGPT: %@", + error]); + NSAlert *alert = [[NSAlert alloc] init]; + [alert setAlertStyle:NSAlertStyleWarning]; + [alert setIcon:ollamaApplicationIcon()]; + [alert setMessageText:@"ChatGPT couldn’t be installed"]; + [alert setInformativeText: + @"Try again or install ChatGPT from its website."]; + [alert addButtonWithTitle:@"Open download page"]; + [alert addButtonWithTitle:@"Cancel"]; + if ([alert runModal] == NSAlertFirstButtonReturn) { + [[NSWorkspace sharedWorkspace] + openURL:[NSURL URLWithString:ChatGPTDownloadPageURL]]; + } +} + - (enum ClaudeInstallResult)downloadClaude { + return [self downloadDesktopApp:DesktopDownloadClaude]; +} + +- (enum ClaudeInstallResult)downloadChatGPT { + return [self downloadDesktopApp:DesktopDownloadChatGPT]; +} + +- (enum ClaudeInstallResult)downloadDesktopApp:(DesktopDownloadKind)kind { if (self.claudeDownloadTask != nil) { return ClaudeInstallCancelled; } + BOOL chatGPT = kind == DesktopDownloadChatGPT; char *downloadAuthorization = NULL; - char *downloadURL = ClaudeDesktopDownloadRequest(&downloadAuthorization); - NSString *downloadURLString = downloadURL == NULL - ? nil - : [NSString stringWithUTF8String:downloadURL]; - NSString *authorization = downloadAuthorization == NULL - ? nil - : [NSString stringWithUTF8String:downloadAuthorization]; - free(downloadURL); - free(downloadAuthorization); + char *downloadURL = NULL; + NSString *downloadURLString = ChatGPTDiskImageURL; + NSString *authorization = nil; + if (!chatGPT) { + downloadURL = ClaudeDesktopDownloadRequest(&downloadAuthorization); + downloadURLString = downloadURL == NULL + ? nil + : [NSString stringWithUTF8String:downloadURL]; + authorization = downloadAuthorization == NULL + ? nil + : [NSString stringWithUTF8String:downloadAuthorization]; + free(downloadURL); + free(downloadAuthorization); + } NSURL *url = downloadURLString == nil ? nil : [NSURL URLWithString:downloadURLString]; - if (url == nil || authorization.length == 0) { + if (url == nil || (!chatGPT && authorization.length == 0)) { NSError *error = [NSError errorWithDomain:@"com.ollama.app" code:3 userInfo:@{NSLocalizedDescriptionKey: - @"Ollama could not authenticate the download request."}]; - [self showClaudeDownloadFailure:error]; + chatGPT + ? @"Ollama could not prepare the ChatGPT download." + : @"Ollama could not authenticate the download request."}]; + if (chatGPT) { + [self showChatGPTDownloadFailure:error]; + } else { + [self showClaudeDownloadFailure:error]; + } return ClaudeInstallFailed; } - [self.claudeAppRow setInactiveStatusText:@"Downloading Claude…"]; - [self.claudeAppRow.integrationSwitch setEnabled:NO]; + IntegrationMenuRow *row = chatGPT ? self.codexAppRow : self.claudeAppRow; + NSString *appName = chatGPT ? @"ChatGPT" : @"Claude"; + [row setInactiveStatusText: + [NSString stringWithFormat:@"Downloading %@…", appName]]; + [row.integrationSwitch setEnabled:NO]; self.claudeDownloadAlert = [[NSAlert alloc] init]; [self.claudeDownloadAlert setAlertStyle:NSAlertStyleInformational]; [self.claudeDownloadAlert setIcon:ollamaApplicationIcon()]; - [self.claudeDownloadAlert setMessageText:@"Downloading Claude"]; + [self.claudeDownloadAlert setMessageText: + [NSString stringWithFormat:@"Downloading %@", appName]]; [self.claudeDownloadAlert setInformativeText: - @"Claude will be installed when the download finishes."]; + [NSString stringWithFormat: + @"%@ will be installed when the download finishes.", appName]]; [self.claudeDownloadAlert addButtonWithTitle:@"Cancel"]; self.claudeDownloadProgress = [[NSProgressIndicator alloc] initWithFrame:NSMakeRect(0, 0, 260, 12)]; @@ -812,6 +996,7 @@ static NSImage *ollamaApplicationIcon(void) { self.claudeDownloadCompleted = NO; self.claudeDownloadedInstallerURL = nil; self.claudeDownloadError = nil; + self.desktopDownloadKind = kind; NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration ephemeralSessionConfiguration]; self.claudeDownloadSession = [NSURLSession @@ -819,7 +1004,9 @@ static NSImage *ollamaApplicationIcon(void) { delegate:self delegateQueue:[NSOperationQueue mainQueue]]; NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url]; - [request setValue:authorization forHTTPHeaderField:@"Authorization"]; + if (authorization.length > 0) { + [request setValue:authorization forHTTPHeaderField:@"Authorization"]; + } self.claudeDownloadTask = [self.claudeDownloadSession downloadTaskWithRequest:request]; @@ -835,7 +1022,7 @@ static NSImage *ollamaApplicationIcon(void) { return ClaudeInstallCancelled; } if (self.claudeDownloadCompleted) { - return [self finishClaudeDownload]; + return [self finishDesktopDownload]; } return ClaudeInstallCancelled; } @@ -891,15 +1078,20 @@ didFinishDownloadingToURL:(NSURL *)location { ? (NSHTTPURLResponse *)downloadTask.response : nil; NSString *host = response.URL.host.lowercaseString; - BOOL trustedHost = [host isEqualToString:@"claude.ai"] || - [host hasSuffix:@".claude.ai"]; + BOOL chatGPT = self.desktopDownloadKind == DesktopDownloadChatGPT; + BOOL trustedHost = chatGPT + ? [host isEqualToString:@"persistent.oaistatic.com"] + : ([host isEqualToString:@"claude.ai"] || + [host hasSuffix:@".claude.ai"]); if (response.statusCode != 200 || !trustedHost || ![response.URL.scheme isEqualToString:@"https"]) { error = [NSError errorWithDomain:@"com.ollama.app" code:1 userInfo:@{NSLocalizedDescriptionKey: - @"Claude returned an invalid download response."}]; + chatGPT + ? @"ChatGPT returned an invalid download response." + : @"Claude returned an invalid download response."}]; } if (error == nil) { @@ -909,15 +1101,18 @@ didFinishDownloadingToURL:(NSURL *)location { if (error == nil && [attributes fileSize] < 1024 * 1024) { error = [NSError errorWithDomain:@"com.ollama.app" - code:2 - userInfo:@{NSLocalizedDescriptionKey: - @"Claude returned an incomplete download."}]; + code:2 + userInfo:@{NSLocalizedDescriptionKey: + chatGPT + ? @"ChatGPT returned an incomplete download." + : @"Claude returned an incomplete download."}]; } } if (error == nil) { NSString *fileName = [NSString - stringWithFormat:@"Claude-%@.zip", [NSUUID UUID].UUIDString]; + stringWithFormat:chatGPT ? @"ChatGPT-%@.dmg" : @"Claude-%@.zip", + [NSUUID UUID].UUIDString]; NSURL *installerURL = [NSURL fileURLWithPath: [NSTemporaryDirectory() stringByAppendingPathComponent:fileName]]; if ([[NSFileManager defaultManager] moveItemAtURL:location @@ -944,16 +1139,19 @@ didCompleteWithError:(NSError *)error { [NSApp abortModal]; return; } - [self finishClaudeDownload]; + [self finishDesktopDownload]; } -- (enum ClaudeInstallResult)finishClaudeDownload { +- (enum ClaudeInstallResult)finishDesktopDownload { BOOL cancelled = self.claudeDownloadCancelled; + BOOL chatGPT = self.desktopDownloadKind == DesktopDownloadChatGPT; + IntegrationMenuRow *row = chatGPT ? self.codexAppRow : self.claudeAppRow; + NSString *appName = chatGPT ? @"ChatGPT" : @"Claude"; [self.claudeDownloadProgress stopAnimation:nil]; [self.claudeDownloadAlert.window orderOut:nil]; - [self.claudeAppRow.integrationSwitch setEnabled:YES]; - [self.claudeAppRow setInactiveStatusText:@"Not installed"]; + [row.integrationSwitch setEnabled:YES]; + [row setInactiveStatusText:@"Not installed"]; [self.claudeDownloadSession finishTasksAndInvalidate]; self.claudeDownloadTask = nil; self.claudeDownloadSession = nil; @@ -965,6 +1163,7 @@ didCompleteWithError:(NSError *)error { self.claudeDownloadedInstallerURL = nil; self.claudeDownloadCancelled = NO; self.claudeDownloadCompleted = NO; + self.desktopDownloadKind = DesktopDownloadNone; return ClaudeInstallCancelled; } if (self.claudeDownloadError != nil || @@ -974,39 +1173,97 @@ didCompleteWithError:(NSError *)error { errorWithDomain:@"com.ollama.app" code:3 userInfo:@{NSLocalizedDescriptionKey: - @"The Claude installer was not downloaded."}]; + chatGPT + ? @"The ChatGPT installer was not downloaded." + : @"The Claude installer was not downloaded."}]; + } + if (chatGPT) { + [self showChatGPTDownloadFailure:self.claudeDownloadError]; + } else { + [self showClaudeDownloadFailure:self.claudeDownloadError]; } - [self showClaudeDownloadFailure:self.claudeDownloadError]; self.claudeDownloadError = nil; self.claudeDownloadedInstallerURL = nil; self.claudeDownloadCancelled = NO; self.claudeDownloadCompleted = NO; + self.desktopDownloadKind = DesktopDownloadNone; return ClaudeInstallFailed; } - [self.claudeAppRow setInactiveStatusText:@"Installing Claude…"]; - BOOL installed = InstallClaudeDesktopArchive( - self.claudeDownloadedInstallerURL.fileSystemRepresentation); + [row setInactiveStatusText: + [NSString stringWithFormat:@"Installing %@…", appName]]; + BOOL installed = NO; + if (chatGPT) { + [row.integrationSwitch setEnabled:NO]; + NSAlert *installAlert = [[NSAlert alloc] init]; + [installAlert setAlertStyle:NSAlertStyleInformational]; + [installAlert setIcon:ollamaApplicationIcon()]; + [installAlert setMessageText:@"Installing ChatGPT"]; + [installAlert setInformativeText: + @"Ollama is verifying and copying the ChatGPT app."]; + NSButton *installingButton = + [installAlert addButtonWithTitle:@"Installing…"]; + [installingButton setEnabled:NO]; + NSProgressIndicator *installProgress = [[NSProgressIndicator alloc] + initWithFrame:NSMakeRect(0, 0, 260, 12)]; + [installProgress setStyle:NSProgressIndicatorStyleBar]; + [installProgress setIndeterminate:YES]; + [installProgress startAnimation:nil]; + [installAlert setAccessoryView:installProgress]; + + NSString *installerPath = + [self.claudeDownloadedInstallerURL.path copy]; + __block BOOL backgroundInstalled = NO; + dispatch_async( + dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + backgroundInstalled = InstallCodexDesktopDiskImage( + installerPath.fileSystemRepresentation); + dispatch_async(dispatch_get_main_queue(), ^{ + [NSApp abortModal]; + }); + }); + [NSApp activateIgnoringOtherApps:YES]; + [installAlert runModal]; + installed = backgroundInstalled; + [installProgress stopAnimation:nil]; + [installAlert.window orderOut:nil]; + [row.integrationSwitch setEnabled:YES]; + } else { + installed = InstallClaudeDesktopArchive( + self.claudeDownloadedInstallerURL.fileSystemRepresentation); + } NSError *removeError = nil; [[NSFileManager defaultManager] removeItemAtURL:self.claudeDownloadedInstallerURL error:&removeError]; if (removeError != nil) { - appLogInfo([NSString stringWithFormat: - @"Unable to remove downloaded Claude archive: %@", removeError]); + appLogInfo([NSString stringWithFormat:chatGPT + ? @"Unable to remove downloaded ChatGPT installer: %@" + : @"Unable to remove downloaded Claude archive: %@", + removeError]); } if (!installed) { NSError *installError = [NSError errorWithDomain:@"com.ollama.app" code:4 userInfo:@{NSLocalizedDescriptionKey: - @"Claude could not be installed."}]; - [self showClaudeInstallFailure:installError]; + [NSString stringWithFormat: + @"%@ could not be installed.", appName]}]; + if (chatGPT) { + [self showChatGPTInstallFailure:installError]; + } else { + [self showClaudeInstallFailure:installError]; + } } self.claudeDownloadError = nil; self.claudeDownloadedInstallerURL = nil; self.claudeDownloadCancelled = NO; self.claudeDownloadCompleted = NO; - [self refreshClaudeAppState]; + self.desktopDownloadKind = DesktopDownloadNone; + if (chatGPT) { + [self refreshCodexAppState]; + } else { + [self refreshClaudeAppState]; + } return installed ? ClaudeInstallerOpened : ClaudeInstallFailed; } @@ -1095,6 +1352,64 @@ didCompleteWithError:(NSError *)error { }); } +- (void)toggleCodexApp:(NSButton *)sender { + BOOL enabled = sender.state == NSControlStateValueOn; + if (enabled && !IsCodexDesktopInstalled()) { + [self refreshCodexAppState]; + NSAlert *installAlert = [[NSAlert alloc] init]; + [installAlert setAlertStyle:NSAlertStyleInformational]; + [installAlert setIcon:ollamaApplicationIcon()]; + [installAlert setMessageText:@"ChatGPT is not installed"]; + [installAlert setInformativeText: + @"Download ChatGPT to add Ollama models to the ChatGPT app."]; + [installAlert addButtonWithTitle:@"Download ChatGPT"]; + [installAlert addButtonWithTitle:@"Cancel"]; + if ([installAlert runModal] == NSAlertFirstButtonReturn) { + if ([self downloadChatGPT] != ClaudeInstallerOpened) { + return; + } + } else { + return; + } + } + + BOOL restartChatGPT = IsCodexDesktopRunning(); + if (restartChatGPT) { + NSAlert *restartAlert = [[NSAlert alloc] init]; + [restartAlert setAlertStyle:NSAlertStyleWarning]; + [restartAlert setIcon:ollamaApplicationIcon()]; + configureChatGPTRestartAlert(restartAlert, enabled + ? ChatGPTRestartActionAddModels + : ChatGPTRestartActionRemoveModels); + [restartAlert addButtonWithTitle:@"Cancel"]; + if ([restartAlert runModal] != NSAlertFirstButtonReturn) { + [self refreshCodexAppState]; + return; + } + } + + [sender setEnabled:NO]; + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + BOOL succeeded = SetCodexDesktopConnected(enabled, restartChatGPT); + dispatch_async(dispatch_get_main_queue(), ^{ + [sender setEnabled:YES]; + [self refreshCodexAppState]; + if (!succeeded) { + NSAlert *alert = [[NSAlert alloc] init]; + [alert setAlertStyle:NSAlertStyleWarning]; + [alert setIcon:ollamaApplicationIcon()]; + [alert setMessageText:enabled + ? @"Unable to add Ollama models to ChatGPT" + : @"Unable to remove Ollama models from ChatGPT"]; + [alert setInformativeText: + @"ChatGPT could not complete the model update. Check the Ollama log for details, then try again."]; + [alert runModal]; + return; + } + }); + }); +} + - (void)appsUI { [self uiRequest:@"/connect"]; } @@ -1437,6 +1752,12 @@ decidePolicyForNavigationAction:(WKNavigationAction *)action [alert setInformativeText: @"Claude Desktop must restart to remove Ollama. Any running task will stop."]; [alert addButtonWithTitle:@"Restart Claude Desktop"]; + } else if ([message hasPrefix:@"Restart ChatGPT to add Ollama models?"]) { + configureChatGPTRestartAlert(alert, ChatGPTRestartActionAddModels); + } else if ([message hasPrefix:@"Restart ChatGPT to update Ollama models?"]) { + configureChatGPTRestartAlert(alert, ChatGPTRestartActionUpdateModels); + } else if ([message hasPrefix:@"Restart ChatGPT to remove Ollama models?"]) { + configureChatGPTRestartAlert(alert, ChatGPTRestartActionRemoveModels); } else { [alert setMessageText:message]; [alert addButtonWithTitle:@"Confirm"]; @@ -2006,6 +2327,21 @@ enum ClaudeInstallResult installClaudeDesktop(void) { return result; } +enum ClaudeInstallResult installCodexDesktop(void) { + __block enum ClaudeInstallResult result = ClaudeInstallFailed; + void (^install)(void) = ^{ + if (appDelegate != nil) { + result = [appDelegate downloadChatGPT]; + } + }; + if ([NSThread isMainThread]) { + install(); + } else { + dispatch_sync(dispatch_get_main_queue(), install); + } + return result; +} + void quit() { dispatch_async(dispatch_get_main_queue(), ^{ [appDelegate quit]; diff --git a/app/cmd/app/app_darwin_test.go b/app/cmd/app/app_darwin_test.go index f4ec5a8af..103bd2968 100644 --- a/app/cmd/app/app_darwin_test.go +++ b/app/cmd/app/app_darwin_test.go @@ -3085,6 +3085,22 @@ func TestClaudeDesktopInstallResultFromCode(t *testing.T) { } } +func TestCodexDesktopInstallResultFromCode(t *testing.T) { + for _, tt := range []struct { + code int + want codexDesktopInstallResult + }{ + {code: 0, want: codexDesktopInstallCancelled}, + {code: 1, want: codexDesktopInstallerOpened}, + {code: 2, want: codexDesktopInstallFailed}, + {code: 99, want: codexDesktopInstallFailed}, + } { + if got := codexDesktopInstallResultFromCode(tt.code); got != tt.want { + t.Errorf("codexDesktopInstallResultFromCode(%d) = %q, want %q", tt.code, got, tt.want) + } + } +} + func TestClaudeGatewayRejectsOllamaHostPortConflict(t *testing.T) { t.Setenv("OLLAMA_HOST", "0.0.0.0:11435") diff --git a/app/cmd/app/codex_app_bindings_darwin.go b/app/cmd/app/codex_app_bindings_darwin.go new file mode 100644 index 000000000..17408b804 --- /dev/null +++ b/app/cmd/app/codex_app_bindings_darwin.go @@ -0,0 +1,75 @@ +//go:build darwin + +package main + +import ( + "errors" + "log/slog" + + "github.com/ollama/ollama/app/webview" +) + +func codexDesktopModelRefreshError(settings codexDesktopModelsSettings) string { + if len(settings.Selected) > 0 { + return "Couldn’t refresh available models. Your saved models are unchanged." + } + return "Couldn’t refresh available models. Try again." +} + +func bindCodexDesktop(wv webview.WebView) { + wv.Bind("getCodexDesktopStatus", func() codexDesktopStatus { + return getCodexDesktopStatus() + }) + wv.Bind("getCodexDesktopRequestCount", func() uint64 { + return codexDesktop.OllamaRequestCount() + }) + wv.Bind("setCodexDesktopConnected", func(enabled, restartConfirmed bool) codexDesktopActionResult { + err := setCodexDesktopConnection(enabled, restartConfirmed) + result := codexDesktopActionResult{Status: getCodexDesktopStatus()} + if errors.Is(err, errCodexDesktopRestartConfirmationRequired) { + result.RestartConfirmationRequired = true + } else if err != nil { + result.Error = err.Error() + slog.Warn("failed to change ChatGPT integration from Settings", "connected", enabled, "error", err) + } + return result + }) + wv.Bind("installCodexDesktop", func() codexDesktopInstallResult { + return requestCodexDesktopInstall() + }) + wv.Bind("getCodexDesktopModelsSettings", func() codexDesktopModelsSettingsResult { + settings, err := getCodexDesktopModelsSettings() + result := codexDesktopModelsSettingsResult{Settings: settings} + if err != nil { + result.Warning = codexDesktopModelRefreshError(settings) + slog.Warn("failed to refresh available ChatGPT models", "error", err) + } + return result + }) + wv.Bind("applyCodexDesktopModels", func(models []string, restartConfirmed bool) codexDesktopModelsSettingsResult { + err := applyCodexDesktopModels(models, restartConfirmed) + settings, statusErr := getCodexDesktopModelsSettings() + result := codexDesktopModelsSettingsResult{Settings: settings} + if errors.Is(err, errCodexDesktopRestartConfirmationRequired) { + result.RestartConfirmationRequired = true + } else if err != nil { + result.Error = err.Error() + } else if statusErr != nil { + result.Warning = codexDesktopModelRefreshError(settings) + slog.Warn("failed to refresh available ChatGPT models after applying settings", "error", statusErr) + } + return result + }) + wv.Bind("resetCodexDesktopModels", func() codexDesktopModelsSettingsResult { + err := resetCodexDesktopModels() + settings, statusErr := getCodexDesktopModelsSettings() + result := codexDesktopModelsSettingsResult{Settings: settings} + if err != nil { + result.Error = err.Error() + } else if statusErr != nil { + result.Warning = codexDesktopModelRefreshError(settings) + slog.Warn("failed to refresh available ChatGPT models after resetting settings", "error", statusErr) + } + return result + }) +} diff --git a/app/cmd/app/codex_app_bindings_windows.go b/app/cmd/app/codex_app_bindings_windows.go new file mode 100644 index 000000000..e056e1dad --- /dev/null +++ b/app/cmd/app/codex_app_bindings_windows.go @@ -0,0 +1,7 @@ +//go:build windows + +package main + +import "github.com/ollama/ollama/app/webview" + +func bindCodexDesktop(_ webview.WebView) {} diff --git a/app/cmd/app/codex_app_darwin.go b/app/cmd/app/codex_app_darwin.go new file mode 100644 index 000000000..7ee73b179 --- /dev/null +++ b/app/cmd/app/codex_app_darwin.go @@ -0,0 +1,979 @@ +//go:build darwin + +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "slices" + "strings" + "sync" + "time" + + "github.com/ollama/ollama/api" + appui "github.com/ollama/ollama/app/ui" + "github.com/ollama/ollama/cmd/config" + "github.com/ollama/ollama/cmd/launch" + "github.com/ollama/ollama/internal/modelref" + "github.com/ollama/ollama/internal/proxy" + modelpkg "github.com/ollama/ollama/types/model" +) + +const ( + codexDesktopIntegrationName = "chatgpt" + codexDesktopMaxModels = 5 + codexDesktopRecommendationsMaxBody = 1 << 20 +) + +var errCodexDesktopRestartConfirmationRequired = launch.ErrCodexAppRestartConfirmationRequired + +type codexDesktopController interface { + Installed() bool + OllamaConfigured() bool + Running() bool + OllamaRequestCount() uint64 + UseOllamaFromDesktop(string, []launch.LaunchModel, bool) error + UpdateOllamaModelsFromDesktop(string, []launch.LaunchModel, bool) error + RestoreFromDesktop(bool) error + RestartFromDesktop(bool) error + Onboard() error +} + +var ( + codexDesktop codexDesktopController = &launch.CodexApp{} + codexDesktopClientFactory = api.ClientFromEnvironment + codexDesktopLoadModels = loadCodexDesktopModels + codexDesktopLoadConnectionModels = loadCodexDesktopConnectionModels + codexDesktopCloudModels = loadCodexDesktopAccountCloudModels + codexDesktopRecommendations = loadCodexDesktopRecommendations + codexDesktopAccessState = currentClaudeDesktopAccessState + codexDesktopRecommendationsClient = &http.Client{Timeout: 3 * time.Second} + codexDesktopRecommendationsEndpoint = func() string { + return strings.TrimRight(appui.OllamaDotCom, "/") + "/api/experimental/model-recommendations?app=codex-desktop" + } + codexDesktopModelLoadAttempts = 20 + codexDesktopModelRetryWait = 250 * time.Millisecond + codexDesktopMu sync.Mutex +) + +type codexDesktopStatus struct { + Supported bool `json:"supported"` + Installed bool `json:"installed"` + Connected bool `json:"connected"` + Running bool `json:"running"` + Model string `json:"model,omitempty"` + Models []string `json:"models,omitempty"` + MaxModels int `json:"maxModels"` + Requests uint64 `json:"requests"` +} + +type codexDesktopActionResult struct { + Status codexDesktopStatus `json:"status"` + Error string `json:"error,omitempty"` + RestartConfirmationRequired bool `json:"restartConfirmationRequired,omitempty"` +} + +type codexDesktopInstallResult string + +const ( + codexDesktopInstallCancelled codexDesktopInstallResult = "cancelled" + codexDesktopInstallerOpened codexDesktopInstallResult = "opened" + codexDesktopInstallFailed codexDesktopInstallResult = "failed" +) + +type codexDesktopModelsSettings struct { + Supported bool `json:"supported"` + Installed bool `json:"installed"` + Connected bool `json:"connected"` + Running bool `json:"running"` + // UsesDefaults keeps recommendations implicit without overwriting saved choices. + UsesDefaults bool `json:"usesDefaults"` + Selected []string `json:"selected"` + Available []string `json:"available"` + Models []codexDesktopModelStatus `json:"models"` + MaxModels int `json:"maxModels"` +} + +type codexDesktopModelStatus struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + Description string `json:"description,omitempty"` + Recommended bool `json:"recommended,omitempty"` + Selected bool `json:"selected"` + Availability string `json:"availability"` + Reason string `json:"reason,omitempty"` + RequiredPlan string `json:"requiredPlan,omitempty"` +} + +type codexDesktopModelsSettingsResult struct { + Settings codexDesktopModelsSettings `json:"settings"` + Error string `json:"error,omitempty"` + Warning string `json:"warning,omitempty"` + RestartConfirmationRequired bool `json:"restartConfirmationRequired,omitempty"` +} + +type codexDesktopModelInventory struct { + Available []launch.LaunchModel + Catalog []codexDesktopCatalogModel + Defaults []launch.LaunchModel + DefaultPrimary string +} + +type codexDesktopCatalogModel struct { + Model launch.LaunchModel + DisplayName string + Description string + Recommended bool + Availability proxy.ClaudeDesktopAvailability + Reason proxy.ClaudeDesktopAccessReason + RequiredPlan string +} + +func getCodexDesktopStatus() codexDesktopStatus { + connected := codexDesktop.OllamaConfigured() + requests := uint64(0) + if connected { + requests = codexDesktop.OllamaRequestCount() + } + var models []string + if saved, err := config.LoadIntegration(codexDesktopIntegrationName); err == nil && len(saved.Models) > 0 { + models = append([]string(nil), saved.Models...) + } + model := "" + if len(models) > 0 { + model = models[0] + } + return codexDesktopStatus{ + Supported: true, + Installed: codexDesktop.Installed(), + Connected: connected, + Running: codexDesktop.Running(), + Model: model, + Models: models, + MaxModels: codexDesktopMaxModels, + Requests: requests, + } +} + +func setCodexDesktopConnection(enabled, restartConfirmed bool) error { + codexDesktopMu.Lock() + defer codexDesktopMu.Unlock() + + if enabled == codexDesktop.OllamaConfigured() { + return nil + } + if !enabled { + if codexDesktop.Running() && !restartConfirmed { + return errCodexDesktopRestartConfirmationRequired + } + return codexDesktop.RestoreFromDesktop(restartConfirmed) + } + if !codexDesktop.Installed() { + return errors.New("ChatGPT is not installed") + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + savedSelection := config.IntegrationModels(codexDesktopIntegrationName) + primary, models, err := codexDesktopLoadConnectionModels(ctx, savedSelection) + if err != nil { + return err + } + // Validate before requesting a restart; do not change the profile without consent. + if codexDesktop.Running() && !restartConfirmed { + return errCodexDesktopRestartConfirmationRequired + } + previous := config.IntegrationModels(codexDesktopIntegrationName) + if err := config.SaveIntegration(codexDesktopIntegrationName, savedSelection); err != nil { + return fmt.Errorf("save ChatGPT integration: %w", err) + } + if err := codexDesktop.Onboard(); err != nil { + _ = config.SaveIntegration(codexDesktopIntegrationName, previous) + return fmt.Errorf("save ChatGPT integration state: %w", err) + } + if err := codexDesktop.UseOllamaFromDesktop(primary, models, restartConfirmed); err != nil { + _ = config.SaveIntegration(codexDesktopIntegrationName, previous) + if errors.Is(err, errCodexDesktopRestartConfirmationRequired) { + return err + } + if codexDesktop.OllamaConfigured() { + if restoreErr := codexDesktop.RestoreFromDesktop(true); restoreErr != nil { + return errors.Join(err, fmt.Errorf("restore ChatGPT after failed update: %w", restoreErr)) + } + } + return err + } + return nil +} + +func getCodexDesktopModelsSettings() (codexDesktopModelsSettings, error) { + settings := codexDesktopModelsSettings{ + Supported: true, + Installed: codexDesktop.Installed(), + Connected: codexDesktop.OllamaConfigured(), + Running: codexDesktop.Running(), + Selected: []string{}, + Available: []string{}, + Models: []codexDesktopModelStatus{}, + MaxModels: codexDesktopMaxModels, + } + // Keep restart available when inventory cannot be loaded. + settings.Selected = config.IntegrationModels(codexDesktopIntegrationName) + settings.UsesDefaults = len(settings.Selected) == 0 + if len(settings.Selected) > codexDesktopMaxModels { + settings.Selected = settings.Selected[:codexDesktopMaxModels] + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + inventory, err := loadCodexDesktopModelInventory(ctx) + if err != nil { + return settings, err + } + settings.Available = codexDesktopModelNames(inventory.Available) + if len(settings.Selected) == 0 { + settings.Selected = codexDesktopModelNames(codexDesktopDefaultModels(inventory)) + } + settings.Models = codexDesktopModelStatuses(inventory, settings.Selected) + return settings, nil +} + +func applyCodexDesktopModels(selected []string, restartConfirmed bool) error { + codexDesktopMu.Lock() + defer codexDesktopMu.Unlock() + return applyCodexDesktopModelsLocked(selected, restartConfirmed, true) +} + +func resetCodexDesktopModels() error { + codexDesktopMu.Lock() + defer codexDesktopMu.Unlock() + + // Reset preferences without enabling the integration or restarting ChatGPT. + if len(config.IntegrationModels(codexDesktopIntegrationName)) == 0 && !codexDesktop.OllamaConfigured() { + return nil + } + if err := config.SaveIntegration(codexDesktopIntegrationName, nil); err != nil { + return fmt.Errorf("reset ChatGPT models: %w", err) + } + return nil +} + +func applyCodexDesktopModelsLocked(selected []string, restartConfirmed, openWhenStopped bool) error { + previous := config.IntegrationModels(codexDesktopIntegrationName) + savedSelection := append([]string(nil), selected...) + wasConfigured := codexDesktop.OllamaConfigured() + selectionUnchanged := slices.Equal(savedSelection, previous) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + primary, models, err := codexDesktopLoadModels(ctx, selected) + if err != nil { + if openWhenStopped && wasConfigured && selectionUnchanged { + return codexDesktop.RestartFromDesktop(restartConfirmed) + } + return err + } + if !wasConfigured && !openWhenStopped { + if err := config.SaveIntegration(codexDesktopIntegrationName, savedSelection); err != nil { + return fmt.Errorf("save ChatGPT models: %w", err) + } + return nil + } + running := codexDesktop.Running() + if running && !restartConfirmed { + return errCodexDesktopRestartConfirmationRequired + } + if err := config.SaveIntegration(codexDesktopIntegrationName, savedSelection); err != nil { + return fmt.Errorf("save ChatGPT models: %w", err) + } + updateModels := codexDesktop.UseOllamaFromDesktop + if !openWhenStopped { + updateModels = codexDesktop.UpdateOllamaModelsFromDesktop + } + if err := updateModels(primary, models, restartConfirmed); err == nil { + return nil + } else if errors.Is(err, errCodexDesktopRestartConfirmationRequired) { + _ = config.SaveIntegration(codexDesktopIntegrationName, previous) + return err + } else if !wasConfigured { + if codexDesktop.OllamaConfigured() { + if restoreErr := codexDesktop.RestoreFromDesktop(true); restoreErr != nil { + _ = config.SaveIntegration(codexDesktopIntegrationName, previous) + return errors.Join(err, fmt.Errorf("restore ChatGPT after failed update: %w", restoreErr)) + } + } + _ = config.SaveIntegration(codexDesktopIntegrationName, previous) + return fmt.Errorf("start ChatGPT with selected Ollama models: %w", err) + } else { + applyErr := err + _ = config.SaveIntegration(codexDesktopIntegrationName, previous) + rollbackCtx, rollbackCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer rollbackCancel() + rollbackPrimary, rollbackModels, rollbackErr := codexDesktopLoadModels(rollbackCtx, previous) + if rollbackErr == nil { + rollbackErr = updateModels(rollbackPrimary, rollbackModels, true) + } + if rollbackErr != nil { + // Restore the original profile if the previous selection is no longer usable. + if restoreErr := codexDesktop.RestoreFromDesktop(true); restoreErr != nil { + return errors.Join( + fmt.Errorf("apply ChatGPT models: %v; restore previous Ollama profile: %w", applyErr, rollbackErr), + fmt.Errorf("restore normal ChatGPT profile: %w", restoreErr), + ) + } + return fmt.Errorf("apply ChatGPT models: %v; restore previous Ollama profile: %v; restored the normal ChatGPT profile", applyErr, rollbackErr) + } + return fmt.Errorf("apply ChatGPT models: %w", applyErr) + } +} + +func loadCodexDesktopModels(ctx context.Context, selected []string) (string, []launch.LaunchModel, error) { + inventory, err := loadCodexDesktopModelInventory(ctx) + if err != nil { + return "", nil, err + } + if len(selected) == 0 { + selected = codexDesktopModelNames(codexDesktopDefaultModels(inventory)) + } + _, models, err := selectCodexDesktopModels(selected, inventory.Available) + if err != nil { + return "", nil, err + } + primary := codexDesktopPreferredPrimary(inventory.DefaultPrimary, models) + return primary, hydrateCodexDesktopModelCapabilities(ctx, models), nil +} + +func loadCodexDesktopConnectionModels(ctx context.Context, selected []string) (string, []launch.LaunchModel, error) { + inventory, err := loadCodexDesktopModelInventory(ctx) + if err != nil { + return "", nil, err + } + defaults := codexDesktopDefaultModels(inventory) + if len(selected) == 0 { + selected = codexDesktopModelNames(defaults) + } + _, models, err := reconcileCodexDesktopModels(selected, inventory.Available, defaults) + if err != nil { + return "", nil, err + } + primary := codexDesktopPreferredPrimary(inventory.DefaultPrimary, models) + return primary, hydrateCodexDesktopModelCapabilities(ctx, models), nil +} + +// /api/show supplies capabilities and family metadata without replacing recommended thinking controls. +func hydrateCodexDesktopModelCapabilities(ctx context.Context, models []launch.LaunchModel) []launch.LaunchModel { + client, err := codexDesktopClientFactory() + if err != nil { + return models + } + + hydrated := append([]launch.LaunchModel(nil), models...) + for i := range hydrated { + response, err := client.Show(ctx, &api.ShowRequest{Model: hydrated[i].Name}) + if err != nil { + continue + } + if len(response.Capabilities) > 0 { + hydrated[i].Capabilities = append([]modelpkg.Capability(nil), response.Capabilities...) + } + if response.Details.Family != "" || len(response.Details.Families) > 0 { + hydrated[i].Details = response.Details + } + } + return hydrated +} + +func loadCodexDesktopAvailableModels(ctx context.Context) ([]launch.LaunchModel, error) { + inventory, err := loadCodexDesktopModelInventory(ctx) + return inventory.Available, err +} + +func loadCodexDesktopModelInventory(ctx context.Context) (codexDesktopModelInventory, error) { + client, err := codexDesktopClientFactory() + if err != nil { + return codexDesktopModelInventory{}, err + } + + recommendations, recommendationsErr := codexDesktopRecommendations(ctx) + if recommendationsErr != nil { + slog.Debug("could not load ChatGPT model recommendations", "error", recommendationsErr) + } + var access proxy.ClaudeDesktopAccessState + accessKnown := false + var last codexDesktopModelInventory + for attempt := range codexDesktopModelLoadAttempts { + if !accessKnown { + resolved, accessErr := codexDesktopAccessState(ctx) + if accessErr == nil { + access = resolved + accessKnown = true + } else { + slog.Debug("could not determine ChatGPT model access", "error", accessErr) + } + } + + var listed []api.ListModelResponse + listKnown := false + if response, listErr := client.List(ctx); listErr == nil { + listed = response.Models + listKnown = true + } + var accountCloud []string + cloudKnown := false + if names, cloudErr := codexDesktopCloudModels(ctx); cloudErr == nil { + accountCloud = names + cloudKnown = true + } + + last = buildCodexDesktopModelInventory(recommendations, listed, accountCloud, access, accessKnown, listKnown, cloudKnown) + // Retry access lookup failures even when recommendations are available. + if len(last.Available) > 0 && (accessKnown || attempt+1 == codexDesktopModelLoadAttempts) { + return last, nil + } + if attempt+1 == codexDesktopModelLoadAttempts { + break + } + timer := time.NewTimer(codexDesktopModelRetryWait) + select { + case <-ctx.Done(): + timer.Stop() + return codexDesktopModelInventory{}, ctx.Err() + case <-timer.C: + } + } + if len(last.Catalog) > 0 { + return last, nil + } + return codexDesktopModelInventory{}, errors.New("no Ollama models are available for ChatGPT") +} + +func loadCodexDesktopRecommendations(ctx context.Context) ([]api.ModelRecommendation, error) { + req, err := newSignedOllamaRequest(ctx, http.MethodGet, codexDesktopRecommendationsEndpoint()) + if err != nil { + return nil, fmt.Errorf("prepare ChatGPT model recommendations request: %w", err) + } + resp, err := codexDesktopRecommendationsClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch ChatGPT model recommendations: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, codexDesktopRecommendationsMaxBody)) + return nil, fmt.Errorf("fetch ChatGPT model recommendations: status %d", resp.StatusCode) + } + + var payload api.ModelRecommendationsResponse + decoder := json.NewDecoder(io.LimitReader(resp.Body, codexDesktopRecommendationsMaxBody+1)) + if err := decoder.Decode(&payload); err != nil { + return nil, fmt.Errorf("decode ChatGPT model recommendations: %w", err) + } + if len(payload.Recommendations) == 0 { + return nil, errors.New("ChatGPT model recommendations are empty") + } + return payload.Recommendations, nil +} + +func loadCodexDesktopAccountCloudModels(ctx context.Context) ([]string, error) { + models, err := currentClaudeDesktopCloudModels(ctx) + if err != nil { + return nil, err + } + names := make([]string, 0, len(models)) + for _, model := range models { + name := strings.TrimSpace(model.OllamaModel) + if name == "" { + name = strings.TrimSpace(model.Name) + } + if name != "" { + names = append(names, name) + } + } + return names, nil +} + +func buildCodexDesktopModelInventory( + recommendations []api.ModelRecommendation, + listed []api.ListModelResponse, + accountCloud []string, + access proxy.ClaudeDesktopAccessState, + accessKnown, localInventoryKnown, cloudInventoryKnown bool, +) codexDesktopModelInventory { + actual := codexDesktopAvailableModels(listed, accountCloud) + actualByName := make(map[string]launch.LaunchModel, len(actual)) + for _, model := range actual { + actualByName[codexDesktopModelKey(model.Name)] = model + } + + seen := make(map[string]bool, len(actual)+len(recommendations)) + recommended := make([]codexDesktopCatalogModel, 0, len(recommendations)) + for _, recommendation := range recommendations { + route := codexDesktopRecommendationRoute(recommendation) + key := codexDesktopModelKey(route) + if key == "" || seen[key] { + continue + } + seen[key] = true + + model, present := actualByName[key] + if !present { + model = launch.LaunchModel{Name: route, Remote: codexDesktopCloudModel(route)} + } + if recommendation.ContextLength > 0 { + model.ContextLength = recommendation.ContextLength + } + if recommendation.MaxOutputTokens > 0 { + model.MaxOutputTokens = recommendation.MaxOutputTokens + } + if recommendation.Thinking != nil { + model.Thinking = recommendation.Thinking.Clone() + } + + availability, reason := codexDesktopRecommendationAccess( + model, + present, + strings.TrimSpace(recommendation.RequiredPlan), + access, + accessKnown, + localInventoryKnown, + ) + entry := codexDesktopCatalogModel{ + Model: model, + DisplayName: strings.TrimSpace(recommendation.Model), + Description: strings.TrimSpace(recommendation.Description), + Recommended: true, + Availability: availability, + Reason: reason, + RequiredPlan: strings.TrimSpace(recommendation.RequiredPlan), + } + recommended = append(recommended, entry) + } + + extras := make([]codexDesktopCatalogModel, 0, len(actual)) + for _, model := range actual { + key := codexDesktopModelKey(model.Name) + if key == "" || seen[key] { + continue + } + seen[key] = true + availability, reason := codexDesktopInventoryModelAccess(model, access, accessKnown, cloudInventoryKnown) + extras = append(extras, codexDesktopCatalogModel{ + Model: model, + DisplayName: model.Name, + Availability: availability, + Reason: reason, + }) + } + + // Sort recommendations only; preserve saved list order. + slices.SortStableFunc(recommended, func(a, b codexDesktopCatalogModel) int { + return codexDesktopRecommendationPriority(a.Model.Name) - codexDesktopRecommendationPriority(b.Model.Name) + }) + catalog := make([]codexDesktopCatalogModel, 0, len(recommended)+len(extras)) + catalog = append(catalog, recommended...) + catalog = append(catalog, extras...) + available := make([]launch.LaunchModel, 0, len(catalog)) + for _, entry := range catalog { + // Recommendations remain configurable regardless of current availability. + if entry.Recommended || entry.Availability == proxy.ClaudeDesktopAvailabilityAvailable { + available = append(available, entry.Model) + } + } + defaults := codexDesktopRecommendationDefaults(catalog) + + return codexDesktopModelInventory{ + Available: available, + Catalog: catalog, + Defaults: defaults, + DefaultPrimary: codexDesktopDefaultPrimary(catalog, defaults, access, accessKnown), + } +} + +func codexDesktopRecommendationPriority(name string) int { + switch codexDesktopModelKey(name) { + case "kimi-k3:cloud": + return 0 + case "glm-5.3:cloud": + return 1 + case "glm-5.3-flash:cloud": + return 2 + case "deepseek-v4-flash:cloud": + return 3 + case "gemma4:31b:cloud": + return 4 + default: + return 5 + } +} + +func codexDesktopRecommendationRoute(recommendation api.ModelRecommendation) string { + name := strings.TrimSpace(recommendation.Model) + if name != "" && recommendation.RequiredPlan != "" && !modelref.HasExplicitCloudSource(name) { + name += ":cloud" + } + return name +} + +func codexDesktopRecommendationAccess( + model launch.LaunchModel, + present bool, + requiredPlan string, + access proxy.ClaudeDesktopAccessState, + accessKnown, localInventoryKnown bool, +) (proxy.ClaudeDesktopAvailability, proxy.ClaudeDesktopAccessReason) { + if !model.Remote { + if !localInventoryKnown { + return proxy.ClaudeDesktopAvailabilityUnknown, proxy.ClaudeDesktopAccessVerificationUnavailable + } + if present { + return proxy.ClaudeDesktopAvailabilityAvailable, "" + } + return proxy.ClaudeDesktopAvailabilityUnavailable, proxy.ClaudeDesktopAccessModelNotInstalled + } + if !accessKnown { + return proxy.ClaudeDesktopAvailabilityUnknown, proxy.ClaudeDesktopAccessVerificationUnavailable + } + if access.Cloud == proxy.ClaudeDesktopCloudOff { + return proxy.ClaudeDesktopAvailabilityUnavailable, proxy.ClaudeDesktopAccessCloudOff + } + if access.Cloud != proxy.ClaudeDesktopCloudOn || access.Account == proxy.ClaudeDesktopAccountUnknown { + return proxy.ClaudeDesktopAvailabilityUnknown, proxy.ClaudeDesktopAccessVerificationUnavailable + } + if access.Account == proxy.ClaudeDesktopAccountSignedOut { + return proxy.ClaudeDesktopAvailabilityUnavailable, proxy.ClaudeDesktopAccessSignInRequired + } + if !codexDesktopPlanSatisfies(access.Plan, requiredPlan) { + return proxy.ClaudeDesktopAvailabilityUnavailable, proxy.ClaudeDesktopAccessUpgradeRequired + } + // Recommended cloud models need not appear in /api/tags. + return proxy.ClaudeDesktopAvailabilityAvailable, "" +} + +func codexDesktopInventoryModelAccess( + model launch.LaunchModel, + access proxy.ClaudeDesktopAccessState, + accessKnown, cloudInventoryKnown bool, +) (proxy.ClaudeDesktopAvailability, proxy.ClaudeDesktopAccessReason) { + if !model.Remote { + return proxy.ClaudeDesktopAvailabilityAvailable, "" + } + if !accessKnown { + return proxy.ClaudeDesktopAvailabilityUnknown, proxy.ClaudeDesktopAccessVerificationUnavailable + } + if access.Cloud == proxy.ClaudeDesktopCloudOff { + return proxy.ClaudeDesktopAvailabilityUnavailable, proxy.ClaudeDesktopAccessCloudOff + } + if !cloudInventoryKnown || access.Cloud != proxy.ClaudeDesktopCloudOn || access.Account == proxy.ClaudeDesktopAccountUnknown { + return proxy.ClaudeDesktopAvailabilityUnknown, proxy.ClaudeDesktopAccessVerificationUnavailable + } + if access.Account == proxy.ClaudeDesktopAccountSignedOut { + return proxy.ClaudeDesktopAvailabilityUnavailable, proxy.ClaudeDesktopAccessSignInRequired + } + return proxy.ClaudeDesktopAvailabilityAvailable, "" +} + +func codexDesktopPlanSatisfies(plan, required string) bool { + plan = strings.ToLower(strings.TrimSpace(plan)) + required = strings.ToLower(strings.TrimSpace(required)) + if required == "" || required == "free" { + return true + } + return plan != "" && plan != "free" +} + +func codexDesktopRecommendationDefaults(catalog []codexDesktopCatalogModel) []launch.LaunchModel { + defaults := make([]launch.LaunchModel, 0, codexDesktopMaxModels) + for _, entry := range catalog { + if !entry.Recommended { + continue + } + defaults = append(defaults, entry.Model) + if len(defaults) == codexDesktopMaxModels { + return defaults + } + } + if len(defaults) > 0 { + return defaults + } + + for _, entry := range catalog { + if entry.Availability != proxy.ClaudeDesktopAvailabilityAvailable { + continue + } + defaults = append(defaults, entry.Model) + if len(defaults) == codexDesktopMaxModels { + break + } + } + return defaults +} + +func codexDesktopDefaultPrimary( + catalog []codexDesktopCatalogModel, + defaults []launch.LaunchModel, + access proxy.ClaudeDesktopAccessState, + accessKnown bool, +) string { + if len(defaults) == 0 { + return "" + } + + // Choose the starting model independently of picker order. + if accessKnown && access.Account == proxy.ClaudeDesktopAccountSignedIn && codexDesktopPlanSatisfies(access.Plan, "pro") { + return codexDesktopPreferredPrimary("glm-5.3-flash:cloud", defaults) + } + for _, entry := range catalog { + if !entry.Recommended { + continue + } + required := strings.ToLower(strings.TrimSpace(entry.RequiredPlan)) + if required == "" || required == "free" { + return entry.Model.Name + } + } + return defaults[0].Name +} + +func codexDesktopPreferredPrimary(preferred string, models []launch.LaunchModel) string { + preferredKey := codexDesktopModelKey(preferred) + for _, model := range models { + if preferredKey != "" && codexDesktopModelKey(model.Name) == preferredKey { + return model.Name + } + } + if len(models) > 0 { + return models[0].Name + } + return "" +} + +func codexDesktopModelStatuses(inventory codexDesktopModelInventory, selected []string) []codexDesktopModelStatus { + selectedSet := make(map[string]bool, len(selected)) + for _, name := range selected { + selectedSet[codexDesktopModelKey(name)] = true + } + statuses := make([]codexDesktopModelStatus, 0, len(inventory.Catalog)+len(selected)) + seen := make(map[string]bool, cap(statuses)) + for _, entry := range inventory.Catalog { + key := codexDesktopModelKey(entry.Model.Name) + seen[key] = true + displayName := entry.DisplayName + if displayName == "" { + displayName = entry.Model.Name + } + statuses = append(statuses, codexDesktopModelStatus{ + Name: entry.Model.Name, + DisplayName: displayName, + Description: entry.Description, + Recommended: entry.Recommended, + Selected: selectedSet[key], + Availability: string(entry.Availability), + Reason: string(entry.Reason), + RequiredPlan: entry.RequiredPlan, + }) + } + for _, name := range selected { + key := codexDesktopModelKey(name) + if key == "" || seen[key] { + continue + } + seen[key] = true + statuses = append(statuses, codexDesktopModelStatus{ + Name: name, + DisplayName: name, + Selected: true, + Availability: string(proxy.ClaudeDesktopAvailabilityUnknown), + Reason: string(proxy.ClaudeDesktopAccessVerificationUnavailable), + }) + } + return statuses +} + +func buildCodexDesktopModels(selected []string, listed []api.ListModelResponse, accountCloud []string) (string, []launch.LaunchModel, error) { + available := codexDesktopAvailableModels(listed, accountCloud) + return selectCodexDesktopModels(selected, available) +} + +func codexDesktopAvailableModels(listed []api.ListModelResponse, accountCloud []string) []launch.LaunchModel { + installed := make(map[string]api.ListModelResponse, len(listed)) + for _, model := range listed { + for _, name := range []string{model.Name, model.Model} { + if key := codexDesktopModelKey(name); key != "" { + installed[key] = model + } + } + } + accountCloudSet := make(map[string]bool, len(accountCloud)) + for _, name := range accountCloud { + if key := codexDesktopModelKey(name); key != "" { + accountCloudSet[key] = true + } + } + models := make([]launch.LaunchModel, 0, len(listed)+len(accountCloud)) + seen := make(map[string]bool, cap(models)) + add := func(model launch.LaunchModel) { + model.Name = strings.TrimSpace(model.Name) + key := codexDesktopModelKey(model.Name) + if key == "" || seen[key] { + return + } + seen[key] = true + models = append(models, model) + } + + for _, model := range listed { + if codexDesktopListedModelIsCloud(model) && !accountCloudSet[codexDesktopModelKey(model.Name)] && !accountCloudSet[codexDesktopModelKey(model.Model)] { + continue + } + add(codexDesktopLaunchModel(model)) + } + for _, name := range accountCloud { + key := codexDesktopModelKey(name) + if listedModel, ok := installed[key]; ok { + add(codexDesktopLaunchModel(listedModel)) + continue + } + add(launch.LaunchModel{Name: strings.TrimSpace(name), Remote: true}) + } + + return models +} + +func codexDesktopDefaultModels(inventory codexDesktopModelInventory) []launch.LaunchModel { + if inventory.Catalog != nil || inventory.Defaults != nil { + return append([]launch.LaunchModel(nil), inventory.Defaults...) + } + return append([]launch.LaunchModel(nil), inventory.Available[:min(len(inventory.Available), codexDesktopMaxModels)]...) +} + +func codexDesktopListedModelIsCloud(model api.ListModelResponse) bool { + return model.RemoteModel != "" || model.RemoteHost != "" || + codexDesktopCloudModel(model.Name) || + codexDesktopCloudModel(model.Model) +} + +func selectCodexDesktopModels(selected []string, available []launch.LaunchModel) (string, []launch.LaunchModel, error) { + byName := make(map[string]launch.LaunchModel, len(available)) + for _, model := range available { + byName[codexDesktopModelKey(model.Name)] = model + } + + if len(selected) > codexDesktopMaxModels { + return "", nil, fmt.Errorf("choose up to %d models for ChatGPT", codexDesktopMaxModels) + } + resolved := make([]launch.LaunchModel, 0, codexDesktopMaxModels) + seen := make(map[string]bool, codexDesktopMaxModels) + for _, name := range selected { + name = strings.TrimSpace(name) + key := codexDesktopModelKey(name) + if key == "" || seen[key] { + continue + } + model, ok := byName[key] + if !ok { + return "", nil, fmt.Errorf("ChatGPT model %q is not available", name) + } + seen[key] = true + resolved = append(resolved, model) + } + + if len(selected) == 0 { + for _, model := range available { + if len(resolved) == codexDesktopMaxModels { + break + } + key := codexDesktopModelKey(model.Name) + if key == "" || seen[key] { + continue + } + seen[key] = true + resolved = append(resolved, model) + } + } + if len(resolved) == 0 { + return "", nil, errors.New("choose at least one available Ollama model for ChatGPT") + } + return resolved[0].Name, resolved, nil +} + +// Reopening tolerates stale selections; explicit Settings changes use strict validation. +func reconcileCodexDesktopModels(selected []string, available, defaults []launch.LaunchModel) (string, []launch.LaunchModel, error) { + if len(selected) == 0 { + if len(defaults) > 0 { + return selectCodexDesktopModels(codexDesktopModelNames(defaults), available) + } + return selectCodexDesktopModels(nil, available) + } + + byName := make(map[string]launch.LaunchModel, len(available)) + for _, model := range available { + byName[codexDesktopModelKey(model.Name)] = model + } + resolved := make([]launch.LaunchModel, 0, min(len(selected), codexDesktopMaxModels)) + seen := make(map[string]bool, codexDesktopMaxModels) + for _, name := range selected { + key := codexDesktopModelKey(name) + model, ok := byName[key] + if key == "" || !ok || seen[key] { + continue + } + seen[key] = true + resolved = append(resolved, model) + if len(resolved) == codexDesktopMaxModels { + break + } + } + if len(resolved) == 0 { + if len(defaults) > 0 { + return selectCodexDesktopModels(codexDesktopModelNames(defaults), available) + } + return selectCodexDesktopModels(nil, available) + } + return resolved[0].Name, resolved, nil +} + +func codexDesktopModelNames(models []launch.LaunchModel) []string { + names := make([]string, 0, len(models)) + for _, model := range models { + if name := strings.TrimSpace(model.Name); name != "" { + names = append(names, name) + } + } + return names +} + +func codexDesktopLaunchModel(model api.ListModelResponse) launch.LaunchModel { + name := strings.TrimSpace(model.Name) + if name == "" { + name = strings.TrimSpace(model.Model) + } + return launch.LaunchModel{ + Name: name, + Remote: model.RemoteModel != "" || model.RemoteHost != "" || codexDesktopCloudModel(name), + Capabilities: append([]modelpkg.Capability(nil), model.Capabilities...), + ContextLength: model.Details.ContextLength, + EmbeddingLength: model.Details.EmbeddingLength, + Size: model.Size, + Details: model.Details, + } +} + +func codexDesktopModelKey(name string) string { + name = strings.TrimSpace(name) + parsed, err := modelref.ParseRef(name) + if err != nil { + return strings.TrimSuffix(name, ":latest") + } + base := strings.TrimSuffix(strings.TrimSpace(parsed.Base), ":latest") + if parsed.Source == modelref.ModelSourceCloud { + return base + ":cloud" + } + return base +} + +func codexDesktopCloudModel(name string) bool { + name = strings.ToLower(strings.TrimSpace(name)) + return strings.HasSuffix(name, ":cloud") || strings.HasSuffix(name, "-cloud") +} diff --git a/app/cmd/app/codex_app_darwin_test.go b/app/cmd/app/codex_app_darwin_test.go new file mode 100644 index 000000000..312c8e6d0 --- /dev/null +++ b/app/cmd/app/codex_app_darwin_test.go @@ -0,0 +1,1379 @@ +//go:build darwin + +package main + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "slices" + "strings" + "testing" + "time" + + "github.com/ollama/ollama/api" + "github.com/ollama/ollama/cmd/config" + "github.com/ollama/ollama/cmd/launch" + "github.com/ollama/ollama/internal/proxy" + modelpkg "github.com/ollama/ollama/types/model" +) + +type fakeCodexDesktopController struct { + installed bool + running bool + configured bool + launched string + models []string + stopped bool + onboarded bool + launchErrs []error + launches [][]string + updates [][]string + requests uint64 + configureBeforeError bool + restarts int +} + +func (f *fakeCodexDesktopController) Installed() bool { return f.installed } +func (f *fakeCodexDesktopController) OllamaConfigured() bool { + return f.configured +} +func (f *fakeCodexDesktopController) Running() bool { return f.running } +func (f *fakeCodexDesktopController) OllamaRequestCount() uint64 { + return f.requests +} + +func (f *fakeCodexDesktopController) UseOllamaFromDesktop(primary string, models []launch.LaunchModel, restartConfirmed bool) error { + if f.running && !restartConfirmed { + return errCodexDesktopRestartConfirmationRequired + } + names := codexDesktopModelNames(models) + f.launches = append(f.launches, names) + if len(f.launchErrs) > 0 { + err := f.launchErrs[0] + f.launchErrs = f.launchErrs[1:] + if err != nil { + if f.configureBeforeError { + f.configured = true + } + return err + } + } + f.launched = primary + f.models = names + f.running = true + f.configured = true + return nil +} + +func (f *fakeCodexDesktopController) UpdateOllamaModelsFromDesktop(primary string, models []launch.LaunchModel, restartConfirmed bool) error { + if f.running && !restartConfirmed { + return errCodexDesktopRestartConfirmationRequired + } + names := codexDesktopModelNames(models) + f.updates = append(f.updates, names) + if len(f.launchErrs) > 0 { + err := f.launchErrs[0] + f.launchErrs = f.launchErrs[1:] + if err != nil { + if f.configureBeforeError { + f.configured = true + } + return err + } + } + f.launched = primary + f.models = names + f.configured = true + return nil +} + +func stubCodexDesktopModelLoader(t *testing.T) { + originalLoadModels := codexDesktopLoadModels + originalLoadConnectionModels := codexDesktopLoadConnectionModels + t.Cleanup(func() { + codexDesktopLoadModels = originalLoadModels + codexDesktopLoadConnectionModels = originalLoadConnectionModels + }) + loader := func(_ context.Context, selected []string) (string, []launch.LaunchModel, error) { + if len(selected) == 0 { + selected = []string{"qwen3:8b", "glm-5.2:cloud"} + } + models := make([]launch.LaunchModel, 0, len(selected)) + for _, name := range selected { + models = append(models, launch.LaunchModel{Name: name}) + } + return selected[0], models, nil + } + codexDesktopLoadModels = loader + codexDesktopLoadConnectionModels = loader +} + +func stubCodexDesktopCatalogSources(t *testing.T, recommendations []api.ModelRecommendation, access proxy.ClaudeDesktopAccessState) { + originalRecommendations := codexDesktopRecommendations + originalAccessState := codexDesktopAccessState + t.Cleanup(func() { + codexDesktopRecommendations = originalRecommendations + codexDesktopAccessState = originalAccessState + }) + codexDesktopRecommendations = func(context.Context) ([]api.ModelRecommendation, error) { + return recommendations, nil + } + codexDesktopAccessState = func(context.Context) (proxy.ClaudeDesktopAccessState, error) { + return access, nil + } +} + +func (f *fakeCodexDesktopController) RestoreFromDesktop(restartConfirmed bool) error { + if f.running && !restartConfirmed { + return errCodexDesktopRestartConfirmationRequired + } + f.stopped = true + f.running = false + f.configured = false + return nil +} + +func (f *fakeCodexDesktopController) RestartFromDesktop(restartConfirmed bool) error { + if f.running && !restartConfirmed { + return errCodexDesktopRestartConfirmationRequired + } + f.restarts++ + f.running = true + return nil +} + +func (f *fakeCodexDesktopController) Onboard() error { + f.onboarded = true + return nil +} + +func TestSetCodexDesktopConnectionSwitchesRegularProfile(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + originalController := codexDesktop + t.Cleanup(func() { + codexDesktop = originalController + }) + stubCodexDesktopModelLoader(t) + + fake := &fakeCodexDesktopController{installed: true} + codexDesktop = fake + if err := setCodexDesktopConnection(true, false); err != nil { + t.Fatal(err) + } + if fake.launched != "qwen3:8b" || !fake.running || !fake.onboarded { + t.Fatalf("controller = %#v, want regular profile switched and onboarded", fake) + } + saved, err := config.LoadIntegration(codexDesktopIntegrationName) + if err != nil { + t.Fatal(err) + } + if len(saved.Models) != 0 { + t.Fatalf("saved integration = %#v, want recommendation defaults to remain implicit", saved) + } + + if err := setCodexDesktopConnection(false, true); err != nil { + t.Fatal(err) + } + if !fake.stopped || fake.configured { + t.Fatalf("controller = %#v, want regular profile restored", fake) + } +} + +func TestSetCodexDesktopConnectionRequiresRestartConfirmation(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + originalController := codexDesktop + t.Cleanup(func() { codexDesktop = originalController }) + stubCodexDesktopModelLoader(t) + + fake := &fakeCodexDesktopController{installed: true, running: true} + codexDesktop = fake + err := setCodexDesktopConnection(true, false) + if !errors.Is(err, errCodexDesktopRestartConfirmationRequired) { + t.Fatalf("setCodexDesktopConnection error = %v, want restart confirmation", err) + } + if fake.launched != "" || fake.onboarded || fake.configured { + t.Fatalf("controller changed before restart confirmation: %#v", fake) + } + + if err := setCodexDesktopConnection(true, true); err != nil { + t.Fatal(err) + } + if fake.launched != "qwen3:8b" || !fake.onboarded || !fake.configured { + t.Fatalf("controller = %#v, want confirmed profile switch", fake) + } +} + +func TestSetCodexDesktopConnectionIsIdempotent(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + originalController := codexDesktop + t.Cleanup(func() { codexDesktop = originalController }) + stubCodexDesktopModelLoader(t) + + t.Run("already connected", func(t *testing.T) { + fake := &fakeCodexDesktopController{installed: true, running: true, configured: true} + codexDesktop = fake + if err := setCodexDesktopConnection(true, false); err != nil { + t.Fatal(err) + } + if len(fake.launches) != 0 || fake.onboarded || fake.stopped { + t.Fatalf("idempotent connect changed controller: %#v", fake) + } + }) + + t.Run("already disconnected", func(t *testing.T) { + fake := &fakeCodexDesktopController{installed: true, running: true} + codexDesktop = fake + if err := setCodexDesktopConnection(false, false); err != nil { + t.Fatal(err) + } + if len(fake.launches) != 0 || fake.onboarded || fake.stopped { + t.Fatalf("idempotent disconnect changed controller: %#v", fake) + } + }) +} + +func TestSetCodexDesktopConnectionRestoresProfileAfterPartialSwitchFailure(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + originalController := codexDesktop + t.Cleanup(func() { codexDesktop = originalController }) + stubCodexDesktopModelLoader(t) + if err := config.SaveIntegration(codexDesktopIntegrationName, []string{"old-model"}); err != nil { + t.Fatal(err) + } + + fake := &fakeCodexDesktopController{ + installed: true, + launchErrs: []error{errors.New("reopen failed")}, + configureBeforeError: true, + } + codexDesktop = fake + if err := setCodexDesktopConnection(true, false); err == nil { + t.Fatal("setCodexDesktopConnection returned nil after a partial switch failure") + } + if !fake.stopped || fake.configured { + t.Fatalf("controller = %#v, want the regular profile restored", fake) + } + if got := config.IntegrationModels(codexDesktopIntegrationName); len(got) != 1 || got[0] != "old-model" { + t.Fatalf("saved models = %v, want previous selection", got) + } +} + +func TestApplyCodexDesktopModelsRestartsConfiguredProfile(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + originalController := codexDesktop + t.Cleanup(func() { codexDesktop = originalController }) + stubCodexDesktopModelLoader(t) + if err := config.SaveIntegration(codexDesktopIntegrationName, []string{"old-model"}); err != nil { + t.Fatal(err) + } + + fake := &fakeCodexDesktopController{installed: true, running: true, configured: true} + codexDesktop = fake + if err := applyCodexDesktopModels([]string{"qwen3:8b", "glm-5.2:cloud"}, true); err != nil { + t.Fatal(err) + } + if !fake.running || !fake.configured { + t.Fatalf("controller = %#v, want configured profile restarted", fake) + } + if len(fake.models) != 2 || fake.models[0] != "qwen3:8b" || fake.models[1] != "glm-5.2:cloud" { + t.Fatalf("launched models = %#v", fake.models) + } + saved := config.IntegrationModels(codexDesktopIntegrationName) + if len(saved) != 2 || saved[0] != "qwen3:8b" || saved[1] != "glm-5.2:cloud" { + t.Fatalf("saved models = %#v", saved) + } +} + +func TestApplyCodexDesktopModelsRequiresLiveRestartConfirmation(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + originalController := codexDesktop + t.Cleanup(func() { codexDesktop = originalController }) + stubCodexDesktopModelLoader(t) + if err := config.SaveIntegration(codexDesktopIntegrationName, []string{"old-model"}); err != nil { + t.Fatal(err) + } + + fake := &fakeCodexDesktopController{installed: true, running: true, configured: true} + codexDesktop = fake + err := applyCodexDesktopModels([]string{"new-model"}, false) + if !errors.Is(err, errCodexDesktopRestartConfirmationRequired) { + t.Fatalf("applyCodexDesktopModels error = %v, want restart confirmation", err) + } + if len(fake.launches) != 0 { + t.Fatalf("launches before confirmation = %v, want none", fake.launches) + } + if got := config.IntegrationModels(codexDesktopIntegrationName); !slices.Equal(got, []string{"old-model"}) { + t.Fatalf("saved models before confirmation = %v, want old selection", got) + } + + if err := applyCodexDesktopModels([]string{"new-model"}, true); err != nil { + t.Fatal(err) + } + if got := config.IntegrationModels(codexDesktopIntegrationName); !slices.Equal(got, []string{"new-model"}) { + t.Fatalf("saved models after confirmation = %v, want new selection", got) + } +} + +func TestApplyCodexDesktopModelsRestartsUnchangedLiveProfileWhenRequested(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + originalController := codexDesktop + t.Cleanup(func() { codexDesktop = originalController }) + stubCodexDesktopModelLoader(t) + if err := config.SaveIntegration(codexDesktopIntegrationName, []string{"same-model"}); err != nil { + t.Fatal(err) + } + + fake := &fakeCodexDesktopController{installed: true, running: true, configured: true} + codexDesktop = fake + err := applyCodexDesktopModels([]string{"same-model"}, false) + if !errors.Is(err, errCodexDesktopRestartConfirmationRequired) { + t.Fatalf("applyCodexDesktopModels error = %v, want restart confirmation", err) + } + if len(fake.launches) != 0 || fake.stopped { + t.Fatalf("unchanged apply restarted ChatGPT before confirmation: %#v", fake) + } + if err := applyCodexDesktopModels([]string{"same-model"}, true); err != nil { + t.Fatal(err) + } + if len(fake.launches) != 1 || fake.launched != "same-model" { + t.Fatalf("confirmed restart did not relaunch ChatGPT: %#v", fake) + } +} + +func TestApplyCodexDesktopModelsCanRestartWhenInventoryIsUnavailable(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + originalController := codexDesktop + t.Cleanup(func() { codexDesktop = originalController }) + stubCodexDesktopModelLoader(t) + if err := config.SaveIntegration(codexDesktopIntegrationName, []string{"same-model"}); err != nil { + t.Fatal(err) + } + codexDesktopLoadModels = func(context.Context, []string) (string, []launch.LaunchModel, error) { + return "", nil, errors.New("inventory unavailable") + } + + fake := &fakeCodexDesktopController{installed: true, running: true, configured: true} + codexDesktop = fake + err := applyCodexDesktopModels([]string{"same-model"}, false) + if !errors.Is(err, errCodexDesktopRestartConfirmationRequired) { + t.Fatalf("applyCodexDesktopModels error = %v, want restart confirmation", err) + } + if fake.restarts != 0 { + t.Fatalf("restarts before confirmation = %d, want none", fake.restarts) + } + if err := applyCodexDesktopModels([]string{"same-model"}, true); err != nil { + t.Fatal(err) + } + if fake.restarts != 1 || len(fake.launches) != 0 { + t.Fatalf("controller = %#v, want one catalog-independent restart", fake) + } +} + +func TestApplyCodexDesktopModelsStartsStoppedRegularProfile(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + originalController := codexDesktop + t.Cleanup(func() { codexDesktop = originalController }) + stubCodexDesktopModelLoader(t) + + fake := &fakeCodexDesktopController{installed: true} + codexDesktop = fake + if err := applyCodexDesktopModels([]string{"qwen3:8b", "glm-5.2:cloud"}, false); err != nil { + t.Fatal(err) + } + if fake.stopped || !fake.running || !fake.configured { + t.Fatalf("controller = %#v, want stopped regular profile configured and started", fake) + } + if len(fake.models) != 2 || fake.models[0] != "qwen3:8b" || fake.models[1] != "glm-5.2:cloud" { + t.Fatalf("launched models = %#v", fake.models) + } +} + +func TestResetCodexDesktopModelsDoesNothingBeforeIntegrationIsUsed(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + originalController := codexDesktop + originalLoadModels := codexDesktopLoadModels + t.Cleanup(func() { + codexDesktop = originalController + codexDesktopLoadModels = originalLoadModels + }) + codexDesktopLoadModels = func(context.Context, []string) (string, []launch.LaunchModel, error) { + t.Fatal("reset loaded models for an unused integration") + return "", nil, nil + } + fake := &fakeCodexDesktopController{installed: true} + codexDesktop = fake + + if err := resetCodexDesktopModels(); err != nil { + t.Fatal(err) + } + if len(fake.launches) != 0 || len(fake.updates) != 0 || fake.running || fake.configured { + t.Fatalf("controller changed during unused reset: %#v", fake) + } +} + +func TestResetCodexDesktopModelsClearsExplicitSelectionWithoutConnecting(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + originalController := codexDesktop + t.Cleanup(func() { codexDesktop = originalController }) + stubCodexDesktopModelLoader(t) + if err := config.SaveIntegration(codexDesktopIntegrationName, []string{"old-model"}); err != nil { + t.Fatal(err) + } + fake := &fakeCodexDesktopController{installed: true, running: true} + codexDesktop = fake + + if err := resetCodexDesktopModels(); err != nil { + t.Fatal(err) + } + if len(fake.launches) != 0 || len(fake.updates) != 0 || !fake.running || fake.configured { + t.Fatalf("disconnected ChatGPT changed during reset: %#v", fake) + } + if got := config.IntegrationModels(codexDesktopIntegrationName); len(got) != 0 { + t.Fatalf("saved models = %v, want implicit recommendation defaults", got) + } +} + +func TestResetCodexDesktopModelsLeavesStoppedProfileAlone(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + originalController := codexDesktop + originalLoadModels := codexDesktopLoadModels + t.Cleanup(func() { + codexDesktop = originalController + codexDesktopLoadModels = originalLoadModels + }) + codexDesktopLoadModels = func(context.Context, []string) (string, []launch.LaunchModel, error) { + t.Fatal("reset loaded models instead of only clearing the saved selection") + return "", nil, nil + } + if err := config.SaveIntegration(codexDesktopIntegrationName, []string{"old-model"}); err != nil { + t.Fatal(err) + } + fake := &fakeCodexDesktopController{installed: true, configured: true} + codexDesktop = fake + + if err := resetCodexDesktopModels(); err != nil { + t.Fatal(err) + } + if len(fake.launches) != 0 || len(fake.updates) != 0 || fake.restarts != 0 || fake.running || !fake.configured { + t.Fatalf("controller changed during reset: %#v", fake) + } + if got := config.IntegrationModels(codexDesktopIntegrationName); len(got) != 0 { + t.Fatalf("saved models = %v, want implicit recommendation defaults", got) + } +} + +func TestResetCodexDesktopModelsLeavesRunningProfileAlone(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + originalController := codexDesktop + originalLoadModels := codexDesktopLoadModels + t.Cleanup(func() { + codexDesktop = originalController + codexDesktopLoadModels = originalLoadModels + }) + codexDesktopLoadModels = func(context.Context, []string) (string, []launch.LaunchModel, error) { + t.Fatal("reset loaded models instead of only clearing the saved selection") + return "", nil, nil + } + if err := config.SaveIntegration(codexDesktopIntegrationName, []string{"old-model"}); err != nil { + t.Fatal(err) + } + fake := &fakeCodexDesktopController{installed: true, configured: true, running: true} + codexDesktop = fake + + if err := resetCodexDesktopModels(); err != nil { + t.Fatal(err) + } + if len(fake.launches) != 0 || len(fake.updates) != 0 || fake.restarts != 0 || !fake.running || !fake.configured { + t.Fatalf("controller changed during reset: %#v", fake) + } + if got := config.IntegrationModels(codexDesktopIntegrationName); len(got) != 0 { + t.Fatalf("saved models = %v, want implicit recommendation defaults", got) + } +} + +func TestApplyCodexDesktopModelsRestoresSelectionAfterStoppedLaunchFailure(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + originalController := codexDesktop + t.Cleanup(func() { codexDesktop = originalController }) + stubCodexDesktopModelLoader(t) + if err := config.SaveIntegration(codexDesktopIntegrationName, []string{"old-model"}); err != nil { + t.Fatal(err) + } + + fake := &fakeCodexDesktopController{ + installed: true, + launchErrs: []error{errors.New("start failed")}, + } + codexDesktop = fake + if err := applyCodexDesktopModels([]string{"new-model"}, false); err == nil { + t.Fatal("applyCodexDesktopModels returned nil error after start failure") + } + if fake.running || fake.stopped || len(fake.launches) != 1 { + t.Fatalf("controller = %#v, want one failed start and no stop", fake) + } + saved := config.IntegrationModels(codexDesktopIntegrationName) + if len(saved) != 1 || saved[0] != "old-model" { + t.Fatalf("saved models = %#v, want previous selection", saved) + } +} + +func TestApplyCodexDesktopModelsRestoresPreviousProfileAfterLaunchFailure(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + originalController := codexDesktop + t.Cleanup(func() { codexDesktop = originalController }) + stubCodexDesktopModelLoader(t) + if err := config.SaveIntegration(codexDesktopIntegrationName, []string{"old-model"}); err != nil { + t.Fatal(err) + } + + fake := &fakeCodexDesktopController{ + installed: true, + configured: true, + running: true, + launchErrs: []error{errors.New("new profile failed"), nil}, + } + codexDesktop = fake + if err := applyCodexDesktopModels([]string{"new-model"}, true); err == nil { + t.Fatal("applyCodexDesktopModels returned nil error after launch failure") + } + if !fake.running || fake.launched != "old-model" { + t.Fatalf("controller = %#v, want previous profile restored", fake) + } + saved := config.IntegrationModels(codexDesktopIntegrationName) + if len(saved) != 1 || saved[0] != "old-model" { + t.Fatalf("saved models = %#v, want previous selection", saved) + } +} + +func TestApplyCodexDesktopModelsFallsBackToNormalProfileWhenRollbackFails(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + originalController := codexDesktop + t.Cleanup(func() { codexDesktop = originalController }) + stubCodexDesktopModelLoader(t) + if err := config.SaveIntegration(codexDesktopIntegrationName, []string{"old-model"}); err != nil { + t.Fatal(err) + } + + fake := &fakeCodexDesktopController{ + installed: true, + configured: true, + running: true, + launchErrs: []error{errors.New("new profile failed"), errors.New("old profile failed")}, + } + codexDesktop = fake + err := applyCodexDesktopModels([]string{"new-model"}, true) + if err == nil || !strings.Contains(err.Error(), "restored the normal ChatGPT profile") { + t.Fatalf("applyCodexDesktopModels error = %v, want safe fallback detail", err) + } + if !fake.stopped || fake.running || fake.configured { + t.Fatalf("controller = %#v, want normal stopped ChatGPT profile", fake) + } + if got := config.IntegrationModels(codexDesktopIntegrationName); !slices.Equal(got, []string{"old-model"}) { + t.Fatalf("saved models = %v, want previous selection", got) + } +} + +func TestBuildCodexDesktopModelsUsesListedAndAccountModels(t *testing.T) { + listed := []api.ListModelResponse{ + {Name: "qwen3:8b", Size: 42, Details: api.ModelDetails{ContextLength: 32768}}, + {Name: "llama3.2:latest"}, + } + + primary, models, err := buildCodexDesktopModels( + []string{"llama3.2", "glm-5.2:cloud", "qwen3:8b"}, + listed, + []string{"glm-5.2:cloud"}, + ) + if err != nil { + t.Fatal(err) + } + if primary != "llama3.2:latest" { + t.Fatalf("primary = %q, want llama3.2:latest", primary) + } + if len(models) != 3 { + t.Fatalf("models = %#v, want cloud and two installed models", models) + } + if models[0].Name != "llama3.2:latest" { + t.Fatalf("first model = %q, want preferred installed model", models[0].Name) + } + byName := make(map[string]bool, len(models)) + for _, model := range models { + byName[model.Name] = model.Remote + if model.Name == "qwen3:8b" && model.ContextLength != 32768 { + t.Fatalf("installed model = %#v, want local inventory metadata", model) + } + } + if !byName["glm-5.2:cloud"] { + t.Fatalf("models = %#v, want account-accessible cloud model", models) + } +} + +func TestCodexDesktopDefaultModelsUsesAvailableOrder(t *testing.T) { + available := codexDesktopAvailableModels( + []api.ListModelResponse{{Name: "llama3.1:latest"}, {Name: "extra-local"}}, + []string{"glm-5.3-flash:cloud", "kimi-k2.7-code:cloud", "extra-cloud:cloud"}, + ) + + got := codexDesktopModelNames(codexDesktopDefaultModels(codexDesktopModelInventory{Available: available})) + want := []string{"llama3.1:latest", "extra-local", "glm-5.3-flash:cloud", "kimi-k2.7-code:cloud", "extra-cloud:cloud"} + if !slices.Equal(got, want) { + t.Fatalf("default models = %v, want first available models %v", got, want) + } +} + +func TestBuildCodexDesktopModelInventoryByAccount(t *testing.T) { + recommendations := []api.ModelRecommendation{ + {Model: "glm-5.3-flash:cloud", RequiredPlan: "pro", Thinking: &api.ModelRecommendationThinking{Values: []any{"low", "high", "max"}, Default: "max"}}, + {Model: "gemma4:31b-cloud", RequiredPlan: "free", Thinking: &api.ModelRecommendationThinking{Values: []any{false, true}, Default: false}}, + } + local := []api.ListModelResponse{{Name: "qwen3:8b"}} + + tests := []struct { + name string + access proxy.ClaudeDesktopAccessState + accountCloud []string + wantCatalog []string + wantDefaults []string + wantPrimary string + wantReasons map[string]proxy.ClaudeDesktopAccessReason + }{ + { + name: "signed out uses the shared list with the Free primary", + access: proxy.ClaudeDesktopAccessState{Cloud: proxy.ClaudeDesktopCloudOn, Account: proxy.ClaudeDesktopAccountSignedOut}, + wantCatalog: []string{"glm-5.3-flash:cloud", "gemma4:31b-cloud", "qwen3:8b"}, + wantDefaults: []string{"glm-5.3-flash:cloud", "gemma4:31b-cloud"}, + wantPrimary: "gemma4:31b-cloud", + wantReasons: map[string]proxy.ClaudeDesktopAccessReason{ + "glm-5.3-flash:cloud": proxy.ClaudeDesktopAccessSignInRequired, + "gemma4:31b-cloud": proxy.ClaudeDesktopAccessSignInRequired, + }, + }, + { + name: "free uses the shared list with the Free primary", + access: proxy.ClaudeDesktopAccessState{Cloud: proxy.ClaudeDesktopCloudOn, Account: proxy.ClaudeDesktopAccountSignedIn, Plan: "free"}, + wantCatalog: []string{"glm-5.3-flash:cloud", "gemma4:31b-cloud", "qwen3:8b"}, + wantDefaults: []string{"glm-5.3-flash:cloud", "gemma4:31b-cloud"}, + wantPrimary: "gemma4:31b-cloud", + wantReasons: map[string]proxy.ClaudeDesktopAccessReason{ + "glm-5.3-flash:cloud": proxy.ClaudeDesktopAccessUpgradeRequired, + }, + }, + { + name: "team uses recommendation order without account inventory entries", + access: proxy.ClaudeDesktopAccessState{Cloud: proxy.ClaudeDesktopCloudOn, Account: proxy.ClaudeDesktopAccountSignedIn, Plan: "team"}, + wantCatalog: []string{"glm-5.3-flash:cloud", "gemma4:31b-cloud", "qwen3:8b"}, + wantDefaults: []string{"glm-5.3-flash:cloud", "gemma4:31b-cloud"}, + wantPrimary: "glm-5.3-flash:cloud", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + inventory := buildCodexDesktopModelInventory(recommendations, local, tt.accountCloud, tt.access, true, true, true) + gotCatalog := make([]string, 0, len(inventory.Catalog)) + for _, model := range inventory.Catalog { + gotCatalog = append(gotCatalog, model.Model.Name) + if wantReason, ok := tt.wantReasons[model.Model.Name]; ok && model.Reason != wantReason { + t.Errorf("%s reason = %q, want %q", model.Model.Name, model.Reason, wantReason) + } + } + if !slices.Equal(gotCatalog, tt.wantCatalog) { + t.Errorf("catalog = %v, want %v", gotCatalog, tt.wantCatalog) + } + if got := codexDesktopModelNames(inventory.Defaults); !slices.Equal(got, tt.wantDefaults) { + t.Errorf("defaults = %v, want %v", got, tt.wantDefaults) + } + if inventory.DefaultPrimary != tt.wantPrimary { + t.Errorf("default primary = %q, want %q", inventory.DefaultPrimary, tt.wantPrimary) + } + }) + } +} + +func TestBuildCodexDesktopModelInventorySignedOutKeepsRecommendationsSelectable(t *testing.T) { + recommendations := []api.ModelRecommendation{ + {Model: "glm-5.3-flash:cloud", RequiredPlan: "pro"}, + {Model: "gemma4:31b-cloud", RequiredPlan: "free"}, + } + inventory := buildCodexDesktopModelInventory( + recommendations, + nil, + nil, + proxy.ClaudeDesktopAccessState{Cloud: proxy.ClaudeDesktopCloudOn, Account: proxy.ClaudeDesktopAccountSignedOut}, + true, + true, + false, + ) + if len(inventory.Catalog) != 2 { + t.Fatalf("catalog = %#v, want both cloud recommendations visible", inventory.Catalog) + } + want := []string{"glm-5.3-flash:cloud", "gemma4:31b-cloud"} + if got := codexDesktopModelNames(inventory.Available); !slices.Equal(got, want) { + t.Fatalf("available = %v, want shared recommendation list %v", got, want) + } + if got := codexDesktopModelNames(inventory.Defaults); !slices.Equal(got, want) { + t.Fatalf("defaults = %v, want shared recommendation list %v", got, want) + } + if inventory.DefaultPrimary != "gemma4:31b-cloud" { + t.Fatalf("default primary = %q, want Free recommendation", inventory.DefaultPrimary) + } + if _, _, err := selectCodexDesktopModels([]string{"glm-5.3-flash:cloud"}, inventory.Available); err != nil { + t.Fatalf("signed-out recommendation should remain configurable: %v", err) + } +} + +func TestCodexDesktopDefaultsForTeamAccountUsesFirstFiveRecommendations(t *testing.T) { + recommendations := make([]api.ModelRecommendation, 6) + want := make([]string, 6) + for i := range recommendations { + name := fmt.Sprintf("model-%d:cloud", i+1) + recommendations[i] = api.ModelRecommendation{Model: name, RequiredPlan: "pro"} + want[i] = name + } + inventory := buildCodexDesktopModelInventory( + recommendations, + nil, + nil, + proxy.ClaudeDesktopAccessState{Cloud: proxy.ClaudeDesktopCloudOn, Account: proxy.ClaudeDesktopAccountSignedIn, Plan: "team"}, + true, + true, + true, + ) + + got := codexDesktopModelNames(inventory.Defaults) + want = want[:codexDesktopMaxModels] + if !slices.Equal(got, want) { + t.Fatalf("defaults = %v, want first five recommendations %v", got, want) + } +} + +func TestCodexDesktopDefaultsKeepPickerOrderAcrossAccounts(t *testing.T) { + recommendations := []api.ModelRecommendation{ + {Model: "glm-5.3-flash:cloud", RequiredPlan: "pro"}, + {Model: "glm-5.3:cloud", RequiredPlan: "pro"}, + {Model: "kimi-k3:cloud", RequiredPlan: "pro"}, + {Model: "deepseek-v4-flash:cloud", RequiredPlan: "pro"}, + {Model: "gemma4:31b-cloud", RequiredPlan: "free"}, + } + want := []string{ + "kimi-k3:cloud", + "glm-5.3:cloud", + "glm-5.3-flash:cloud", + "deepseek-v4-flash:cloud", + "gemma4:31b:cloud", + } + for _, plan := range []string{"signed out", "free", "pro", "max", "team"} { + t.Run(plan, func(t *testing.T) { + access := proxy.ClaudeDesktopAccessState{ + Cloud: proxy.ClaudeDesktopCloudOn, Account: proxy.ClaudeDesktopAccountSignedIn, Plan: plan, + } + if plan == "signed out" { + access.Account = proxy.ClaudeDesktopAccountSignedOut + access.Plan = "" + } + inventory := buildCodexDesktopModelInventory( + recommendations, + []api.ListModelResponse{{Name: "gemma4:12b"}}, + []string{"gemma4:31b:cloud"}, + access, + true, + true, + true, + ) + if got := codexDesktopModelNames(inventory.Defaults); !slices.Equal(got, want) { + t.Fatalf("defaults = %v, want picker order %v", got, want) + } + wantPrimary := "glm-5.3-flash:cloud" + if plan == "free" || plan == "signed out" { + wantPrimary = "gemma4:31b:cloud" + } + if inventory.DefaultPrimary != wantPrimary { + t.Errorf("default primary = %q, want %q", inventory.DefaultPrimary, wantPrimary) + } + if len(inventory.Catalog) != len(recommendations)+1 { + t.Fatalf("catalog = %#v, want five recommendations and one local model", inventory.Catalog) + } + for i, entry := range inventory.Catalog[:len(recommendations)] { + if !entry.Recommended || entry.Model.Name != want[i] { + t.Errorf("recommendation %d = %#v, want %q", i, entry, want[i]) + } + } + custom := []string{"gemma4:31b:cloud", "kimi-k3:cloud", "glm-5.3-flash:cloud"} + _, selected, err := selectCodexDesktopModels(custom, inventory.Available) + if err != nil { + t.Fatal(err) + } + if got := codexDesktopModelNames(selected); !slices.Equal(got, custom) { + t.Errorf("custom picker order = %v, want saved order %v", got, custom) + } + }) + } +} + +func TestCodexDesktopDefaultsForFreeAccountFollowRecommendationMetadata(t *testing.T) { + recommendations := []api.ModelRecommendation{ + {Model: "paid-model:cloud", RequiredPlan: "pro"}, + {Model: "new-free-model:cloud", RequiredPlan: "free"}, + } + inventory := buildCodexDesktopModelInventory( + recommendations, + nil, + nil, + proxy.ClaudeDesktopAccessState{Cloud: proxy.ClaudeDesktopCloudOn, Account: proxy.ClaudeDesktopAccountSignedIn, Plan: "free"}, + true, + true, + true, + ) + + if got, want := codexDesktopModelNames(inventory.Defaults), []string{"paid-model:cloud", "new-free-model:cloud"}; !slices.Equal(got, want) { + t.Fatalf("defaults = %v, want shared recommendation list %v", got, want) + } + if inventory.DefaultPrimary != "new-free-model:cloud" { + t.Fatalf("default primary = %q, want endpoint-provided Free recommendation", inventory.DefaultPrimary) + } +} + +func TestCodexDesktopAutomaticSelectionFollowsCurrentAccount(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/tags" { + http.NotFound(w, r) + return + } + _, _ = w.Write([]byte(`{"models":[]}`)) + })) + defer server.Close() + base, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + client := api.NewClient(base, server.Client()) + + originalController := codexDesktop + originalClientFactory := codexDesktopClientFactory + originalRecommendations := codexDesktopRecommendations + originalAccessState := codexDesktopAccessState + originalCloudModels := codexDesktopCloudModels + originalAttempts := codexDesktopModelLoadAttempts + t.Cleanup(func() { + codexDesktop = originalController + codexDesktopClientFactory = originalClientFactory + codexDesktopRecommendations = originalRecommendations + codexDesktopAccessState = originalAccessState + codexDesktopCloudModels = originalCloudModels + codexDesktopModelLoadAttempts = originalAttempts + }) + codexDesktop = &fakeCodexDesktopController{installed: true} + codexDesktopClientFactory = func() (*api.Client, error) { return client, nil } + codexDesktopRecommendations = func(context.Context) ([]api.ModelRecommendation, error) { + return []api.ModelRecommendation{ + {Model: "paid-a:cloud", RequiredPlan: "pro"}, + {Model: "paid-b:cloud", RequiredPlan: "pro"}, + {Model: "free-e:cloud", RequiredPlan: "free"}, + }, nil + } + codexDesktopCloudModels = func(context.Context) ([]string, error) { return nil, nil } + codexDesktopModelLoadAttempts = 1 + plan := "free" + codexDesktopAccessState = func(context.Context) (proxy.ClaudeDesktopAccessState, error) { + return proxy.ClaudeDesktopAccessState{ + Cloud: proxy.ClaudeDesktopCloudOn, Account: proxy.ClaudeDesktopAccountSignedIn, Plan: plan, + }, nil + } + + freeSettings, err := getCodexDesktopModelsSettings() + if err != nil { + t.Fatal(err) + } + want := []string{"paid-a:cloud", "paid-b:cloud", "free-e:cloud"} + if !freeSettings.UsesDefaults || !slices.Equal(freeSettings.Selected, want) { + t.Fatalf("free settings = %#v, want implicit shared defaults %v", freeSettings, want) + } + freePrimary, freeModels, err := loadCodexDesktopModels(context.Background(), nil) + if err != nil { + t.Fatal(err) + } + if freePrimary != "free-e:cloud" || !slices.Equal(codexDesktopModelNames(freeModels), want) { + t.Fatalf("free route = %q/%v, want Free primary with shared list", freePrimary, codexDesktopModelNames(freeModels)) + } + + plan = "team" + paidSettings, err := getCodexDesktopModelsSettings() + if err != nil { + t.Fatal(err) + } + if !paidSettings.UsesDefaults || !slices.Equal(paidSettings.Selected, want) { + t.Fatalf("paid settings = %#v, want same implicit shared defaults %v", paidSettings, want) + } + paidPrimary, paidModels, err := loadCodexDesktopModels(context.Background(), nil) + if err != nil { + t.Fatal(err) + } + if paidPrimary != "paid-a:cloud" || !slices.Equal(codexDesktopModelNames(paidModels), want) { + t.Fatalf("paid route = %q/%v, want paid primary with shared list", paidPrimary, codexDesktopModelNames(paidModels)) + } + if got := config.IntegrationModels(codexDesktopIntegrationName); len(got) != 0 { + t.Fatalf("saved models = %v, want account defaults to remain implicit", got) + } + + custom := []string{"paid-b:cloud", "free-e:cloud"} + if err := config.SaveIntegration(codexDesktopIntegrationName, custom); err != nil { + t.Fatal(err) + } + plan = "free" + customFreeSettings, err := getCodexDesktopModelsSettings() + if err != nil { + t.Fatal(err) + } + if customFreeSettings.UsesDefaults || !slices.Equal(customFreeSettings.Selected, custom) { + t.Fatalf("custom Free settings = %#v, want saved list %v", customFreeSettings, custom) + } + customFreePrimary, customFreeModels, err := loadCodexDesktopModels(context.Background(), custom) + if err != nil { + t.Fatal(err) + } + if customFreePrimary != "free-e:cloud" || !slices.Equal(codexDesktopModelNames(customFreeModels), custom) { + t.Fatalf("custom Free route = %q/%v, want preserved list with Free primary", customFreePrimary, codexDesktopModelNames(customFreeModels)) + } + + plan = "team" + customPaidSettings, err := getCodexDesktopModelsSettings() + if err != nil { + t.Fatal(err) + } + if customPaidSettings.UsesDefaults || !slices.Equal(customPaidSettings.Selected, custom) { + t.Fatalf("custom paid settings = %#v, want saved list %v", customPaidSettings, custom) + } + customPaidPrimary, customPaidModels, err := loadCodexDesktopModels(context.Background(), custom) + if err != nil { + t.Fatal(err) + } + if customPaidPrimary != "paid-b:cloud" || !slices.Equal(codexDesktopModelNames(customPaidModels), custom) { + t.Fatalf("custom paid route = %q/%v, want preserved list with first available paid model", customPaidPrimary, codexDesktopModelNames(customPaidModels)) + } +} + +func TestBuildCodexDesktopModelInventoryMergesEquivalentCloudAliases(t *testing.T) { + inventory := buildCodexDesktopModelInventory( + []api.ModelRecommendation{{Model: "gemma4:31b-cloud", RequiredPlan: "free"}}, + nil, + []string{"gemma4:31b:cloud"}, + proxy.ClaudeDesktopAccessState{Cloud: proxy.ClaudeDesktopCloudOn, Account: proxy.ClaudeDesktopAccountSignedIn, Plan: "team"}, + true, + true, + true, + ) + + if len(inventory.Catalog) != 1 { + t.Fatalf("catalog = %#v, want one merged model", inventory.Catalog) + } + model := inventory.Catalog[0] + if model.Model.Name != "gemma4:31b:cloud" || model.DisplayName != "gemma4:31b-cloud" { + t.Fatalf("merged model = %#v, want inventory route with recommendation display name", model) + } + if got := codexDesktopModelNames(inventory.Defaults); !slices.Equal(got, []string{"gemma4:31b:cloud"}) { + t.Fatalf("defaults = %v, want the merged cloud route", got) + } +} + +func TestCodexDesktopModelStatusesKeepsSavedUnavailableModel(t *testing.T) { + statuses := codexDesktopModelStatuses(codexDesktopModelInventory{}, []string{"saved-model:cloud"}) + if len(statuses) != 1 { + t.Fatalf("statuses = %#v, want saved model", statuses) + } + status := statuses[0] + if status.Name != "saved-model:cloud" || !status.Selected || + status.Availability != string(proxy.ClaudeDesktopAvailabilityUnknown) || + status.Reason != string(proxy.ClaudeDesktopAccessVerificationUnavailable) { + t.Fatalf("status = %#v, want selected model with unverified access", status) + } +} + +func TestCodexDesktopInventoryModelAccessReportsCloudOff(t *testing.T) { + availability, reason := codexDesktopInventoryModelAccess( + launch.LaunchModel{Name: "cloud-model:cloud", Remote: true}, + proxy.ClaudeDesktopAccessState{Cloud: proxy.ClaudeDesktopCloudOff}, + true, + false, + ) + if availability != proxy.ClaudeDesktopAvailabilityUnavailable || reason != proxy.ClaudeDesktopAccessCloudOff { + t.Fatalf("access = %q/%q, want unavailable/cloud_off", availability, reason) + } +} + +func TestLoadCodexDesktopRecommendationsUsesCodexQualifier(t *testing.T) { + useTestOllamaRequestSigner(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/experimental/model-recommendations" || r.URL.Query().Get("app") != "codex-desktop" { + t.Fatalf("request URL = %q", r.URL.String()) + } + if r.Header.Get("Authorization") != "test-public-key:test-signature" { + t.Fatal("recommendation request is missing public-key identity") + } + _, _ = w.Write([]byte(`{"recommendations":[{"model":"glm-5.3-flash:cloud","required_plan":"pro","thinking":{"values":["low","high","max"],"default":"max"}}],"mappings":{"ignored":{"model":"other"}}}`)) + })) + defer server.Close() + + previousClient := codexDesktopRecommendationsClient + previousEndpoint := codexDesktopRecommendationsEndpoint + codexDesktopRecommendationsClient = server.Client() + codexDesktopRecommendationsEndpoint = func() string { + return server.URL + "/api/experimental/model-recommendations?app=codex-desktop" + } + t.Cleanup(func() { + codexDesktopRecommendationsClient = previousClient + codexDesktopRecommendationsEndpoint = previousEndpoint + }) + + recommendations, err := loadCodexDesktopRecommendations(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(recommendations) != 1 || recommendations[0].Model != "glm-5.3-flash:cloud" || recommendations[0].Thinking == nil { + t.Fatalf("recommendations = %#v, want Codex catalog with thinking metadata", recommendations) + } +} + +func TestGetCodexDesktopModelsSettingsKeepsSelectionWhenInventoryFails(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + originalController := codexDesktop + originalClientFactory := codexDesktopClientFactory + t.Cleanup(func() { + codexDesktop = originalController + codexDesktopClientFactory = originalClientFactory + }) + if err := config.SaveIntegration(codexDesktopIntegrationName, []string{"same-model"}); err != nil { + t.Fatal(err) + } + codexDesktop = &fakeCodexDesktopController{installed: true, running: true, configured: true} + codexDesktopClientFactory = func() (*api.Client, error) { + return nil, errors.New("inventory unavailable") + } + + settings, err := getCodexDesktopModelsSettings() + if err == nil { + t.Fatal("getCodexDesktopModelsSettings returned nil inventory error") + } + if !slices.Equal(settings.Selected, []string{"same-model"}) { + t.Fatalf("selected models = %v, want saved selection", settings.Selected) + } + if !settings.Connected || !settings.Running { + t.Fatalf("settings = %#v, want live connected state", settings) + } +} + +func TestCodexDesktopDefaultModelsLimitsSelectionToFive(t *testing.T) { + available := []launch.LaunchModel{ + {Name: "model-1"}, + {Name: "model-2"}, + {Name: "model-3"}, + {Name: "model-4"}, + {Name: "model-5"}, + {Name: "model-6"}, + } + got := codexDesktopModelNames(codexDesktopDefaultModels(codexDesktopModelInventory{Available: available})) + if len(got) != codexDesktopMaxModels || got[0] != "model-1" || got[4] != "model-5" { + t.Fatalf("default models = %v, want first five available models", got) + } +} + +func TestBuildCodexDesktopModelsRejectsEmptyCatalog(t *testing.T) { + if _, _, err := buildCodexDesktopModels(nil, nil, nil); err == nil { + t.Fatal("buildCodexDesktopModels returned nil error for empty catalog") + } +} + +func TestBuildCodexDesktopModelsRejectsUnavailableLocalSelection(t *testing.T) { + if _, _, err := buildCodexDesktopModels([]string{"missing-local"}, []api.ListModelResponse{{Name: "qwen3:8b"}}, nil); err == nil { + t.Fatal("buildCodexDesktopModels returned nil error for unavailable selection") + } +} + +func TestLoadCodexDesktopAvailableModelsRetriesEmptyStartupInventory(t *testing.T) { + stubCodexDesktopCatalogSources(t, nil, proxy.ClaudeDesktopAccessState{ + Cloud: proxy.ClaudeDesktopCloudOn, Account: proxy.ClaudeDesktopAccountSignedOut, + }) + tagRequests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/tags": + tagRequests++ + if tagRequests < 3 { + _, _ = w.Write([]byte(`{"models":[]}`)) + return + } + _, _ = w.Write([]byte(`{"models":[{"name":"qwen3:8b"}]}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + base, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + client := api.NewClient(base, server.Client()) + + originalClientFactory := codexDesktopClientFactory + originalCloudModels := codexDesktopCloudModels + originalAttempts := codexDesktopModelLoadAttempts + originalRetryWait := codexDesktopModelRetryWait + t.Cleanup(func() { + codexDesktopClientFactory = originalClientFactory + codexDesktopCloudModels = originalCloudModels + codexDesktopModelLoadAttempts = originalAttempts + codexDesktopModelRetryWait = originalRetryWait + }) + codexDesktopClientFactory = func() (*api.Client, error) { return client, nil } + codexDesktopCloudModels = func(context.Context) ([]string, error) { return nil, nil } + codexDesktopModelLoadAttempts = 3 + codexDesktopModelRetryWait = time.Millisecond + + models, err := loadCodexDesktopAvailableModels(context.Background()) + if err != nil { + t.Fatal(err) + } + if tagRequests != 3 { + t.Fatalf("tag requests = %d, want 3", tagRequests) + } + if got := codexDesktopModelNames(models); len(got) != 1 || got[0] != "qwen3:8b" { + t.Fatalf("models = %#v, want qwen3:8b", got) + } +} + +func TestLoadCodexDesktopModelInventoryRetriesAccountAccessDuringServerRestart(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/tags" { + _, _ = w.Write([]byte(`{"models":[]}`)) + return + } + http.NotFound(w, r) + })) + defer server.Close() + base, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + client := api.NewClient(base, server.Client()) + + originalClientFactory := codexDesktopClientFactory + originalCloudModels := codexDesktopCloudModels + originalRecommendations := codexDesktopRecommendations + originalAccessState := codexDesktopAccessState + originalAttempts := codexDesktopModelLoadAttempts + originalRetryWait := codexDesktopModelRetryWait + t.Cleanup(func() { + codexDesktopClientFactory = originalClientFactory + codexDesktopCloudModels = originalCloudModels + codexDesktopRecommendations = originalRecommendations + codexDesktopAccessState = originalAccessState + codexDesktopModelLoadAttempts = originalAttempts + codexDesktopModelRetryWait = originalRetryWait + }) + codexDesktopClientFactory = func() (*api.Client, error) { return client, nil } + codexDesktopCloudModels = func(context.Context) ([]string, error) { return nil, nil } + codexDesktopRecommendations = func(context.Context) ([]api.ModelRecommendation, error) { + return []api.ModelRecommendation{{Model: "glm-5.3-flash:cloud", RequiredPlan: "pro"}}, nil + } + accessRequests := 0 + codexDesktopAccessState = func(context.Context) (proxy.ClaudeDesktopAccessState, error) { + accessRequests++ + if accessRequests < 3 { + return proxy.ClaudeDesktopAccessState{}, errors.New("server is restarting") + } + return proxy.ClaudeDesktopAccessState{ + Cloud: proxy.ClaudeDesktopCloudOn, Account: proxy.ClaudeDesktopAccountSignedIn, Plan: "team", + }, nil + } + codexDesktopModelLoadAttempts = 3 + codexDesktopModelRetryWait = time.Millisecond + + inventory, err := loadCodexDesktopModelInventory(context.Background()) + if err != nil { + t.Fatal(err) + } + if accessRequests != 3 { + t.Fatalf("account access requests = %d, want 3", accessRequests) + } + if got := codexDesktopModelNames(inventory.Defaults); !slices.Equal(got, []string{"glm-5.3-flash:cloud"}) { + t.Fatalf("defaults = %v, want recommendation after server restart", got) + } +} + +func TestLoadCodexDesktopModelsHydratesAccountOnlyCloudCapabilities(t *testing.T) { + stubCodexDesktopCatalogSources(t, []api.ModelRecommendation{{ + Model: "glm-5.3-flash:cloud", + Thinking: &api.ModelRecommendationThinking{ + Values: []any{"low", "high", "max"}, Default: "max", + }, + }}, proxy.ClaudeDesktopAccessState{ + Cloud: proxy.ClaudeDesktopCloudOn, Account: proxy.ClaudeDesktopAccountSignedIn, Plan: "pro", + }) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/tags": + _, _ = w.Write([]byte(`{"models":[]}`)) + case "/api/show": + _, _ = w.Write([]byte(`{"capabilities":["completion","thinking","tools","vision"],"details":{"family":"glm5_next"}}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + base, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + client := api.NewClient(base, server.Client()) + + originalClientFactory := codexDesktopClientFactory + originalCloudModels := codexDesktopCloudModels + t.Cleanup(func() { + codexDesktopClientFactory = originalClientFactory + codexDesktopCloudModels = originalCloudModels + }) + codexDesktopClientFactory = func() (*api.Client, error) { return client, nil } + codexDesktopCloudModels = func(context.Context) ([]string, error) { + return []string{"glm-5.3-flash:cloud"}, nil + } + + primary, models, err := loadCodexDesktopModels(context.Background(), []string{"glm-5.3-flash:cloud"}) + if err != nil { + t.Fatal(err) + } + if primary != "glm-5.3-flash:cloud" || len(models) != 1 { + t.Fatalf("models = %q, %#v; want selected cloud model", primary, models) + } + if !models[0].HasCapability(modelpkg.CapabilityThinking) { + t.Fatalf("capabilities = %v, want thinking from /api/show", models[0].Capabilities) + } + if models[0].Details.Family != "glm5_next" { + t.Fatalf("family = %q, want model metadata from /api/show", models[0].Details.Family) + } + if models[0].Thinking == nil || !slices.Equal(models[0].Thinking.Values, []any{"low", "high", "max"}) || models[0].Thinking.Default != "max" { + t.Fatalf("thinking = %#v, want recommendation endpoint metadata", models[0].Thinking) + } +} + +func TestReconcileCodexDesktopModelsDropsUnavailableSavedSelections(t *testing.T) { + available := []launch.LaunchModel{ + {Name: "qwen3:8b"}, + {Name: "glm-5.2:cloud", Remote: true}, + } + primary, models, err := reconcileCodexDesktopModels( + []string{"removed-model", "qwen3:8b", "expired:cloud", "glm-5.2:cloud"}, + available, + nil, + ) + if err != nil { + t.Fatal(err) + } + if primary != "qwen3:8b" { + t.Fatalf("primary = %q, want qwen3:8b", primary) + } + if got := codexDesktopModelNames(models); len(got) != 2 || got[0] != "qwen3:8b" || got[1] != "glm-5.2:cloud" { + t.Fatalf("models = %#v, want remaining available saved models", got) + } +} + +func TestReconcileCodexDesktopModelsFallsBackWhenAllSavedSelectionsAreUnavailable(t *testing.T) { + available := []launch.LaunchModel{{Name: "qwen3:8b"}, {Name: "llama3.2:latest"}} + primary, models, err := reconcileCodexDesktopModels([]string{"removed-model"}, available, nil) + if err != nil { + t.Fatal(err) + } + if primary != "qwen3:8b" { + t.Fatalf("primary = %q, want qwen3:8b", primary) + } + if got := codexDesktopModelNames(models); len(got) != 2 || got[0] != "qwen3:8b" || got[1] != "llama3.2:latest" { + t.Fatalf("models = %#v, want current defaults", got) + } +} + +func TestReconcileCodexDesktopModelsUsesProvidedDefaultsWhenSavedSelectionsAreUnavailable(t *testing.T) { + available := []launch.LaunchModel{ + {Name: "default:cloud", Remote: true}, + {Name: "extra-local"}, + } + defaults := []launch.LaunchModel{available[0]} + primary, models, err := reconcileCodexDesktopModels([]string{"removed-model"}, available, defaults) + if err != nil { + t.Fatal(err) + } + if primary != "default:cloud" { + t.Fatalf("primary = %q, want provided default", primary) + } + if got := codexDesktopModelNames(models); len(got) != 1 || got[0] != "default:cloud" { + t.Fatalf("models = %v, want only provided defaults", got) + } +} + +func TestBuildCodexDesktopModelsExcludesUnentitledListedCloudModels(t *testing.T) { + listed := []api.ListModelResponse{ + {Name: "glm-5.2:cloud", RemoteModel: "glm-5.2"}, + {Name: "qwen3:8b"}, + } + + primary, models, err := buildCodexDesktopModels(nil, listed, nil) + if err != nil { + t.Fatal(err) + } + if primary != "qwen3:8b" || len(models) != 1 || models[0].Name != "qwen3:8b" { + t.Fatalf("models = %q, %#v; want cloud manifest excluded without account access", primary, models) + } +} + +func TestBuildCodexDesktopModelsLimitsCatalogToFive(t *testing.T) { + listed := []api.ListModelResponse{ + {Name: "model-1"}, + {Name: "model-2"}, + {Name: "model-3"}, + {Name: "model-4"}, + {Name: "model-5"}, + {Name: "model-6"}, + {Name: "preferred-model"}, + } + + primary, models, err := buildCodexDesktopModels([]string{"preferred-model", "model-1", "model-2", "model-3", "model-4"}, listed, nil) + if err != nil { + t.Fatal(err) + } + if primary != "preferred-model" { + t.Fatalf("primary = %q, want preferred-model", primary) + } + if len(models) != codexDesktopMaxModels { + t.Fatalf("model count = %d, want %d", len(models), codexDesktopMaxModels) + } + if models[0].Name != "preferred-model" { + t.Fatalf("first model = %q, want preferred-model", models[0].Name) + } +} + +func TestBuildCodexDesktopModelsRejectsMoreThanFiveSelections(t *testing.T) { + selected := []string{"model-1", "model-2", "model-3", "model-4", "model-5", "model-6"} + listed := make([]api.ListModelResponse, 0, len(selected)) + for _, name := range selected { + listed = append(listed, api.ListModelResponse{Name: name}) + } + if _, _, err := buildCodexDesktopModels(selected, listed, nil); err == nil { + t.Fatal("buildCodexDesktopModels returned nil error for six selections") + } +} + +func TestCodexDesktopModelRefreshErrorUsesUserFacingCopy(t *testing.T) { + withSavedModels := codexDesktopModelRefreshError(codexDesktopModelsSettings{ + Selected: []string{"qwen3:8b"}, + }) + if withSavedModels != "Couldn’t refresh available models. Your saved models are unchanged." { + t.Fatalf("saved-model message = %q", withSavedModels) + } + + withoutSavedModels := codexDesktopModelRefreshError(codexDesktopModelsSettings{}) + if withoutSavedModels != "Couldn’t refresh available models. Try again." { + t.Fatalf("empty-selection message = %q", withoutSavedModels) + } +} diff --git a/app/cmd/app/codex_desktop_installer_darwin.go b/app/cmd/app/codex_desktop_installer_darwin.go new file mode 100644 index 000000000..e638a1746 --- /dev/null +++ b/app/cmd/app/codex_desktop_installer_darwin.go @@ -0,0 +1,212 @@ +//go:build darwin + +package main + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +const ( + maxCodexDesktopDiskImageBytes = 2 << 30 + codexDesktopBundleID = "com.openai.codex" + codexDesktopTeamID = "2DC432GLL2" +) + +var errCodexDesktopDestinationExists = errors.New("ChatGPT installation destination already exists") + +func codexDesktopInstallDestinations() []string { + destinations := []string{"/Applications/ChatGPT.app"} + if home, err := os.UserHomeDir(); err == nil { + destinations = append(destinations, filepath.Join(home, "Applications", "ChatGPT.app")) + } + return destinations +} + +func installCodexDesktopDiskImage(imagePath string, destinations []string, verify func(string) error) (installedPath string, err error) { + if len(destinations) == 0 { + return "", errors.New("ChatGPT installation destination is required") + } + if verify == nil { + return "", errors.New("ChatGPT bundle verifier is required") + } + info, err := os.Stat(imagePath) + if err != nil { + return "", fmt.Errorf("stat ChatGPT disk image: %w", err) + } + if !info.Mode().IsRegular() { + return "", errors.New("ChatGPT disk image is not a regular file") + } + if info.Size() > maxCodexDesktopDiskImageBytes { + return "", fmt.Errorf("ChatGPT disk image exceeds %d bytes", maxCodexDesktopDiskImageBytes) + } + + workDir, err := os.MkdirTemp("", "ollama-chatgpt-install-") + if err != nil { + return "", fmt.Errorf("create ChatGPT installation directory: %w", err) + } + defer os.RemoveAll(workDir) + mountPath := filepath.Join(workDir, "volume") + if err := os.Mkdir(mountPath, 0o700); err != nil { + return "", fmt.Errorf("create ChatGPT mount point: %w", err) + } + + output, err := exec.Command( + "/usr/bin/hdiutil", + "attach", + "-nobrowse", + "-readonly", + "-mountpoint", + mountPath, + imagePath, + ).CombinedOutput() + if err != nil { + return "", fmt.Errorf("mount ChatGPT disk image: %w: %s", err, strings.TrimSpace(string(output))) + } + defer func() { + detachOutput, detachErr := exec.Command("/usr/bin/hdiutil", "detach", mountPath).CombinedOutput() + if detachErr == nil { + return + } + forceOutput, forceErr := exec.Command("/usr/bin/hdiutil", "detach", "-force", mountPath).CombinedOutput() + if forceErr != nil && err == nil { + err = fmt.Errorf( + "unmount ChatGPT disk image: %v: %s; force detach: %v: %s", + detachErr, + strings.TrimSpace(string(detachOutput)), + forceErr, + strings.TrimSpace(string(forceOutput)), + ) + } + }() + + bundlePath, err := codexDesktopBundleOnVolume(mountPath) + if err != nil { + return "", err + } + return installCodexDesktopBundle(bundlePath, destinations, verify) +} + +func codexDesktopBundleOnVolume(mountPath string) (string, error) { + for _, name := range []string{"ChatGPT.app", "Codex.app"} { + bundlePath := filepath.Join(mountPath, name) + info, err := os.Lstat(bundlePath) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return "", fmt.Errorf("inspect ChatGPT bundle: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return "", fmt.Errorf("ChatGPT disk image contains an invalid %s", name) + } + return bundlePath, nil + } + return "", errors.New("ChatGPT disk image does not contain ChatGPT.app") +} + +func installCodexDesktopBundle(bundlePath string, destinations []string, verify func(string) error) (string, error) { + if err := validateCodexDesktopBundle(bundlePath); err != nil { + return "", err + } + if err := verify(bundlePath); err != nil { + return "", fmt.Errorf("verify ChatGPT signature: %w", err) + } + + var permissionErr error + for _, destination := range destinations { + if strings.TrimSpace(destination) == "" { + continue + } + if _, err := os.Lstat(destination); err == nil { + return "", fmt.Errorf("%w: %s", errCodexDesktopDestinationExists, destination) + } else if !errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("check ChatGPT destination %s: %w", destination, err) + } + parent := filepath.Dir(destination) + if err := os.MkdirAll(parent, 0o755); err != nil { + if errors.Is(err, os.ErrPermission) { + permissionErr = err + continue + } + return "", fmt.Errorf("create ChatGPT destination: %w", err) + } + stageDir, err := os.MkdirTemp(parent, ".ollama-chatgpt-install-") + if err != nil { + if errors.Is(err, os.ErrPermission) { + permissionErr = err + continue + } + return "", fmt.Errorf("create staged ChatGPT destination: %w", err) + } + stagedBundle := filepath.Join(stageDir, "ChatGPT.app") + copyOutput, copyErr := exec.Command("/usr/bin/ditto", bundlePath, stagedBundle).CombinedOutput() + if copyErr == nil { + copyErr = validateCodexDesktopBundle(stagedBundle) + } + if copyErr == nil { + copyErr = verify(stagedBundle) + } + if copyErr == nil { + copyErr = os.Rename(stagedBundle, destination) + } + removeErr := os.RemoveAll(stageDir) + if copyErr != nil { + if errors.Is(copyErr, os.ErrPermission) { + permissionErr = copyErr + continue + } + return "", fmt.Errorf("install ChatGPT in %s: %w: %s", parent, copyErr, strings.TrimSpace(string(copyOutput))) + } + if removeErr != nil { + return "", fmt.Errorf("remove staged ChatGPT destination: %w", removeErr) + } + return destination, nil + } + if permissionErr != nil { + return "", fmt.Errorf("install ChatGPT in Applications: %w", permissionErr) + } + return "", errors.New("ChatGPT installation destination is required") +} + +func validateCodexDesktopBundle(bundlePath string) error { + info, err := os.Stat(bundlePath) + if err != nil || !info.IsDir() { + return errors.New("ChatGPT disk image does not contain a valid app bundle") + } + for _, executableName := range []string{"ChatGPT", "Codex"} { + executable := filepath.Join(bundlePath, "Contents", "MacOS", executableName) + info, err = os.Stat(executable) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return fmt.Errorf("inspect ChatGPT executable: %w", err) + } + if info.Mode().IsRegular() && info.Mode()&0o111 != 0 { + return nil + } + return errors.New("ChatGPT executable is not executable") + } + return errors.New("ChatGPT executable is missing") +} + +func verifyCodexDesktopBundle(bundlePath string) error { + if output, err := exec.Command("/usr/bin/codesign", "--verify", "--deep", "--strict", bundlePath).CombinedOutput(); err != nil { + return fmt.Errorf("codesign verification failed: %w: %s", err, strings.TrimSpace(string(output))) + } + output, err := exec.Command("/usr/bin/codesign", "-d", "--verbose=4", bundlePath).CombinedOutput() + if err != nil { + return fmt.Errorf("read code signature: %w: %s", err, strings.TrimSpace(string(output))) + } + details := string(output) + if !strings.Contains(details, "Identifier="+codexDesktopBundleID) || + !strings.Contains(details, "TeamIdentifier="+codexDesktopTeamID) { + return errors.New("unexpected ChatGPT signing identity") + } + return nil +} diff --git a/app/cmd/app/codex_desktop_installer_darwin_test.go b/app/cmd/app/codex_desktop_installer_darwin_test.go new file mode 100644 index 000000000..e02c67764 --- /dev/null +++ b/app/cmd/app/codex_desktop_installer_darwin_test.go @@ -0,0 +1,120 @@ +//go:build darwin + +package main + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func TestInstallCodexDesktopBundle(t *testing.T) { + bundle := writeCodexDesktopTestBundle(t, "ChatGPT.app", "ChatGPT") + destination := filepath.Join(t.TempDir(), "Applications", "ChatGPT.app") + verified := 0 + installed, err := installCodexDesktopBundle(bundle, []string{destination}, func(string) error { + verified++ + return nil + }) + if err != nil { + t.Fatal(err) + } + if installed != destination { + t.Fatalf("installed = %q, want %q", installed, destination) + } + if verified != 2 { + t.Fatalf("signature verification count = %d, want 2", verified) + } + info, err := os.Stat(filepath.Join(installed, "Contents", "MacOS", "ChatGPT")) + if err != nil { + t.Fatal(err) + } + if info.Mode()&0o111 == 0 { + t.Fatal("installed ChatGPT executable is not executable") + } +} + +func TestInstallCodexDesktopBundleAcceptsCodexNamedSource(t *testing.T) { + bundle := writeCodexDesktopTestBundle(t, "Codex.app", "Codex") + destination := filepath.Join(t.TempDir(), "Applications", "ChatGPT.app") + if _, err := installCodexDesktopBundle(bundle, []string{destination}, func(string) error { return nil }); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(destination, "Contents", "MacOS", "Codex")); err != nil { + t.Fatal(err) + } +} + +func TestInstallCodexDesktopBundleVerifiesBeforeCopy(t *testing.T) { + bundle := writeCodexDesktopTestBundle(t, "ChatGPT.app", "ChatGPT") + destination := filepath.Join(t.TempDir(), "ChatGPT.app") + wantErr := errors.New("invalid signature") + if _, err := installCodexDesktopBundle(bundle, []string{destination}, func(string) error { return wantErr }); !errors.Is(err, wantErr) { + t.Fatalf("error = %v, want %v", err, wantErr) + } + if _, err := os.Stat(destination); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("invalid bundle created destination: %v", err) + } +} + +func TestInstallCodexDesktopBundleDoesNotOverwrite(t *testing.T) { + bundle := writeCodexDesktopTestBundle(t, "ChatGPT.app", "ChatGPT") + destination := filepath.Join(t.TempDir(), "ChatGPT.app") + if err := os.MkdirAll(destination, 0o755); err != nil { + t.Fatal(err) + } + if _, err := installCodexDesktopBundle(bundle, []string{destination}, func(string) error { return nil }); !errors.Is(err, errCodexDesktopDestinationExists) { + t.Fatalf("error = %v, want destination exists", err) + } +} + +func TestInstallCodexDesktopBundleDoesNotOverwriteBrokenSymlink(t *testing.T) { + bundle := writeCodexDesktopTestBundle(t, "ChatGPT.app", "ChatGPT") + destination := filepath.Join(t.TempDir(), "ChatGPT.app") + if err := os.Symlink(filepath.Join(t.TempDir(), "missing"), destination); err != nil { + t.Fatal(err) + } + if _, err := installCodexDesktopBundle(bundle, []string{destination}, func(string) error { return nil }); !errors.Is(err, errCodexDesktopDestinationExists) { + t.Fatalf("error = %v, want destination exists", err) + } +} + +func TestCodexDesktopBundleOnVolumeRejectsSymlink(t *testing.T) { + volume := t.TempDir() + target := writeCodexDesktopTestBundle(t, "ChatGPT.app", "ChatGPT") + if err := os.Symlink(target, filepath.Join(volume, "ChatGPT.app")); err != nil { + t.Fatal(err) + } + if _, err := codexDesktopBundleOnVolume(volume); err == nil { + t.Fatal("codexDesktopBundleOnVolume accepted a symlink") + } +} + +func TestInstallCodexDesktopDiskImageRealArchive(t *testing.T) { + image := os.Getenv("OLLAMA_TEST_CODEX_DESKTOP_DMG") + if image == "" { + t.Skip("set OLLAMA_TEST_CODEX_DESKTOP_DMG to the official ChatGPT DMG") + } + destination := filepath.Join(t.TempDir(), "Applications", "ChatGPT.app") + installed, err := installCodexDesktopDiskImage(image, []string{destination}, verifyCodexDesktopBundle) + if err != nil { + t.Fatal(err) + } + if installed != destination { + t.Fatalf("installed = %q, want %q", installed, destination) + } +} + +func writeCodexDesktopTestBundle(t *testing.T, appName, executableName string) string { + t.Helper() + bundle := filepath.Join(t.TempDir(), appName) + executable := filepath.Join(bundle, "Contents", "MacOS", executableName) + if err := os.MkdirAll(filepath.Dir(executable), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(executable, []byte("binary"), 0o755); err != nil { + t.Fatal(err) + } + return bundle +} diff --git a/app/cmd/app/webview.go b/app/cmd/app/webview.go index 9518cacb3..0427601a7 100644 --- a/app/cmd/app/webview.go +++ b/app/cmd/app/webview.go @@ -223,6 +223,7 @@ func (w *Webview) Run(path string) unsafe.Pointer { }) bindClaudeDesktop(wv) + bindCodexDesktop(wv) wv.Bind("close", func() { hideWindow(wv.Window()) diff --git a/app/darwin/Ollama.app/Contents/Resources/chatgpt.svg b/app/darwin/Ollama.app/Contents/Resources/chatgpt.svg new file mode 100644 index 000000000..683aefc9c --- /dev/null +++ b/app/darwin/Ollama.app/Contents/Resources/chatgpt.svg @@ -0,0 +1 @@ + diff --git a/app/ui/app/public/launch-icons/codex-app.png b/app/ui/app/public/launch-icons/codex-app.png deleted file mode 100644 index 72e9baf7608cb565dfe8e11e43fc869ff9ea8efa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 41615 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 L4IiN&+DH8#JiG3F diff --git a/app/ui/app/public/launch-icons/codex-color.svg b/app/ui/app/public/launch-icons/codex-color.svg new file mode 100644 index 000000000..4c1f44ee2 --- /dev/null +++ b/app/ui/app/public/launch-icons/codex-color.svg @@ -0,0 +1 @@ + diff --git a/app/ui/app/src/components/ClaudeDesktopModelsSettings.tsx b/app/ui/app/src/components/ClaudeDesktopModelsSettings.tsx index 0a7172633..c36308962 100644 --- a/app/ui/app/src/components/ClaudeDesktopModelsSettings.tsx +++ b/app/ui/app/src/components/ClaudeDesktopModelsSettings.tsx @@ -38,6 +38,7 @@ interface ClaudeDesktopModelsSettingsProps { initialCloudModels?: string[]; includeCloudModels?: boolean; onDraftChange?: (hasChanges: boolean) => void; + showSectionHeading?: boolean; } const fallbackRoutes: ClaudeDesktopMappingStatus[] = [ @@ -266,6 +267,7 @@ export const ClaudeDesktopModelsSettings = forwardRef< initialCloudModels, includeCloudModels = false, onDraftChange, + showSectionHeading = true, }, ref, ) { @@ -575,13 +577,18 @@ export const ClaudeDesktopModelsSettings = forwardRef< : null); return ( -
-

- Apps -

+
+ {showSectionHeading && ( +

+ Apps +

+ )}
-
+ ); }); diff --git a/app/ui/app/src/components/CodexDesktopModelsSettings.test.tsx b/app/ui/app/src/components/CodexDesktopModelsSettings.test.tsx new file mode 100644 index 000000000..fb75409b6 --- /dev/null +++ b/app/ui/app/src/components/CodexDesktopModelsSettings.test.tsx @@ -0,0 +1,1094 @@ +import type { + CodexDesktopModelsSettings as ModelsSettings, + CodexDesktopStatus, +} from "@/types/webview"; +import type { + ButtonHTMLAttributes, + HTMLAttributes, + MouseEvent as ReactMouseEvent, + ReactNode, +} from "react"; +import { createRef } from "react"; +import { act, create } from "react-test-renderer"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + CodexDesktopModelsSettings, + type CodexDesktopModelsSettingsHandle, +} from "./CodexDesktopModelsSettings"; + +vi.mock("@headlessui/react", async (importOriginal) => { + const React = await import("react"); + const original = await importOriginal(); + type PopoverContextValue = { + open: boolean; + toggle: () => void; + }; + const PopoverContext = React.createContext(null); + const usePopover = () => { + const context = React.useContext(PopoverContext); + if (!context) throw new Error("Popover components must be nested"); + return context; + }; + + function TestPopover({ + children, + className, + }: { + children: ReactNode; + className?: string; + }) { + const [open, setOpen] = React.useState(false); + return ( + setOpen((current) => !current), + }} + > +
{children}
+
+ ); + } + + function TestPopoverButton({ + onClick, + ...props + }: ButtonHTMLAttributes) { + const { open, toggle } = usePopover(); + return ( + + ); + })} + {filtered.length === 0 && ( +

No models found

+ )} + + + ); +} + +export const CodexDesktopModelsSettings = forwardRef< + CodexDesktopModelsSettingsHandle, + CodexDesktopModelsSettingsProps +>(function CodexDesktopModelsSettings( + { initialSettings, accountKey, onDraftChange }, + ref, +) { + const normalizedInitialSettings = initialSettings + ? normalizeSettings(initialSettings) + : null; + const [settings, setSettings] = useState( + normalizedInitialSettings, + ); + const [selected, setSelected] = useState( + normalizedInitialSettings?.selected ?? [], + ); + const [saved, setSaved] = useState( + normalizedInitialSettings?.selected ?? [], + ); + const [loading, setLoading] = useState(!initialSettings); + const [applying, setApplying] = useState(false); + const [resetting, setResetting] = useState(false); + const [launchAction, setLaunchAction] = useState<"start" | "restart">( + "start", + ); + const [error, setError] = useState(null); + const [warning, setWarning] = useState(null); + const draftRef = useRef({ selected, saved }); + const accountKeyRef = useRef(accountKey); + const statusRequestRef = useRef(0); + const operationInFlightRef = useRef(false); + draftRef.current = { selected, saved }; + + const applyResult = useCallback( + (result: CodexDesktopModelsSettingsResult, preserveDraft = false) => { + const nextSettings = normalizeSettings(result.settings); + const keepDraft = + preserveDraft && + !selectionsEqual(draftRef.current.selected, draftRef.current.saved); + setSettings(nextSettings); + if (!keepDraft) { + setSelected(nextSettings.selected); + setSaved(nextSettings.selected); + } + setError(result.error ?? null); + setWarning(result.warning ?? null); + }, + [], + ); + + const refresh = useCallback(async () => { + if (!window.getCodexDesktopModelsSettings) { + setError("ChatGPT model settings are unavailable in this Ollama build."); + setWarning(null); + setLoading(false); + return; + } + const request = ++statusRequestRef.current; + try { + const result = await window.getCodexDesktopModelsSettings(); + if ( + request === statusRequestRef.current && + !operationInFlightRef.current + ) { + applyResult(result, true); + } + } catch { + if ( + request === statusRequestRef.current && + !operationInFlightRef.current + ) { + setError("Ollama could not load the ChatGPT model settings."); + setWarning(null); + } + } finally { + if (request === statusRequestRef.current) setLoading(false); + } + }, [applyResult]); + + useEffect(() => { + if (!initialSettings) void refresh(); + const onFocus = () => void refresh(); + window.addEventListener("focus", onFocus); + return () => window.removeEventListener("focus", onFocus); + }, [initialSettings, refresh]); + + useEffect(() => { + if (accountKeyRef.current === accountKey) return; + accountKeyRef.current = accountKey; + void refresh(); + }, [accountKey, refresh]); + + const hasChanges = !selectionsEqual(selected, saved); + useEffect(() => { + onDraftChange?.(hasChanges); + }, [hasChanges, onDraftChange]); + + const maxModels = settings?.maxModels ?? 5; + const models = useMemo(() => { + const catalog = [...(settings?.models ?? [])]; + const known = new Set(catalog.map((model) => model.name)); + for (const name of selected) { + if (known.has(name)) continue; + known.add(name); + catalog.push({ + name, + displayName: name, + selected: true, + availability: "unknown", + reason: "verification_unavailable", + }); + } + return catalog; + }, [selected, settings?.models]); + const displayNames = useMemo( + () => new Map(models.map((model) => [model.name, model.displayName])), + [models], + ); + + const toggleModel = (model: string) => { + setError(null); + setWarning(null); + setSelected((current) => { + if (current.includes(model)) { + return current.filter((name) => name !== model); + } + if (current.length >= maxModels) return current; + return [...current, model]; + }); + }; + + const applyChanges = async () => { + if (!window.applyCodexDesktopModels) { + setError("ChatGPT model settings are available in the Ollama macOS app."); + return; + } + if (selected.length === 0) { + setError("Choose at least one model for ChatGPT."); + return; + } + if (operationInFlightRef.current) return; + + setApplying(true); + setLaunchAction(settings?.running ? "restart" : "start"); + setError(null); + setWarning(null); + operationInFlightRef.current = true; + ++statusRequestRef.current; + try { + const modelsToApply = + !hasChanges && settings?.usesDefaults ? [] : selected; + let result = await window.applyCodexDesktopModels(modelsToApply, false); + if (result.restartConfirmationRequired) { + applyResult(result, true); + if ( + !window.confirm( + result.settings.connected + ? "Restart ChatGPT to update Ollama models? Any running task will stop." + : "Restart ChatGPT to add Ollama models? Any running task will stop.", + ) + ) { + return; + } + result = await window.applyCodexDesktopModels(modelsToApply, true); + } + ++statusRequestRef.current; + if (result.error) { + setSettings(normalizeSettings(result.settings)); + setError(result.error); + setWarning(result.warning ?? null); + return; + } + applyResult(result); + const openedStatus = await waitForChatGPTToOpen(); + if (openedStatus === null) { + setError("ChatGPT is taking longer than expected to open. Try again."); + return; + } + if (openedStatus) { + setSettings((current) => + current + ? { + ...current, + installed: openedStatus.installed, + connected: openedStatus.connected, + running: openedStatus.running, + } + : current, + ); + } + } catch { + setError("Ollama could not apply the ChatGPT models."); + } finally { + ++statusRequestRef.current; + operationInFlightRef.current = false; + setApplying(false); + } + }; + + const resetToDefaults = useCallback(async (): Promise => { + if (operationInFlightRef.current) return false; + + const resetModels = window.resetCodexDesktopModels; + if (!resetModels) { + setError("Ollama could not reset the ChatGPT models."); + return false; + } + + setResetting(true); + setError(null); + setWarning(null); + operationInFlightRef.current = true; + ++statusRequestRef.current; + try { + const result = await resetModels(); + ++statusRequestRef.current; + if (result.error) { + setSettings(normalizeSettings(result.settings)); + setError(result.error); + setWarning(result.warning ?? null); + return false; + } + applyResult(result); + return true; + } catch { + setError("Ollama could not reset the ChatGPT models."); + return false; + } finally { + ++statusRequestRef.current; + operationInFlightRef.current = false; + setResetting(false); + } + }, [applyResult]); + + useImperativeHandle(ref, () => ({ resetToDefaults }), [resetToDefaults]); + + if (!settings?.supported && !loading && !error) return null; + + const busy = applying || resetting; + + return ( +
+
+ + + + +
+
+
+

+ ChatGPT +

+

+ Choose up to {maxModels} Ollama models to use in ChatGPT. +

+
+
+ +
+
+ +
+ +
+ + Choose ChatGPT models + +
+ {selected.map((model) => ( + + + + {displayNames.get(model) ?? model} + + + + + ))} + {selected.length === 0 && ( + + {loading ? "Loading models…" : "Select models"} + + )} +
+
+ + + +
+
+ + {error && ( +

+ {error} +

+ )} + {warning && ( +

+ {warning} +

+ )} +
+
+
+ ); +}); diff --git a/app/ui/app/src/components/CodexDesktopRow.test.tsx b/app/ui/app/src/components/CodexDesktopRow.test.tsx new file mode 100644 index 000000000..5971f3545 --- /dev/null +++ b/app/ui/app/src/components/CodexDesktopRow.test.tsx @@ -0,0 +1,558 @@ +import type { IntegrationStatus } from "@/api"; +import type { CodexDesktopStatus } from "@/types/webview"; +import { renderToStaticMarkup } from "react-dom/server"; +import { act, create } from "react-test-renderer"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { CodexDesktopRow } from "./CodexDesktopRow"; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +const integration: IntegrationStatus = { + id: "chatgpt", + name: "ChatGPT", + description: "Use Ollama models in ChatGPT", + installed: true, + command: "ollama launch chatgpt", +}; + +function status( + overrides: Partial = {}, +): CodexDesktopStatus { + return { + supported: true, + installed: true, + connected: false, + running: false, + ...overrides, + }; +} + +describe("CodexDesktopRow", () => { + it("renders a disconnected ChatGPT toggle", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain(">ChatGPT (Desktop)

"); + expect(html).toContain("Use Ollama models in ChatGPT"); + expect(html).toContain('aria-label="Add Ollama models to ChatGPT"'); + expect(html).toContain('aria-checked="false"'); + }); + + it("shows only the Ollama request count when connected", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("0 Ollama requests this session"); + expect(html).not.toContain("Codex + Ollama"); + expect(html).not.toContain("3 Ollama models"); + expect(html).toContain('aria-label="Remove Ollama models from ChatGPT"'); + expect(html).toContain('aria-checked="true"'); + }); + + it("shows the Ollama request count with singular copy", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("1 Ollama request this session"); + }); + + it("offers installation when ChatGPT is not installed", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("Use Ollama models in ChatGPT"); + expect(html).not.toContain('disabled=""'); + expect(html).toContain('title="Install ChatGPT and add Ollama models"'); + expect(html).toContain("Download & connect"); + }); + + it("matches Claude's download and install progress states", async () => { + const notInstalled = status({ installed: false }); + let finishInstall!: (result: "opened") => void; + const install = new Promise<"opened">((resolve) => { + finishInstall = resolve; + }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + setInterval: globalThis.setInterval, + clearInterval: globalThis.clearInterval, + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + getCodexDesktopStatus: vi.fn().mockResolvedValue(notInstalled), + setCodexDesktopConnected: vi.fn(), + installCodexDesktop: vi.fn().mockReturnValue(install), + }); + + let renderer; + try { + await act(async () => { + renderer = create( + , + ); + }); + const toggle = renderer!.root.findByProps({ role: "switch" }); + await act(async () => { + toggle.props.onClick(); + await Promise.resolve(); + }); + + expect(toggle.props["aria-checked"]).toBe(true); + expect(toggle.props["aria-busy"]).toBe(true); + expect(toggle.props.disabled).toBe(true); + expect(toggle.props.className).toContain("disabled:cursor-wait"); + expect(renderer!.root.findByProps({ role: "status" }).children).toContain( + "Downloading…", + ); + expect( + renderer!.root.findAll((node) => + node.children.includes( + "Ollama is downloading the ChatGPT installer…", + ), + ), + ).toHaveLength(1); + + await act(async () => { + finishInstall("opened"); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(toggle.props["aria-checked"]).toBe(true); + expect(toggle.props["aria-busy"]).toBe(true); + expect(toggle.props.disabled).toBe(true); + expect(renderer!.root.findByProps({ role: "status" }).children).toContain( + "Finish installing…", + ); + expect( + renderer!.root.findAll((node) => + node.children.includes( + "Finish installing ChatGPT. Ollama will connect it automatically.", + ), + ), + ).toHaveLength(1); + } finally { + await act(async () => renderer?.unmount()); + } + }); + + it("matches Claude's connecting state", async () => { + let finishConnect!: (result: { status: CodexDesktopStatus }) => void; + const connect = new Promise<{ status: CodexDesktopStatus }>((resolve) => { + finishConnect = resolve; + }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + setCodexDesktopConnected: vi.fn().mockReturnValue(connect), + }); + + let renderer; + try { + await act(async () => { + renderer = create( + , + ); + }); + const toggle = renderer!.root.findByProps({ role: "switch" }); + await act(async () => { + toggle.props.onClick(); + await Promise.resolve(); + }); + + expect(toggle.props["aria-checked"]).toBe(true); + expect(toggle.props["aria-busy"]).toBe(true); + expect(toggle.props.disabled).toBe(true); + expect(renderer!.root.findByProps({ role: "status" }).children).toContain( + "Connecting…", + ); + expect( + renderer!.root.findAll((node) => + node.children.includes("Connecting ChatGPT to Ollama…"), + ), + ).toHaveLength(1); + + await act(async () => { + finishConnect({ status: status({ connected: true }) }); + await connect; + }); + } finally { + await act(async () => renderer?.unmount()); + } + }); + + it("opens the installer and connects after ChatGPT is detected", async () => { + const installedStatus = status({ installed: true }); + const connectedStatus = status({ + installed: true, + connected: true, + models: ["glm-5.3-flash:cloud"], + }); + const openInstaller = vi.fn().mockResolvedValue("opened"); + const getStatus = vi.fn().mockResolvedValue(installedStatus); + const connect = vi.fn().mockResolvedValue({ status: connectedStatus }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + setInterval: globalThis.setInterval, + clearInterval: globalThis.clearInterval, + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + getCodexDesktopStatus: getStatus, + setCodexDesktopConnected: connect, + installCodexDesktop: openInstaller, + }); + + let renderer; + try { + await act(async () => { + renderer = create( + , + ); + }); + const toggle = renderer!.root.findByProps({ + "aria-label": "Add Ollama models to ChatGPT", + }); + await act(async () => { + await toggle.props.onClick(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(openInstaller).toHaveBeenCalledOnce(); + expect(getStatus).toHaveBeenCalled(); + expect(connect).toHaveBeenCalledWith(true, false); + expect( + renderer!.root.findByProps({ + "aria-label": "Remove Ollama models from ChatGPT", + }).props["aria-checked"], + ).toBe(true); + expect(renderer!.root.findByProps({ role: "status" }).children).toContain( + "Ollama models added alongside Codex models", + ); + } finally { + await act(async () => renderer?.unmount()); + } + }); + + it("does not restart ChatGPT automatically when installation detection finds it running", async () => { + const installedAndRunning = status({ installed: true, running: true }); + const connect = vi.fn(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + setInterval: globalThis.setInterval, + clearInterval: globalThis.clearInterval, + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + getCodexDesktopStatus: vi.fn().mockResolvedValue(installedAndRunning), + setCodexDesktopConnected: connect, + installCodexDesktop: vi.fn().mockResolvedValue("opened"), + }); + + let renderer; + try { + await act(async () => { + renderer = create( + , + ); + }); + const toggle = renderer!.root.findByProps({ + "aria-label": "Add Ollama models to ChatGPT", + }); + await act(async () => { + await toggle.props.onClick(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(connect).not.toHaveBeenCalled(); + expect(renderer!.root.findByProps({ role: "alert" }).children).toContain( + "ChatGPT is installed. Turn on the switch to restart it with Ollama models.", + ); + expect( + renderer!.root.findByProps({ + "aria-label": "Add Ollama models to ChatGPT", + }).props["aria-checked"], + ).toBe(false); + } finally { + await act(async () => renderer?.unmount()); + } + }); + + it("returns to the disconnected state when installation is cancelled", async () => { + const getStatus = vi.fn(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + getCodexDesktopStatus: getStatus, + setCodexDesktopConnected: vi.fn(), + installCodexDesktop: vi.fn().mockResolvedValue("cancelled"), + }); + + let renderer; + try { + await act(async () => { + renderer = create( + , + ); + }); + const toggle = renderer!.root.findByProps({ + "aria-label": "Add Ollama models to ChatGPT", + }); + await act(async () => { + await toggle.props.onClick(); + }); + + expect(getStatus).not.toHaveBeenCalled(); + expect(toggle.props["aria-checked"]).toBe(false); + expect(toggle.props.disabled).toBe(false); + expect(renderer!.root.findAllByProps({ role: "alert" })).toHaveLength(0); + } finally { + await act(async () => renderer?.unmount()); + } + }); + + it("uses concise restart copy when adding Ollama models", async () => { + const confirm = vi.fn(() => false); + const runningStatus = status({ running: true }); + const connect = vi.fn().mockResolvedValue({ + status: runningStatus, + restartConfirmationRequired: true, + }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + setCodexDesktopConnected: connect, + confirm, + }); + + let renderer; + try { + await act(async () => { + renderer = create( + , + ); + }); + const toggle = renderer!.root.findByProps({ + "aria-label": "Add Ollama models to ChatGPT", + }); + await act(async () => { + await toggle.props.onClick(); + }); + + expect(confirm).toHaveBeenCalledWith( + "Restart ChatGPT to add Ollama models? Any running task will stop.", + ); + expect(connect).toHaveBeenCalledOnce(); + expect(connect).toHaveBeenCalledWith(true, false); + } finally { + await act(async () => renderer?.unmount()); + } + }); + + it("adds Ollama models after the restart is confirmed", async () => { + const confirm = vi.fn(() => true); + const connectedStatus = status({ + connected: true, + running: true, + models: ["glm-5.3-flash:cloud"], + }); + const connect = vi + .fn() + .mockResolvedValueOnce({ + status: status({ running: true }), + restartConfirmationRequired: true, + }) + .mockResolvedValueOnce({ status: connectedStatus }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + setInterval: globalThis.setInterval, + clearInterval: globalThis.clearInterval, + setCodexDesktopConnected: connect, + confirm, + }); + + let renderer; + try { + await act(async () => { + renderer = create( + , + ); + }); + const toggle = renderer!.root.findByProps({ + "aria-label": "Add Ollama models to ChatGPT", + }); + await act(async () => { + await toggle.props.onClick(); + }); + + expect(confirm).toHaveBeenCalledWith( + "Restart ChatGPT to add Ollama models? Any running task will stop.", + ); + expect(connect).toHaveBeenNthCalledWith(1, true, false); + expect(connect).toHaveBeenNthCalledWith(2, true, true); + expect( + renderer!.root.findByProps({ + "aria-label": "Remove Ollama models from ChatGPT", + }).props["aria-checked"], + ).toBe(true); + } finally { + await act(async () => renderer?.unmount()); + } + }); + + it("keeps a native restart failure visible after confirmation", async () => { + let onFocus: (() => void) | undefined; + const addEventListener = vi.fn((event: string, handler: () => void) => { + if (event === "focus") onFocus = handler; + }); + const getStatus = vi.fn().mockResolvedValue(status({ running: true })); + const confirm = vi.fn(() => { + onFocus?.(); + return true; + }); + const connect = vi + .fn() + .mockResolvedValueOnce({ + status: status({ running: true }), + restartConfirmationRequired: true, + }) + .mockResolvedValueOnce({ + status: status({ running: true }), + error: "quit ChatGPT: timed out waiting for ChatGPT to exit", + }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", { + addEventListener, + removeEventListener: vi.fn(), + getCodexDesktopStatus: getStatus, + setCodexDesktopConnected: connect, + confirm, + }); + + let renderer; + try { + await act(async () => { + renderer = create( + , + ); + }); + const toggle = renderer!.root.findByProps({ + "aria-label": "Add Ollama models to ChatGPT", + }); + await act(async () => { + await toggle.props.onClick(); + }); + + expect(getStatus).not.toHaveBeenCalled(); + expect(renderer!.root.findByProps({ role: "alert" }).children).toContain( + "quit ChatGPT: timed out waiting for ChatGPT to exit", + ); + expect(toggle.props["aria-checked"]).toBe(false); + } finally { + await act(async () => renderer?.unmount()); + } + }); + + it("allows the normal profile to be restored if ChatGPT is removed", async () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('aria-label="Remove Ollama models from ChatGPT"'); + expect(html).not.toContain('disabled=""'); + + const restore = vi.fn().mockResolvedValue({ + status: status({ installed: false, connected: false }), + }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + setCodexDesktopConnected: restore, + confirm: vi.fn(() => true), + }); + + let renderer; + try { + await act(async () => { + renderer = create( + , + ); + }); + const restoreButton = renderer!.root.findByProps({ + "aria-label": "Remove Ollama models from ChatGPT", + }); + await act(async () => { + await restoreButton.props.onClick(); + }); + + expect(restore).toHaveBeenCalledWith(false, false); + } finally { + await act(async () => renderer?.unmount()); + } + }); +}); diff --git a/app/ui/app/src/components/CodexDesktopRow.tsx b/app/ui/app/src/components/CodexDesktopRow.tsx new file mode 100644 index 000000000..b0283c875 --- /dev/null +++ b/app/ui/app/src/components/CodexDesktopRow.tsx @@ -0,0 +1,387 @@ +import type { IntegrationStatus } from "@/api"; +import { INTEGRATION_ICONS } from "@/lib/launchCommands"; +import type { + CodexDesktopActionResult, + CodexDesktopInstallResult, + CodexDesktopStatus, +} from "@/types/webview"; +import { ArrowPathIcon, CommandLineIcon } from "@heroicons/react/24/outline"; +import { useCallback, useEffect, useRef, useState } from "react"; + +export const CODEX_DESKTOP_INSTALL_TIMEOUT_MS = 120_000; + +type CodexConnectPhase = + | "idle" + | "installing" + | "waiting-for-install" + | "connecting" + | "disconnecting"; + +interface CodexDesktopRowProps { + integration: IntegrationStatus; + initialStatus?: CodexDesktopStatus; +} + +function CodexIcon({ integration }: { integration: IntegrationStatus }) { + const icon = INTEGRATION_ICONS[integration.id]; + return ( +
+ {icon ? ( + <> + + {icon.darkSrc && ( + + )} + + ) : ( + + )} +
+ ); +} + +function codexDesktopDescription( + status: CodexDesktopStatus | null, + defaultDescription: string, +): string { + if (!status?.connected) return defaultDescription; + const requestCount = status.requests ?? 0; + return `${requestCount} Ollama ${requestCount === 1 ? "request" : "requests"} this session`; +} + +export function CodexDesktopRow({ + integration, + initialStatus, +}: CodexDesktopRowProps) { + const [status, setStatus] = useState( + initialStatus ?? null, + ); + const [phase, setPhase] = useState("idle"); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const mounted = useRef(true); + const operationInFlight = useRef(false); + + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + const refreshStatus = useCallback(async () => { + if (operationInFlight.current || !window.getCodexDesktopStatus) return; + try { + const next = await window.getCodexDesktopStatus(); + setStatus(next); + setError(null); + setNotice(null); + } catch { + setError("Ollama could not read the ChatGPT connection status."); + } + }, []); + + useEffect(() => { + if (!initialStatus) void refreshStatus(); + const onFocus = () => void refreshStatus(); + window.addEventListener("focus", onFocus); + return () => window.removeEventListener("focus", onFocus); + }, [initialStatus, refreshStatus]); + + useEffect(() => { + if (!status?.connected || !window.getCodexDesktopRequestCount) return; + + let active = true; + let checking = false; + const refreshRequestCount = async () => { + if (!active || checking || document.visibilityState === "hidden") return; + checking = true; + try { + const requests = await window.getCodexDesktopRequestCount?.(); + if (!active || requests === undefined) return; + setStatus((current) => { + if (!current || current.requests === requests) return current; + return { ...current, requests }; + }); + } catch { + // The next interval or window-focus refresh can recover the count. + } finally { + checking = false; + } + }; + + void refreshRequestCount(); + const interval = window.setInterval(refreshRequestCount, 1000); + return () => { + active = false; + window.clearInterval(interval); + }; + }, [status?.connected]); + + useEffect(() => { + if (phase !== "waiting-for-install") return; + + let active = true; + let checking = false; + let completing = false; + const checkForInstall = async () => { + if ( + !active || + checking || + completing || + !window.getCodexDesktopStatus || + !window.setCodexDesktopConnected + ) { + return; + } + checking = true; + try { + const next = await window.getCodexDesktopStatus(); + if (!active || !mounted.current) return; + setStatus(next); + if (!next.installed) return; + completing = true; + + if (next.running) { + setPhase("idle"); + setError( + "ChatGPT is installed. Turn on the switch to restart it with Ollama models.", + ); + return; + } + + setPhase("connecting"); + const result = await window.setCodexDesktopConnected(true, false); + if (!mounted.current) return; + setStatus(result.status); + if (result.restartConfirmationRequired) { + setError( + "ChatGPT is installed. Turn on the switch to restart it with Ollama models.", + ); + } else if (result.error || !result.status.connected) { + setError( + result.error || "Ollama could not add its models to ChatGPT.", + ); + } else { + setNotice("Ollama models added alongside Codex models"); + } + setPhase("idle"); + } catch { + if (!mounted.current) return; + setPhase("idle"); + setError("Ollama could not finish connecting ChatGPT."); + } finally { + checking = false; + } + }; + + void checkForInstall(); + const interval = window.setInterval(checkForInstall, 1000); + const timeout = window.setTimeout(() => { + if (!active || completing) return; + setPhase("idle"); + setError("ChatGPT installation wasn’t detected. Try again."); + }, CODEX_DESKTOP_INSTALL_TIMEOUT_MS); + return () => { + active = false; + window.clearInterval(interval); + window.clearTimeout(timeout); + }; + }, [phase]); + + const connected = status?.connected ?? false; + const installed = status?.installed ?? integration.installed ?? false; + const pending = phase !== "idle"; + const displayedConnected = + phase === "disconnecting" + ? false + : connected || + phase === "installing" || + phase === "waiting-for-install" || + phase === "connecting"; + const isConnecting = phase !== "idle"; + const statusLabel = + phase === "installing" + ? "Downloading…" + : phase === "waiting-for-install" + ? "Finish installing…" + : phase === "connecting" + ? "Connecting…" + : phase === "disconnecting" + ? "Disconnecting…" + : !connected && !installed + ? "Download & connect" + : null; + const description = + error ?? + notice ?? + (phase === "installing" + ? "Ollama is downloading the ChatGPT installer…" + : phase === "waiting-for-install" + ? "Finish installing ChatGPT. Ollama will connect it automatically." + : phase === "connecting" + ? "Connecting ChatGPT to Ollama…" + : phase === "disconnecting" + ? "Restoring ChatGPT’s usual connection…" + : codexDesktopDescription(status, integration.description)); + + const toggleConnection = async () => { + if (pending || operationInFlight.current) return; + if (!window.setCodexDesktopConnected) { + setError("The ChatGPT integration is unavailable."); + return; + } + + const enabled = !connected; + if (enabled && !installed) { + if (!window.installCodexDesktop || !window.getCodexDesktopStatus) { + setError("Ollama could not install ChatGPT."); + return; + } + setPhase("installing"); + setError(null); + setNotice(null); + let installResult: CodexDesktopInstallResult = "failed"; + try { + installResult = await window.installCodexDesktop(); + } catch { + // The shared failure message below covers a rejected native request. + } + if (installResult === "cancelled") { + setPhase("idle"); + return; + } + if (installResult !== "opened") { + setPhase("idle"); + setError("Ollama could not install ChatGPT."); + return; + } + setPhase("waiting-for-install"); + return; + } + + const nextPhase = enabled ? "connecting" : "disconnecting"; + operationInFlight.current = true; + setPhase(nextPhase); + setError(null); + setNotice(null); + try { + let result: CodexDesktopActionResult = + await window.setCodexDesktopConnected(enabled, false); + + setStatus(result.status); + if (result.restartConfirmationRequired) { + // Keep focus-driven status refreshes from discarding this operation + // while the native confirmation dialog temporarily owns focus. + if ( + !window.confirm( + enabled + ? "Restart ChatGPT to add Ollama models? Any running task will stop." + : "Restart ChatGPT to remove Ollama models? Any running task will stop.", + ) + ) { + return; + } + setPhase(nextPhase); + result = await window.setCodexDesktopConnected(enabled, true); + setStatus(result.status); + } + + if (result.error) { + setError(result.error); + return; + } + if (result.status.connected !== enabled) { + setError( + enabled + ? "Ollama could not add its models to ChatGPT." + : "Ollama could not remove its models from ChatGPT.", + ); + return; + } + setNotice( + enabled + ? "Ollama models added alongside Codex models" + : "Ollama models removed · Codex models remain available", + ); + } catch { + setError( + enabled + ? "Ollama could not add its models to ChatGPT." + : "Ollama could not remove its models from ChatGPT.", + ); + } finally { + operationInFlight.current = false; + if (mounted.current) setPhase("idle"); + } + }; + + return ( +
+
+ +
+

+ ChatGPT (Desktop) +

+

+ {description} +

+
+
+
+ {statusLabel && ( + + {isConnecting && ( + + )} + {statusLabel} + + )} + +
+
+ ); +} diff --git a/app/ui/app/src/components/Onboarding.test.tsx b/app/ui/app/src/components/Onboarding.test.tsx index ff043f12a..4e326ecae 100644 --- a/app/ui/app/src/components/Onboarding.test.tsx +++ b/app/ui/app/src/components/Onboarding.test.tsx @@ -62,7 +62,7 @@ describe("Onboarding", () => { } }); - it("hides the Claude application on Windows", () => { + it("hides the Claude and ChatGPT desktop integrations on Windows", () => { vi.stubGlobal("window", { OLLAMA_PLATFORM: "windows", innerHeight: 660, @@ -85,12 +85,21 @@ describe("Onboarding", () => { description: "Anthropic's coding tool with subagents", command: "ollama launch claude", }, + { + id: "chatgpt", + name: "ChatGPT", + description: "Use Ollama models in ChatGPT", + installed: true, + command: "ollama launch chatgpt", + }, ]} />, ); expect(html).not.toContain('id="desktop-heading"'); expect(html).not.toContain("Use Ollama models in Claude Desktop"); + expect(html).not.toContain("Use Ollama models in ChatGPT"); + expect(html).not.toContain("ollama launch chatgpt"); expect(html).toContain('id="terminal-heading"'); expect(html).toContain("ollama launch claude"); } finally { @@ -214,7 +223,10 @@ describe("Onboarding", () => { await Promise.resolve(); }); - const claudeSwitch = () => renderer!.root.findByProps({ role: "switch" }); + const claudeSwitch = () => + renderer!.root + .findAllByProps({ role: "switch" }) + .find((node) => String(node.props["aria-label"]).endsWith("Claude"))!; let clickResult!: Promise; await act(async () => { clickResult = claudeSwitch().props.onClick(); @@ -226,9 +238,11 @@ describe("Onboarding", () => { expect(claudeSwitch().props["aria-busy"]).toBe(true); expect(claudeSwitch().props.disabled).toBe(true); expect(claudeSwitch().props.className).toContain("disabled:opacity-50"); - expect(renderer.root.findByProps({ role: "status" }).children).toContain( - "Downloading…", - ); + expect( + renderer.root + .findAllByProps({ role: "status" }) + .some((node) => node.children.includes("Downloading…")), + ).toBe(true); expect( renderer.root.findAll( (node) => @@ -247,9 +261,11 @@ describe("Onboarding", () => { expect(claudeSwitch().props["aria-busy"]).toBe(true); expect(claudeSwitch().props.disabled).toBe(true); expect(claudeSwitch().props.className).toContain("disabled:opacity-50"); - expect(renderer.root.findByProps({ role: "status" }).children).toContain( - "Finish installing…", - ); + expect( + renderer.root + .findAllByProps({ role: "status" }) + .some((node) => node.children.includes("Finish installing…")), + ).toBe(true); expect( renderer.root.findAll( (node) => @@ -333,7 +349,10 @@ describe("Onboarding", () => { await Promise.resolve(); }); - const claudeSwitch = () => renderer!.root.findByProps({ role: "switch" }); + const claudeSwitch = () => + renderer!.root + .findAllByProps({ role: "switch" }) + .find((node) => String(node.props["aria-label"]).endsWith("Claude"))!; expect(claudeSwitch().props["aria-checked"]).toBe(false); expect(claudeSwitch().props["aria-busy"]).toBeUndefined(); expect(claudeSwitch().props.disabled).toBe(false); @@ -470,7 +489,7 @@ describe("Onboarding", () => { const integrations: IntegrationStatuses = [ { id: "claude-desktop", - name: "Claude", + name: "Claude Code (Desktop)", description: "Use Ollama models in Claude Desktop", installed: true, action: "connect", @@ -485,7 +504,7 @@ describe("Onboarding", () => { }, { id: "codex", - name: "Codex", + name: "Codex CLI", description: "OpenAI's open-source coding agent", installed: true, action: "copy", @@ -550,9 +569,10 @@ describe("Onboarding", () => { expect(html).not.toContain( "Connect Claude, or copy a command to run in your terminal.", ); - expect(html).toContain("Claude"); + expect(html).toContain("Claude Code (Desktop)"); expect(html).toContain("Use Ollama models in Claude Desktop"); expect(html).toContain("Claude Code"); + expect(html).toContain("Codex CLI"); expect(html).not.toContain("Search apps"); expect(html).not.toContain('type="search"'); expect(html).toContain("Desktop"); @@ -566,18 +586,18 @@ describe("Onboarding", () => { expect(html).not.toContain(">Command"); expect(html).toContain("ollama launch claude"); expect(html).not.toContain("Installed"); - expect(html).not.toContain("Not installed"); + expect(html).toContain("Use Ollama models in ChatGPT"); expect(html).toContain('aria-label="Connect Claude"'); expect(html).toContain('role="switch"'); expect(html).toContain('aria-checked="false"'); expect(html).not.toContain("Inactive"); - expect(html).not.toContain("Download & connect"); + expect(html).toContain("Download & connect"); expect(html).not.toContain("Active"); expect(html).toContain("bg-transparent"); expect(html).toContain('aria-label="Copy OpenCode command"'); expect(html).toContain('aria-label="Copy Terminal command"'); expect(html).not.toContain(">Copy command"); - expect(html).not.toContain("ChatGPT"); + expect(html).toContain("ChatGPT (Desktop)"); expect(html).toContain("OpenCode"); expect(html).toContain("Terminal"); expect(html).toContain("overflow-y-auto"); @@ -587,6 +607,7 @@ describe("Onboarding", () => { expect(html).not.toContain("inert"); expect(html).toContain("/launch-icons/claude.svg"); expect(html).toContain("/launch-icons/claude-code.svg"); + expect(html).toContain("/launch-icons/codex-color.svg"); expect(html).toMatch( /src="\/launch-icons\/cline\.svg"[^>]*class="[^"]*dark:invert/, ); @@ -605,6 +626,40 @@ describe("Onboarding", () => { expect(html).not.toContain('viewBox="0 0 3400 3400"'); }); + it("places ChatGPT directly below Claude instead of in Terminal", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html.indexOf("Use Ollama models in Claude Desktop")).toBeLessThan( + html.indexOf(">ChatGPT (Desktop)

"), + ); + expect(html).toContain('aria-label="Add Ollama models to ChatGPT"'); + expect(html).not.toContain('aria-label="Copy ChatGPT command"'); + expect(html).toContain('aria-label="Copy Codex CLI command"'); + }); + it("keeps connected Claude in Desktop without an idle status", () => { const html = renderToStaticMarkup( { }, { id: "codex", - name: "Codex", + name: "Codex CLI", description: "OpenAI's open-source coding agent", installed: true, action: "copy", @@ -757,8 +812,11 @@ describe("Onboarding", () => { expect(html).toContain('aria-label="Connect Claude"'); expect(html).toContain("Download & connect"); expect(html).not.toContain("Inactive"); - expect(html).not.toContain("Not installed"); - expect(html).not.toContain('disabled=""'); + const claudeButton = html.match( + /]*aria-label="Connect Claude"[^>]*>/, + )?.[0]; + expect(claudeButton).toBeDefined(); + expect(claudeButton).not.toContain('disabled=""'); }); it("uses branded icons for the remaining launcher integrations", () => { diff --git a/app/ui/app/src/components/Onboarding.tsx b/app/ui/app/src/components/Onboarding.tsx index 0941865be..976d0672a 100644 --- a/app/ui/app/src/components/Onboarding.tsx +++ b/app/ui/app/src/components/Onboarding.tsx @@ -1,4 +1,5 @@ import CopyButton from "@/components/CopyButton"; +import { CodexDesktopRow } from "@/components/CodexDesktopRow"; import Logo from "@/components/Logo"; import { nextOnboardingStep, type OnboardingStep } from "@/lib/onboarding"; import { @@ -22,6 +23,7 @@ import { isWindowsPlatform } from "@/lib/platform"; import type { ClaudeDesktopActionResult, ClaudeDesktopStatus, + CodexDesktopStatus, } from "@/types/webview"; import { copyTextToClipboard } from "@/utils/clipboard"; import { @@ -96,6 +98,7 @@ interface RunOllamaScreenProps { interface ConnectAppsScreenProps { initialIntegrations?: IntegrationStatuses; initialClaudeStatus?: ClaudeDesktopStatus; + initialCodexStatus?: CodexDesktopStatus; } function TitleBar({ onSignIn }: { onSignIn?: () => void }) { @@ -432,6 +435,7 @@ export function ClaudeConnectedIntro({ onDone }: { onDone: () => void }) { export function ConnectAppsScreen({ initialIntegrations, initialClaudeStatus, + initialCodexStatus, }: ConnectAppsScreenProps) { const isWindows = isWindowsPlatform(); const [copiedCommand, setCopiedCommand] = useState(null); @@ -888,9 +892,18 @@ export function ConnectAppsScreen({ const claudeIntegration = isWindows ? undefined : integrationStatuses?.find((item) => item.id === "claude-desktop"); + const codexIntegration = isWindows + ? undefined + : (integrationStatuses?.find((item) => item.id === "chatgpt") ?? { + id: "chatgpt", + name: "ChatGPT (Desktop)", + description: "Use Ollama models in ChatGPT", + installed: false, + }); const launchIntegrations = integrationStatuses?.filter( - (item) => item.id !== "claude-desktop" && item.command, + (item) => + item.id !== "claude-desktop" && item.id !== "chatgpt" && item.command, ) ?? []; const claudeConnected = claudeStatus?.connected ?? false; const claudeConfigured = claudeStatus @@ -1048,7 +1061,7 @@ export function ConnectAppsScreen({
{integrationStatuses ? (
- {claudeIntegration && ( + {(claudeIntegration || codexIntegration) && (

Desktop

-
+
{claudeRow} + {codexIntegration && ( + + )}
)} @@ -1078,11 +1097,13 @@ export function ConnectAppsScreen({ )} - {!claudeIntegration && launchIntegrations.length === 0 && ( -

- No apps found. -

- )} + {!claudeIntegration && + !codexIntegration && + launchIntegrations.length === 0 && ( +

+ No apps found. +

+ )}
) : statusError ? (

diff --git a/app/ui/app/src/components/Settings.interaction.test.tsx b/app/ui/app/src/components/Settings.interaction.test.tsx index f6683a6bd..7479fdddd 100644 --- a/app/ui/app/src/components/Settings.interaction.test.tsx +++ b/app/ui/app/src/components/Settings.interaction.test.tsx @@ -7,10 +7,12 @@ import Settings from "./Settings"; const mocks = vi.hoisted(() => ({ resetClaudeMappings: vi.fn(), + resetChatGPTModels: vi.fn(), updateSettings: vi.fn(), updateCloudSetting: vi.fn(), setShowAppsInMenu: vi.fn(), refetchUser: vi.fn(), + disconnectUser: vi.fn(), isWindows: false, queryClient: { cancelQueries: vi.fn().mockResolvedValue(undefined), @@ -32,16 +34,32 @@ vi.mock("@/components/ClaudeDesktopModelsSettings", () => ({ ), })); +vi.mock("@/components/CodexDesktopModelsSettings", () => ({ + CodexDesktopModelsSettings: forwardRef( + function MockCodexDesktopSettings(_props, ref) { + useImperativeHandle(ref, () => ({ + resetToDefaults: mocks.resetChatGPTModels, + })); + return

; + }, + ), +})); + vi.mock("@/hooks/useUser", () => ({ useUser: () => ({ - user: { name: "Paid user", email: "paid@example.com", plan: "pro" }, + user: { + id: "paid-user-id", + name: "Paid user", + email: "paid@example.com", + plan: "pro", + }, isAuthenticated: true, refreshUser: vi.fn(), isRefreshing: false, refetchUser: mocks.refetchUser, fetchConnectUrl: vi.fn(), isLoading: false, - disconnectUser: vi.fn(), + disconnectUser: mocks.disconnectUser, }), })); @@ -150,6 +168,8 @@ describe("Settings reset interactions", () => { source: "none", }); mocks.setShowAppsInMenu.mockResolvedValue(undefined); + mocks.resetChatGPTModels.mockResolvedValue(true); + mocks.disconnectUser.mockResolvedValue(undefined); vi.stubGlobal("window", { addEventListener: vi.fn(), @@ -160,6 +180,7 @@ describe("Settings reset interactions", () => { setShowAppsInMenu: mocks.setShowAppsInMenu, open: vi.fn(), confirm: vi.fn(() => true), + location: { reload: vi.fn() }, OLLAMA_TOOLS: false, }); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); @@ -193,6 +214,7 @@ describe("Settings reset interactions", () => { expect(settingsFieldset.props["aria-busy"]).toBe(true); expect(textContent(resetButton)).toContain("Resetting…"); expect(renderer!.root.findAllByType(Badge)).toHaveLength(0); + expect(mocks.resetChatGPTModels).toHaveBeenCalledOnce(); await act(async () => { pendingClaudeReset.resolve(true); @@ -212,7 +234,7 @@ describe("Settings reset interactions", () => { } }); - it("hides Claude Desktop settings and skips its reset on Windows", async () => { + it("hides Claude and ChatGPT desktop settings on Windows", async () => { mocks.isWindows = true; let renderer; @@ -225,6 +247,9 @@ describe("Settings reset interactions", () => { expect( renderer!.root.findAllByProps({ "aria-label": "Claude settings" }), ).toHaveLength(0); + expect( + renderer!.root.findAllByProps({ "aria-label": "ChatGPT settings" }), + ).toHaveLength(0); const resetButton = renderer!.root .findAllByType("button") @@ -237,6 +262,37 @@ describe("Settings reset interactions", () => { }); expect(mocks.resetClaudeMappings).not.toHaveBeenCalled(); + expect(mocks.resetChatGPTModels).not.toHaveBeenCalled(); + } finally { + await act(async () => { + renderer?.unmount(); + await Promise.resolve(); + }); + vi.unstubAllGlobals(); + } + }); + + it("reloads Settings after signing out", async () => { + let renderer; + try { + await act(async () => { + renderer = create(); + await Promise.resolve(); + }); + + const signOutButton = renderer!.root + .findAllByType("button") + .find((button) => textContent(button) === "Sign out"); + if (!signOutButton) throw new Error("Sign out button not found"); + + await act(async () => { + signOutButton.props.onClick(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mocks.disconnectUser).toHaveBeenCalledOnce(); + expect(window.location.reload).toHaveBeenCalledOnce(); } finally { await act(async () => { renderer?.unmount(); diff --git a/app/ui/app/src/components/Settings.test.ts b/app/ui/app/src/components/Settings.test.ts index a802ca8c9..67fc8b49c 100644 --- a/app/ui/app/src/components/Settings.test.ts +++ b/app/ui/app/src/components/Settings.test.ts @@ -23,6 +23,7 @@ describe("Settings defaults", () => { const updateSettings = vi.fn(() => settingsUpdate.promise); const updateCloud = vi.fn().mockResolvedValue(undefined); const updateShowAppsInMenu = vi.fn().mockResolvedValue(undefined); + const resetChatGPTModels = vi.fn().mockResolvedValue(true); let resolveClaudeReset!: (succeeded: boolean) => void; const claudeReset = new Promise((resolve) => { resolveClaudeReset = resolve; @@ -34,6 +35,7 @@ describe("Settings defaults", () => { updateSettings, updateCloud, updateShowAppsInMenu, + resetChatGPTModels, resetClaudeMappings, currentSettings: currentSettings({ Expose: true, @@ -47,6 +49,7 @@ describe("Settings defaults", () => { await vi.waitFor(() => expect(updateSettings).toHaveBeenCalledOnce()); expect(updateCloud).toHaveBeenCalledWith(true); expect(updateShowAppsInMenu).not.toHaveBeenCalled(); + expect(resetChatGPTModels).not.toHaveBeenCalled(); expect(resetClaudeMappings).not.toHaveBeenCalled(); expect(onSaved).not.toHaveBeenCalled(); @@ -60,6 +63,7 @@ describe("Settings defaults", () => { settingsUpdate.resolve(); await vi.waitFor(() => expect(resetClaudeMappings).toHaveBeenCalledOnce()); expect(updateShowAppsInMenu).toHaveBeenCalledWith(true); + expect(resetChatGPTModels).toHaveBeenCalledOnce(); expect(onSaved).not.toHaveBeenCalled(); resolveClaudeReset(true); @@ -73,6 +77,9 @@ describe("Settings defaults", () => { updateShowAppsInMenu.mock.invocationCallOrder[0], ); expect(updateShowAppsInMenu.mock.invocationCallOrder[0]).toBeLessThan( + resetChatGPTModels.mock.invocationCallOrder[0], + ); + expect(resetChatGPTModels.mock.invocationCallOrder[0]).toBeLessThan( resetClaudeMappings.mock.invocationCallOrder[0], ); expect(resetClaudeMappings.mock.invocationCallOrder[0]).toBeLessThan( @@ -88,6 +95,7 @@ describe("Settings defaults", () => { updateSettings: vi.fn().mockResolvedValue(undefined), updateCloud, updateShowAppsInMenu, + resetChatGPTModels: vi.fn().mockResolvedValue(true), resetClaudeMappings: vi.fn().mockResolvedValue(true), currentSettings: currentSettings(), currentShowAppsInMenu: true, @@ -110,6 +118,7 @@ describe("Settings defaults", () => { updateSettings: vi.fn().mockResolvedValue(undefined), updateCloud, updateShowAppsInMenu: vi.fn().mockResolvedValue(undefined), + resetChatGPTModels: vi.fn().mockResolvedValue(true), resetClaudeMappings: vi.fn().mockResolvedValue(true), currentSettings: currentSettings(), currentShowAppsInMenu: true, @@ -131,6 +140,7 @@ describe("Settings defaults", () => { updateSettings: vi.fn().mockResolvedValue(undefined), updateCloud, updateShowAppsInMenu: vi.fn().mockResolvedValue(undefined), + resetChatGPTModels: vi.fn().mockResolvedValue(true), resetClaudeMappings: vi.fn().mockResolvedValue(true), currentSettings: currentSettings(), currentShowAppsInMenu: true, @@ -144,6 +154,7 @@ describe("Settings defaults", () => { it("restores Cloud and leaves Claude untouched when settings fail", async () => { const updateCloud = vi.fn().mockResolvedValue(undefined); const updateShowAppsInMenu = vi.fn().mockResolvedValue(undefined); + const resetChatGPTModels = vi.fn().mockResolvedValue(true); const resetClaudeMappings = vi.fn().mockResolvedValue(true); const onSaved = vi.fn(); @@ -152,6 +163,7 @@ describe("Settings defaults", () => { updateSettings: vi.fn().mockRejectedValue(new Error("restart failed")), updateCloud, updateShowAppsInMenu, + resetChatGPTModels, resetClaudeMappings, currentSettings: currentSettings({ Expose: true, @@ -164,6 +176,7 @@ describe("Settings defaults", () => { ).rejects.toThrow("restart failed"); expect(updateCloud.mock.calls).toEqual([[true], [false]]); + expect(resetChatGPTModels).not.toHaveBeenCalled(); expect(resetClaudeMappings).not.toHaveBeenCalled(); expect(updateShowAppsInMenu).not.toHaveBeenCalled(); expect(onSaved).not.toHaveBeenCalled(); @@ -178,6 +191,7 @@ describe("Settings defaults", () => { updateSettings: vi.fn().mockResolvedValue(undefined), updateCloud: vi.fn().mockRejectedValue(new Error("cloud failed")), updateShowAppsInMenu, + resetChatGPTModels: vi.fn().mockResolvedValue(true), resetClaudeMappings: vi.fn().mockResolvedValue(true), currentSettings: currentSettings(), currentShowAppsInMenu: true, @@ -205,6 +219,7 @@ describe("Settings defaults", () => { updateSettings, updateCloud, updateShowAppsInMenu, + resetChatGPTModels: vi.fn().mockResolvedValue(true), resetClaudeMappings: vi.fn().mockResolvedValue(false), currentSettings: previousSettings, currentShowAppsInMenu: false, @@ -219,4 +234,33 @@ describe("Settings defaults", () => { expect(updateShowAppsInMenu.mock.calls).toEqual([[true], [false]]); expect(onSaved).not.toHaveBeenCalled(); }); + + it("stops before Claude and rolls settings back when ChatGPT cannot be reset", async () => { + const onSaved = vi.fn(); + const updateSettings = vi.fn().mockResolvedValue(undefined); + const updateCloud = vi.fn().mockResolvedValue(undefined); + const updateShowAppsInMenu = vi.fn().mockResolvedValue(undefined); + const resetClaudeMappings = vi.fn().mockResolvedValue(true); + const previousSettings = currentSettings({ Expose: true }); + + await expect( + applySettingsDefaults({ + updateSettings, + updateCloud, + updateShowAppsInMenu, + resetChatGPTModels: vi.fn().mockResolvedValue(false), + resetClaudeMappings, + currentSettings: previousSettings, + currentShowAppsInMenu: false, + cloudSource: "config", + onSaved, + }), + ).rejects.toThrow("ChatGPT models could not be reset"); + + expect(resetClaudeMappings).not.toHaveBeenCalled(); + expect(updateCloud.mock.calls).toEqual([[true], [false]]); + expect(updateSettings.mock.calls[1][0]).toBe(previousSettings); + expect(updateShowAppsInMenu.mock.calls).toEqual([[true], [false]]); + expect(onSaved).not.toHaveBeenCalled(); + }); }); diff --git a/app/ui/app/src/components/Settings.tsx b/app/ui/app/src/components/Settings.tsx index 757754885..60a83fac0 100644 --- a/app/ui/app/src/components/Settings.tsx +++ b/app/ui/app/src/components/Settings.tsx @@ -10,6 +10,10 @@ import { ClaudeDesktopModelsSettings, type ClaudeDesktopModelsSettingsHandle, } from "@/components/ClaudeDesktopModelsSettings"; +import { + CodexDesktopModelsSettings, + type CodexDesktopModelsSettingsHandle, +} from "@/components/CodexDesktopModelsSettings"; import { WifiIcon, FolderIcon, @@ -55,6 +59,7 @@ interface SettingsDefaultsActions { updateSettings: (settings: SettingsType) => Promise; updateCloud: (enabled: boolean) => Promise; updateShowAppsInMenu: (visible: boolean) => Promise; + resetChatGPTModels: () => Promise; resetClaudeMappings: () => Promise; currentSettings: SettingsType; currentShowAppsInMenu: boolean; @@ -74,6 +79,7 @@ export async function applySettingsDefaults({ updateSettings, updateCloud, updateShowAppsInMenu, + resetChatGPTModels, resetClaudeMappings, currentSettings, currentShowAppsInMenu, @@ -105,8 +111,12 @@ export async function applySettingsDefaults({ await updateShowAppsInMenu(true); rollbacks.push(() => updateShowAppsInMenu(currentShowAppsInMenu)); - // Apply Claude last so no later settings failure can leave its mappings - // reset while the rest of the page rolls back. + // Reset app-specific model settings only after the rest of the page has + // succeeded, because those native profile changes cannot be rolled back + // with the settings API. + if (!(await resetChatGPTModels())) { + throw new Error("ChatGPT models could not be reset"); + } if (!(await resetClaudeMappings())) { throw new Error("Claude model mappings could not be reset"); } @@ -137,14 +147,17 @@ export default function Settings() { const [resettingToDefaults, setResettingToDefaults] = useState(false); const [resetError, setResetError] = useState(null); const [hasClaudeDraftChanges, setHasClaudeDraftChanges] = useState(false); + const [hasCodexDraftChanges, setHasCodexDraftChanges] = useState(false); const claudeModelsSettingsRef = useRef(null); + const codexModelsSettingsRef = + useRef(null); const savedConfirmationTimeoutRef = useRef(null); useBlocker({ shouldBlockFn: () => - !window.confirm("Discard unapplied Claude routing changes?"), - enableBeforeUnload: hasClaudeDraftChanges, - disabled: !hasClaudeDraftChanges, + !window.confirm("Discard unapplied app model changes?"), + enableBeforeUnload: hasClaudeDraftChanges || hasCodexDraftChanges, + disabled: !hasClaudeDraftChanges && !hasCodexDraftChanges, }); const { user, @@ -397,6 +410,8 @@ export default function Settings() { updateSettingsMutation.mutateAsync(defaultSettings), updateCloud: requestCloudUpdate, updateShowAppsInMenu: updateShowAppsInMenuVisibility, + resetChatGPTModels: async () => + (await codexModelsSettingsRef.current?.resetToDefaults()) ?? true, resetClaudeMappings: async () => (await claudeModelsSettingsRef.current?.resetToDefaults()) ?? true, currentSettings: settings, @@ -449,6 +464,16 @@ export default function Settings() { } }; + const handleDisconnectOllamaAccount = async () => { + setConnectionError(null); + try { + await disconnectUser(); + window.location.reload(); + } catch { + setConnectionError("Failed to disconnect Ollama account"); + } + }; + if (loading) { return null; } @@ -529,7 +554,7 @@ export default function Settings() { type="button" color="zinc" className="px-3 py-2 text-sm" - onClick={() => disconnectUser()} + onClick={() => void handleDisconnectOllamaAccount()} > Sign out @@ -756,13 +781,30 @@ export default function Settings() {
{!isWindows && ( - +
+

+ Apps +

+ + +
)} {/* Agent Mode */} diff --git a/app/ui/app/src/hooks/useUser.ts b/app/ui/app/src/hooks/useUser.ts index b4e6698eb..a9541f3ca 100644 --- a/app/ui/app/src/hooks/useUser.ts +++ b/app/ui/app/src/hooks/useUser.ts @@ -35,8 +35,15 @@ export function useUser() { const disconnectMutation = useMutation({ mutationFn: disconnectUser, - onSuccess: () => { + onMutate: async () => { + await queryClient.cancelQueries({ queryKey: ["user"] }); + const previousUser = queryClient.getQueryData(["user"]); queryClient.setQueryData(["user"], null); + + return { previousUser }; + }, + onError: (_error, _variables, context) => { + queryClient.setQueryData(["user"], context?.previousUser); }, }); @@ -54,6 +61,6 @@ export function useUser() { refetchUser: userQuery.refetch, fetchConnectUrl: connectUrlQuery.refetch, connectUrl: connectUrlQuery.data, - disconnectUser: disconnectMutation.mutate, + disconnectUser: disconnectMutation.mutateAsync, }; } diff --git a/app/ui/app/src/lib/launchCommands.ts b/app/ui/app/src/lib/launchCommands.ts index 47b7570c7..fad71fa33 100644 --- a/app/ui/app/src/lib/launchCommands.ts +++ b/app/ui/app/src/lib/launchCommands.ts @@ -15,6 +15,9 @@ export const INTEGRATION_ICONS: Record = { className: "h-7 w-7 rounded", }, codex: { + src: "/launch-icons/codex-color.svg", + }, + chatgpt: { src: "/launch-icons/codex.svg", darkSrc: "/launch-icons/codex-dark.svg", }, diff --git a/app/ui/app/src/types/webview.d.ts b/app/ui/app/src/types/webview.d.ts index 6bfc3aabd..19a89e520 100644 --- a/app/ui/app/src/types/webview.d.ts +++ b/app/ui/app/src/types/webview.d.ts @@ -61,7 +61,60 @@ interface ClaudeDesktopActionResult { restartConfirmationRequired?: boolean; } +interface CodexDesktopStatus { + supported: boolean; + installed: boolean; + connected: boolean; + running: boolean; + model?: string; + models?: string[]; + maxModels?: number; + requests?: number; +} + +interface CodexDesktopActionResult { + status: CodexDesktopStatus; + error?: string; + restartConfirmationRequired?: boolean; +} + +interface CodexDesktopModelsSettings { + supported: boolean; + installed: boolean; + connected: boolean; + running: boolean; + usesDefaults: boolean; + selected: string[]; + available: string[]; + models?: CodexDesktopModelStatus[]; + maxModels: number; +} + +interface CodexDesktopModelStatus { + name: string; + displayName: string; + description?: string; + recommended?: boolean; + selected: boolean; + availability?: "unknown" | "available" | "unavailable"; + reason?: + | "cloud_off" + | "sign_in_required" + | "upgrade_required" + | "verification_unavailable" + | "model_not_installed"; + requiredPlan?: string; +} + +interface CodexDesktopModelsSettingsResult { + settings: CodexDesktopModelsSettings; + error?: string; + warning?: string; + restartConfirmationRequired?: boolean; +} + type ClaudeDesktopInstallResult = "opened" | "cancelled" | "failed"; +type CodexDesktopInstallResult = "opened" | "cancelled" | "failed"; interface WebviewAPI { selectFile: () => Promise; @@ -85,6 +138,19 @@ declare global { ) => Promise; prepareClaudeDesktopConnection?: () => Promise; openClaudeDesktop?: () => Promise; + getCodexDesktopStatus?: () => Promise; + getCodexDesktopRequestCount?: () => Promise; + setCodexDesktopConnected?: ( + enabled: boolean, + restartConfirmed: boolean, + ) => Promise; + installCodexDesktop?: () => Promise; + getCodexDesktopModelsSettings?: () => Promise; + applyCodexDesktopModels?: ( + models: string[], + restartConfirmed: boolean, + ) => Promise; + resetCodexDesktopModels?: () => Promise; installClaudeDesktop?: () => Promise; getShowAppsInMenu?: () => Promise; setShowAppsInMenu?: (visible: boolean) => Promise; @@ -129,6 +195,12 @@ export type { ClaudeDesktopMappingStatus, ClaudeDesktopModelStatus, ClaudeDesktopStatus, + CodexDesktopActionResult, + CodexDesktopInstallResult, + CodexDesktopModelsSettings, + CodexDesktopModelStatus, + CodexDesktopModelsSettingsResult, + CodexDesktopStatus, ContextMenuItem, ContextMenuResult, ImageData, diff --git a/app/ui/ui.go b/app/ui/ui.go index e5458f3a3..ab1623065 100644 --- a/app/ui/ui.go +++ b/app/ui/ui.go @@ -339,7 +339,7 @@ func (s *Server) getIntegrationStatuses(w http.ResponseWriter, _ *http.Request) claudeDesktopInstalled := isInstalled("claude-desktop") statuses = append(statuses, integrationStatus{ ID: "claude-desktop", - Name: "Claude", + Name: "Claude Code (Desktop)", Description: "Use Ollama models in Claude Desktop", Installed: &claudeDesktopInstalled, Action: "connect", diff --git a/app/ui/ui_test.go b/app/ui/ui_test.go index 95cc88a5a..4da7a8177 100644 --- a/app/ui/ui_test.go +++ b/app/ui/ui_test.go @@ -135,6 +135,7 @@ func TestGetIntegrationStatuses(t *testing.T) { var got []struct { ID string `json:"id"` + Name string `json:"name"` Installed *bool `json:"installed"` Action string `json:"action"` Command string `json:"command"` @@ -146,7 +147,7 @@ func TestGetIntegrationStatuses(t *testing.T) { if len(got) < 5 { t.Fatalf("got %d integrations, want the full registry", len(got)) } - if got[0].ID != "claude-desktop" || got[0].Action != "connect" || got[0].Command != "" { + if got[0].ID != "claude-desktop" || got[0].Name != "Claude Code (Desktop)" || got[0].Action != "connect" || got[0].Command != "" { t.Fatalf("first integration = %+v, want command-free Claude Desktop connect", got[0]) } wantPrefix := []string{"claude-desktop", "claude", "codex", "openclaw", "opencode", "hermes", "hermes-desktop", "droid", "pi", "cline"} diff --git a/cmd/launch/codex.go b/cmd/launch/codex.go index 22c3bb75c..6d760f146 100644 --- a/cmd/launch/codex.go +++ b/cmd/launch/codex.go @@ -18,7 +18,7 @@ import ( // Codex implements Runner for Codex integration type Codex struct{} -func (c *Codex) String() string { return "Codex" } +func (c *Codex) String() string { return "Codex CLI" } const ( codexProfileName = "ollama-launch" @@ -30,6 +30,7 @@ const ( codexRootModelKey = "model" codexRootModelProviderKey = "model_provider" codexRootModelCatalogJSONKey = "model_catalog_json" + codexRootOpenAIBaseURLKey = "openai_base_url" ) func (c *Codex) args(model, modelCatalogPath string, extra []string) ([]string, error) { @@ -389,24 +390,6 @@ func codexValidateProfileConfigText(config codexParsedConfig, profileName, model return nil } -func codexUpsertSection(text, header string, lines []string) string { - block := strings.Join(append([]string{header}, lines...), "\n") + "\n" - - if targetPath, ok := codexTableHeaderPath(header); ok { - if start, end, found := codexSectionRange(text, targetPath); found { - return text[:start] + block + text[end:] - } - } - - if text != "" && !strings.HasSuffix(text, "\n") { - text += "\n" - } - if text != "" { - text += "\n" - } - return text + block -} - func codexRemoveSection(text, header string) string { targetPath, ok := codexTableHeaderPath(header) if !ok { diff --git a/cmd/launch/codex_app.go b/cmd/launch/codex_app.go index 1e15f02ac..57b49d872 100644 --- a/cmd/launch/codex_app.go +++ b/cmd/launch/codex_app.go @@ -1,56 +1,84 @@ package launch import ( + "context" "encoding/json" "errors" "fmt" + "net/http" "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" - codexAppModelCatalogFilename = "ollama-launch-models.json" - codexAppRestoreHint = "To restore your usual ChatGPT profile, run: ollama launch chatgpt --restore" - codexAppConfigurationSuccess = "ChatGPT profile changed to Ollama." - codexAppRestoreSuccess = "ChatGPT restored to your usual profile." + chatGPTIntegrationName = "chatgpt" + codexAppIntegrationName = "codex-app" + codexAppProfileName = "ollama-launch-codex-app" + codexAppBundleID = "com.openai.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 - codexAppForceQuit = defaultCodexAppForceQuitApp - codexAppHasWindow = defaultCodexAppHasOpenWindow - codexAppIsRunning = defaultCodexAppIsRunning - codexAppRunPath = defaultCodexAppRunningAppPath - codexAppStartID = defaultCodexAppStartAppID - codexAppCanOpenID = defaultCodexAppCanOpenBundleID - codexAppSleep = time.Sleep + 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 - codexAppExitTimeout = 5 * time.Second - codexAppForceExitTimeout = 5 * time.Second + codexAppProfileApplication = defaultCodexAppOllamaProfileApplication + codexAppProfileExecutable = defaultCodexAppOllamaProfileExecutable + codexAppStopProfile = defaultCodexAppStopOllamaProfile + codexAppProfileIsRunning = defaultCodexAppOllamaProfileIsRunning + codexAppProcessCommand = defaultCodexAppProcessCommand + + codexAppExitTimeout = 5 * time.Second ) -// CodexApp configures the desktop Codex app with one launch-selected default -// model while leaving model discovery and switching to Codex's Ollama provider. +// 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() } @@ -72,6 +100,11 @@ func (c *CodexApp) ConfigureWithModels(primary string, models []LaunchModel) err if primary == "" { return fmt.Errorf("chatgpt requires a model") } + models = codexAppCatalogModels(primary, models) + autoReviewModel, err := codexAppConfiguredAutoReviewModel(primary, models) + if err != nil { + return err + } configPath, err := codexConfigPath() if err != nil { @@ -80,14 +113,34 @@ func (c *CodexApp) ConfigureWithModels(primary string, models []LaunchModel) 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 } - if err := writeCodexAppModelCatalog(catalogPath, primary, codexAppCatalogModels(primary, models)); err != nil { + routingCatalogPath := codexAppRoutingCatalogPathForConfig(configPath) + if err := writeCodexAppRoutingCatalog(routingCatalogPath, models, autoReviewModel); err != nil { return err } - return writeCodexAppConfig(configPath, primary, catalogPath) + 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); 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 { @@ -104,10 +157,19 @@ func (c *CodexApp) CurrentModel() string { if err != nil { return "" } + if codexAppRootUsesProxy(parsed) && 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 codexNormalizeURL(baseURL) == codexNormalizeURL(codexBaseURL()) && codexAppCatalogHealthy(parsed, profileName) { + if codexAppManagedProviderURL(profileName, baseURL) && codexAppCatalogHealthy(parsed, profileName) { model := strings.TrimSpace(parsed.RootString(codexRootModelKey)) if codexAppCatalogContainsModel(model) { return model @@ -154,6 +216,14 @@ 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 { @@ -162,7 +232,7 @@ func codexAppCatalogHealthy(config codexParsedConfig, profileName string) bool { if config.RootString(codexRootModelCatalogJSONKey) != catalogPath { return false } - if config.Exists("profiles", profileName) && config.ProfileString(profileName, codexRootModelCatalogJSONKey) != catalogPath { + if profileName != "" && config.Exists("profiles", profileName) && config.ProfileString(profileName, codexRootModelCatalogJSONKey) != catalogPath { return false } data, err := os.ReadFile(catalogPath) @@ -178,22 +248,33 @@ func codexAppCatalogHealthy(config codexParsedConfig, profileName string) bool { return len(catalog.Models) > 0 } -// codexAppCatalogContainsModel reports whether model appears as a slug in the -// Ollama-managed model catalog. When the configured model is not in the catalog -// the user has drifted away from the launch-managed model (e.g. by selecting a -// built-in OpenAI model in the Codex App UI), and the launch config should be -// treated as inactive. +// Check the routing allow-list, not the combined picker catalog. func codexAppCatalogContainsModel(model string) bool { if strings.TrimSpace(model) == "" { return false } - catalogPath, err := codexAppModelCatalogPath() + 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 false + return nil, err } var catalog struct { Models []struct { @@ -201,19 +282,30 @@ func codexAppCatalogContainsModel(model string) bool { } `json:"models"` } if err := json.Unmarshal(data, &catalog); err != nil { - return false + return nil, err } - target := codexAppCatalogModelKey(model) + models := make([]string, 0, len(catalog.Models)) for _, m := range catalog.Models { - if codexAppCatalogModelKey(m.Slug) == target { - return true + if slug := strings.TrimSpace(m.Slug); slug != "" { + models = append(models, slug) } } - return false + 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) error { - baseURL := codexBaseURL() + baseURL := codexAppProxyBaseURL() content, readErr := os.ReadFile(configPath) text := "" @@ -227,15 +319,13 @@ func writeCodexAppConfig(configPath, model, modelCatalogPath string) error { } text = codexRemoveRootValue(text, codexRootProfileKey) - text = codexRemoveSection(text, codexProfileHeaderFor(codexAppProfileName)) + text = codexAppRemoveOwnedSections(text) text = codexSetRootStringValue(text, codexRootModelKey, model) - text = codexSetRootStringValue(text, codexRootModelProviderKey, codexAppProfileName) + // Leave model_provider unset to preserve ChatGPT's native account controls. + text = codexRemoveRootValue(text, codexRootModelProviderKey) text = codexSetRootStringValue(text, codexRootModelCatalogJSONKey, modelCatalogPath) - text = codexUpsertSection(text, codexProviderHeaderFor(codexAppProfileName), []string{ - fmt.Sprintf("name = %q", codexProviderName), - fmt.Sprintf("base_url = %q", baseURL), - `wire_api = "responses"`, - }) + text = codexSetRootStringValue(text, codexRootOpenAIBaseURLKey, baseURL) + text = codexAppSetReasoningEfforts(text, codexAppReasoningEffortsForConfig(text)) parsed, err := codexParseConfig(text) if err != nil { @@ -257,16 +347,23 @@ func codexValidateAppConfigText(config codexParsedConfig, model, modelCatalogPat 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{codexRootModelProviderKey}, codexAppProfileName}, {[]string{codexRootModelCatalogJSONKey}, modelCatalogPath}, - {[]string{"model_providers", codexAppProfileName, "name"}, codexProviderName}, - {[]string{"model_providers", codexAppProfileName, "base_url"}, baseURL}, - {[]string{"model_providers", codexAppProfileName, "wire_api"}, "responses"}, + {[]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) @@ -275,6 +372,251 @@ func codexValidateAppConfigText(config codexParsedConfig, model, modelCatalogPat 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) } @@ -302,13 +644,159 @@ func (c *CodexApp) Run(_ string, _ []LaunchModel, args []string) error { if len(args) > 0 { return fmt.Errorf("chatgpt does not accept extra arguments") } - return codexAppLaunchOrRestart("Restart ChatGPT to use Ollama?", nil) + 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() != "" +} + +// 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 @@ -317,6 +805,9 @@ func (c *CodexApp) Restore() error { data, err := os.ReadFile(configPath) if err != nil { if os.IsNotExist(err) { + if err := removeCodexAppManagedAuth(configPath); err != nil { + return codexAppRestoreFailure(configPath, err) + } if err := removeCodexAppRestoreState(); err != nil { return codexAppRestoreFailure(configPath, err) } @@ -326,7 +817,7 @@ func (c *CodexApp) Restore() error { if err := codexAppRemoveOwnedCatalog(); err != nil { return codexAppRestoreFailure(configPath, err) } - return codexAppLaunchOrRestart("Restart ChatGPT to use your usual profile?", nil) + return nil } return codexAppRestoreFailure(configPath, err) } @@ -359,10 +850,13 @@ func (c *CodexApp) Restore() error { if err := codexAppRemoveOwnedCatalogIfUnused(text); err != nil { return codexAppRestoreFailure(configPath, err) } + if err := removeCodexAppManagedAuth(configPath); err != nil { + return codexAppRestoreFailure(configPath, err) + } if err := removeCodexAppRestoreState(); err != nil { return codexAppRestoreFailure(configPath, err) } - return codexAppLaunchOrRestart("Restart ChatGPT to use your usual profile?", nil) + return nil } func codexAppRestoreFailure(configPath string, err error) error { @@ -406,6 +900,30 @@ func codexAppModelCatalogPath() (string, error) { 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 { @@ -422,18 +940,54 @@ func codexAppModelCatalogPathForConfig(configPath string) string { return filepath.Join(filepath.Dir(configPath), codexAppModelCatalogFilename) } -func writeCodexAppModelCatalog(path, primary string, models []LaunchModel) error { +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") } - baseInstructions := codexAppBaseInstructions() - entries := make([]map[string]any, 0, len(models)) + 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 { - entries = append(entries, codexAppCatalogEntry(model.Name, codexAppModelMetadataFromLaunchModel(model), i, baseInstructions)) + 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(map[string]any{"models": entries}, "", " ") + data, err := json.MarshalIndent(codexAppRawModelCatalog{Models: entries}, "", " ") if err != nil { return err } @@ -443,6 +997,189 @@ func writeCodexAppModelCatalog(path, primary string, models []LaunchModel) error 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 +} + +func writeCodexAppRoutingCatalog(path string, models []LaunchModel, autoReviewModel string) 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"` + } + 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, + }, + }) + } + catalog := struct { + Models []routingEntry `json:"models"` + AutoReviewModel string `json:"auto_review_model,omitempty"` + }{ + Models: entries, + AutoReviewModel: autoReviewModel, + } + 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) (string, error) { + configured := strings.TrimSpace(os.Getenv(codexAppAutoReviewModelEnv)) + switch strings.ToLower(configured) { + case "", "selected", "ollama": + return primary, nil + case "native", "chatgpt": + return "", nil + } + + target := codexAppCatalogModelKey(configured) + for _, model := range models { + if codexAppCatalogModelKey(model.Name) == target { + return model.Name, nil + } + } + return "", 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) @@ -459,13 +1196,13 @@ func codexAppCatalogModels(primary string, models []LaunchModel) []LaunchModel { out = append(out, model) } - if model, ok := findLaunchModel(models, primary); ok { - model.Name = primary - add(model) - } else { + 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 @@ -478,6 +1215,14 @@ func codexAppCatalogModelKey(name string) string { type codexAppModelMetadata struct { contextWindow int inputModalities []string + thinking codexAppThinkingContract + toolCapable bool +} + +type codexAppThinkingContract struct { + defaultLevel string + levels []string + values map[string]any } func codexAppDefaultModelMetadata() codexAppModelMetadata { @@ -495,42 +1240,175 @@ func codexAppModelMetadataFromLaunchModel(model LaunchModel) codexAppModelMetada if model.HasCapability("vision") { metadata.inputModalities = []string{"text", "image"} } + metadata.thinking = codexAppThinkingContractForModel(model) + metadata.toolCapable = model.ToolCapable || model.HasCapability(modelpkg.CapabilityTools) return metadata } -func codexAppCatalogEntry(model string, metadata codexAppModelMetadata, priority int, baseInstructions string) map[string]any { - return map[string]any{ - "slug": model, - "display_name": model, - "description": "Ollama local model", - "default_reasoning_level": nil, - "supported_reasoning_levels": []any{}, - "shell_type": "default", - "visibility": "list", - "supported_in_api": true, - "priority": priority, - "additional_speed_tiers": []any{}, - "availability_nux": nil, - "upgrade": nil, - "base_instructions": baseInstructions, - "model_messages": nil, - "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": "bytes", "limit": 10_000}, - "supports_parallel_tool_calls": false, - "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": false, +// 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 "medium" to on. + return codexAppThinkingContract{ + defaultLevel: "medium", + levels: []string{"none", "medium"}, + values: map[string]any{"none": false, "medium": true}, + } +} + +func codexAppThinkingContractFromRecommendation(thinking *api.ModelRecommendationThinking) (codexAppThinkingContract, bool) { + if thinking == nil || len(thinking.Values) == 0 || thinking.Default == nil { + return codexAppThinkingContract{}, false + } + + contract := codexAppThinkingContract{values: make(map[string]any, len(thinking.Values))} + for _, value := range thinking.Values { + level, ok := codexAppThinkingLevelForOllamaValue(value) + if !ok { + return codexAppThinkingContract{}, false + } + if _, duplicate := contract.values[level]; duplicate { + return codexAppThinkingContract{}, false + } + 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 codexAppThinkingContract{}, false + } + if len(contract.levels) == 1 && contract.levels[0] == "none" { + return codexAppThinkingContract{}, true + } + contract.defaultLevel = defaultLevel + return contract, true +} + +func codexAppThinkingLevelForOllamaValue(value any) (string, bool) { + switch value := value.(type) { + case bool: + if value { + return "medium", true + } + return "none", true + case string: + think := api.ThinkValue{Value: value} + return value, think.IsValid() + } + 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 { @@ -554,6 +1432,163 @@ func codexAppBaseInstructions() string { 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 { @@ -689,8 +1724,8 @@ func codexAppLaunchOrRestart(prompt string, launchArgs []string) error { } // A single spinner and cancellation channel span the entire restart flow - // (quit, wait, force-quit, wait, reopen) so that one Ctrl+C aborts the - // whole sequence rather than just the currently-active wait. The bubbletea + // (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) @@ -714,58 +1749,74 @@ func codexAppLaunchOrRestart(prompt string, launchArgs []string) error { if isCancelled() { return ErrCancelled } - gracefulErr := waitForCodexAppGracefulExit(codexAppExitTimeout, cancelled) + if err := waitForCodexAppGracefulExit(codexAppExitTimeout, cancelled); err != nil { + return err + } if isCancelled() { return ErrCancelled } - if errors.Is(gracefulErr, ErrCancelled) { - return gracefulErr + if sp != nil { + sp.Stop() } - if gracefulErr != nil && !codexAppForceQuitSupported() { - return gracefulErr - } - if codexAppForceQuitSupported() && codexAppIsRunning() { - if isCancelled() { - return ErrCancelled - } - if forceErr := codexAppForceQuit(); forceErr != nil { - return fmt.Errorf("force stop ChatGPT: %w", forceErr) - } - if err := waitForCodexAppExit(codexAppForceExitTimeout, cancelled); err != nil { + return codexAppOpenAfterRestart(restartAppID, restartAppPath, launchArgs) +} + +func codexAppApplyProfileFromDesktop(change func() error, openWhenStopped, restartConfirmed bool) error { + if !codexAppIsRunning() { + if err := change(); err != nil { return err } - } else if gracefulErr != nil { - if codexAppIsRunning() { - return gracefulErr + if openWhenStopped { + return codexAppOpenApp(nil) } + return nil } - if isCancelled() { - return ErrCancelled + if !restartConfirmed { + return ErrCodexAppRestartConfirmationRequired } - sp.Stop() - if restartAppID != "" { - return codexAppOpenStart(restartAppID) + + restartAppID, restartAppPath := codexAppRestartTarget() + if err := codexAppStopForRestart(nil); err != nil { + return err } - if restartAppPath != "" { - return codexAppOpenPath(restartAppPath) + 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 codexAppForceQuitSupported() bool { - return codexAppGOOS == "darwin" || codexAppGOOS == "windows" -} - func waitForCodexAppGracefulExit(timeout time.Duration, cancel <-chan struct{}) error { - return waitForCodexAppCondition(timeout, cancel, func() bool { - if codexAppGOOS == "windows" { - return !codexAppHasWindow() - } - return !codexAppIsRunning() - }) -} - -func waitForCodexAppExit(timeout time.Duration, cancel <-chan struct{}) error { return waitForCodexAppCondition(timeout, cancel, func() bool { return !codexAppIsRunning() }) @@ -779,8 +1830,8 @@ const codexAppRestartMessage = "Restarting ChatGPT..." // 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. When timeout is zero the loop never runs, so -// force-quit paths that short-circuit the graceful wait return immediately. +// 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) { @@ -799,7 +1850,197 @@ func waitForCodexAppCondition(timeout time.Duration, cancel <-chan struct{}, don if done() { return nil } - return fmt.Errorf("ChatGPT did not quit; quit it manually and re-run the command") + 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 { @@ -873,46 +2114,6 @@ func defaultCodexAppQuitApp() error { return scriptErr } -func defaultCodexAppForceQuitApp() error { - if !codexAppForceQuitSupported() { - return nil - } - pids := codexAppMatchingProcessIDs() - if len(pids) == 0 { - return nil - } - pidArgs := make([]string, 0, len(pids)) - for _, pid := range pids { - pidArgs = append(pidArgs, strconv.Itoa(pid)) - } - switch codexAppGOOS { - case "windows": - script := "Stop-Process -Id " + strings.Join(pidArgs, ",") + " -Force -ErrorAction SilentlyContinue" - return runCodexAppForceQuitCommand(exec.Command("powershell.exe", "-NoProfile", "-Command", script)) - case "darwin": - return runCodexAppForceQuitCommand(exec.Command("kill", append([]string{"-TERM"}, pidArgs...)...)) - default: - return nil - } -} - -func runCodexAppForceQuitCommand(cmd *exec.Cmd) error { - err := cmd.Run() - if err != nil && !codexAppIsRunning() { - return nil - } - return err -} - -func defaultCodexAppHasOpenWindow() bool { - if codexAppGOOS != "windows" { - return codexAppIsRunning() - } - script := `(Get-Process ChatGPT,Codex -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -First 1).Id` - out, err := exec.Command("powershell.exe", "-NoProfile", "-Command", script).Output() - return err == nil && strings.TrimSpace(string(out)) != "" -} - func defaultCodexAppIsRunning() bool { switch codexAppGOOS { case "windows": @@ -1052,7 +2253,8 @@ func codexAppRootStillManaged(text string) bool { return false } return codexAppIsOwnedProfileName(config.RootString(codexRootProfileKey)) || - codexAppIsOwnedProfileName(config.RootString(codexRootModelProviderKey)) + codexAppIsOwnedProfileName(config.RootString(codexRootModelProviderKey)) || + codexAppRootUsesProxy(config) } func codexAppRootReferencesOwnedConfig(text string) bool { @@ -1061,7 +2263,22 @@ func codexAppRootReferencesOwnedConfig(text string) bool { return false } return config.RootString(codexRootProfileKey) == codexAppProfileName || - config.RootString(codexRootModelProviderKey) == 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 codexNormalizeURL(config.RootString(codexRootOpenAIBaseURLKey)) == codexNormalizeURL(codexAppProxyBaseURL()) && + config.RootString(codexRootModelCatalogJSONKey) == catalogPath } func codexAppRootReferencesCatalog(text string) bool { @@ -1077,11 +2294,26 @@ func codexAppRootReferencesCatalog(text string) bool { } func codexAppRemoveOwnedSections(text string) string { - text = codexRemoveSection(text, codexProfileHeaderFor(codexAppProfileName)) - text = codexRemoveSection(text, codexProviderHeaderFor(codexAppProfileName)) + 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 @@ -1090,12 +2322,17 @@ func codexAppRemoveOwnedCatalogIfUnused(text string) error { } func codexAppRemoveOwnedCatalog() error { - if catalogPath, err := codexAppModelCatalogPath(); err == nil { - if err := os.Remove(catalogPath); err != nil && !os.IsNotExist(err) { + 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 } - } else { - return err } return nil } @@ -1118,17 +2355,21 @@ func codexAppRemoveOwnedRootValues(text string) string { } modelProvider := config.RootString(codexRootModelProviderKey) modelCatalogJSON := config.RootString(codexRootModelCatalogJSONKey) - if !codexAppIsOwnedProfileName(config.RootString(codexRootProfileKey)) && !codexAppIsOwnedProfileName(modelProvider) { + usesProxy := codexAppRootUsesProxy(config) + if !codexAppIsOwnedProfileName(config.RootString(codexRootProfileKey)) && !codexAppIsOwnedProfileName(modelProvider) && !usesProxy { return text } text = codexRemoveRootValue(text, codexRootProfileKey) text = codexRemoveRootValue(text, codexRootModelKey) - if codexAppIsOwnedProfileName(modelProvider) { + 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 } @@ -1136,22 +2377,60 @@ func codexAppRestoreRootValues(text string, state codexAppRestoreState) string { if !codexAppRootStillManaged(text) { return text } + preserveCurrentModel := codexAppShouldPreserveCurrentModel(text) text = codexRestoreRootStringValue(text, codexRootProfileKey, state.HadProfile, state.Profile) - text = codexRestoreRootStringValue(text, codexRootModelKey, state.HadModel, state.Model) + 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"` + 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 { @@ -1187,15 +2466,40 @@ func saveCodexAppRestoreState(configPath string) error { 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. - upgraded = codexAppRestoreState{} + // 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 @@ -1216,7 +2520,9 @@ func codexAppRestoreStateHasRootConfig(data []byte) (bool, error) { _, hasModel := raw["had_model"] _, hasModelProvider := raw["had_model_provider"] _, hasModelCatalogJSON := raw["had_model_catalog_json"] - return hasModel && hasModelProvider && hasModelCatalogJSON, nil + _, hasOpenAIBaseURL := raw["had_openai_base_url"] + _, hasDesktopReasoningEfforts := raw["had_desktop_reasoning_efforts"] + return hasModel && hasModelProvider && hasModelCatalogJSON && hasOpenAIBaseURL && hasDesktopReasoningEfforts, nil } func codexAppRestoreStateFromText(text string) codexAppRestoreState { @@ -1228,15 +2534,21 @@ func codexAppRestoreStateFromText(text string) codexAppRestoreState { 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, + HadProfile: hadProfile, + Profile: profile, + HadModel: hadModel, + Model: model, + HadModelProvider: hadModelProvider, + ModelProvider: modelProvider, + HadModelCatalogJSON: hadModelCatalogJSON, + ModelCatalogJSON: modelCatalogJSON, + HadOpenAIBaseURL: hadOpenAIBaseURL, + OpenAIBaseURL: openAIBaseURL, + HadDesktopReasoningEfforts: hadDesktopReasoningEfforts, + DesktopReasoningEfforts: desktopReasoningEfforts, } } diff --git a/cmd/launch/codex_app_profile.go b/cmd/launch/codex_app_profile.go new file mode 100644 index 000000000..364c01f8e --- /dev/null +++ b/cmd/launch/codex_app_profile.go @@ -0,0 +1,245 @@ +package launch + +import ( + "bufio" + "bytes" + "encoding/json" + "io" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "time" +) + +type codexAppRequestCursor struct { + mu sync.Mutex + start time.Time + filterKey string + files map[string]int64 + models map[string]string + count uint64 +} + +var codexAppRequests codexAppRequestCursor + +func resetCodexAppRequestCountAt(path string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + start := time.Now().UTC() + if err := os.WriteFile(path, []byte(start.Format(time.RFC3339Nano)+"\n"), 0o600); err != nil { + return err + } + codexAppRequests.mu.Lock() + codexAppRequests.start = start + codexAppRequests.filterKey = "" + codexAppRequests.files = make(map[string]int64) + codexAppRequests.models = make(map[string]string) + codexAppRequests.count = 0 + codexAppRequests.mu.Unlock() + return nil +} + +func resetCodexAppRegularProfileRequestCount() error { + path, err := codexAppRegularProfileSessionStartPath() + if err != nil { + return err + } + return resetCodexAppRequestCountAt(path) +} + +func codexAppRegularProfileRequestCount() uint64 { + startPath, err := codexAppRegularProfileSessionStartPath() + if err != nil { + return 0 + } + data, err := os.ReadFile(startPath) + if err != nil { + return 0 + } + start, err := time.Parse(time.RFC3339Nano, string(bytes.TrimSpace(data))) + if err != nil { + return 0 + } + configPath, err := codexConfigPath() + if err != nil { + return 0 + } + // The regular profile contains native Codex and Ollama turns. Filter its + // user-prompt count by the Ollama-only routing catalog; the proxy's raw API + // counter would overcount prompts that need multiple tool-loop requests. + return codexAppRequests.scan( + filepath.Join(filepath.Dir(configPath), "sessions"), + start, + codexAppRegularProfileRoutingModels(configPath), + ) +} + +func codexAppRegularProfileSessionStartPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".ollama", "launch", "chatgpt-session-start"), nil +} + +func codexAppRegularProfileRoutingModels(configPath string) map[string]struct{} { + models := make(map[string]struct{}) + data, err := os.ReadFile(codexAppRoutingCatalogPathForConfig(configPath)) + if err != nil { + return models + } + var catalog struct { + Models []struct { + Slug string `json:"slug"` + } `json:"models"` + } + if json.Unmarshal(data, &catalog) != nil { + return models + } + for _, model := range catalog.Models { + if key := codexAppCatalogModelKey(strings.TrimSpace(model.Slug)); key != "" { + models[key] = struct{}{} + } + } + return models +} + +func (c *codexAppRequestCursor) scan(root string, start time.Time, allowedModels map[string]struct{}) uint64 { + c.mu.Lock() + defer c.mu.Unlock() + return c.scanLocked(root, start, allowedModels, true) +} + +func (c *codexAppRequestCursor) scanLocked(root string, start time.Time, allowedModels map[string]struct{}, retryOnTruncate bool) uint64 { + filterKey := codexAppRequestModelFilterKey(allowedModels) + if !c.start.Equal(start) || c.filterKey != filterKey || c.files == nil || c.models == nil { + c.start = start + c.filterKey = filterKey + c.files = make(map[string]int64) + c.models = make(map[string]string) + c.count = 0 + } + + paths := make([]string, 0) + _ = filepath.WalkDir(root, func(path string, entry fs.DirEntry, _ error) error { + // Request counting is best-effort; skip paths that disappear or cannot + // be inspected while the session directory is changing. + if entry == nil || entry.IsDir() || filepath.Ext(path) != ".jsonl" { + return nil + } + paths = append(paths, path) + return nil + }) + slices.Sort(paths) + for _, path := range paths { + info, err := os.Stat(path) + if err != nil || info.ModTime().Before(start) { + continue + } + offset := c.files[path] + if info.Size() < offset { + c.start = start + c.filterKey = filterKey + c.files = make(map[string]int64) + c.models = make(map[string]string) + c.count = 0 + if retryOnTruncate { + return c.scanLocked(root, start, allowedModels, false) + } + return 0 + } + file, err := os.Open(path) + if err != nil { + continue + } + if _, err := file.Seek(offset, io.SeekStart); err != nil { + _ = file.Close() + continue + } + reader := bufio.NewReader(file) + model := c.models[path] + for { + line, readErr := reader.ReadBytes('\n') + if readErr != nil { + break + } + offset += int64(len(line)) + if turnModel, ok := codexAppLineTurnModel(line); ok { + model = codexAppCatalogModelKey(turnModel) + } + if codexAppLineIsUserRequest(line, start) && codexAppRequestModelAllowed(model, allowedModels) { + c.count++ + } + } + _ = file.Close() + c.files[path] = offset + c.models[path] = model + } + return c.count +} + +func codexAppRequestModelFilterKey(models map[string]struct{}) string { + if models == nil { + return "*" + } + keys := make([]string, 0, len(models)) + for model := range models { + keys = append(keys, model) + } + slices.Sort(keys) + return strings.Join(keys, "\x00") +} + +func codexAppRequestModelAllowed(model string, allowedModels map[string]struct{}) bool { + if allowedModels == nil { + return true + } + _, ok := allowedModels[model] + return ok +} + +func codexAppLineTurnModel(line []byte) (string, bool) { + if !bytes.Contains(line, []byte(`"type":"turn_context"`)) || !bytes.Contains(line, []byte(`"model"`)) { + return "", false + } + var event struct { + Type string `json:"type"` + Payload struct { + Model string `json:"model"` + } `json:"payload"` + } + if json.Unmarshal(line, &event) != nil || event.Type != "turn_context" { + return "", false + } + model := strings.TrimSpace(event.Payload.Model) + return model, model != "" +} + +func codexAppLineIsUserRequest(line []byte, start time.Time) bool { + if !bytes.Contains(line, []byte(`"role":"user"`)) || !bytes.Contains(line, []byte(`"user.text"`)) { + return false + } + var event struct { + Timestamp time.Time `json:"timestamp"` + Type string `json:"type"` + Payload struct { + Type string `json:"type"` + Role string `json:"role"` + Metadata struct { + ContentItemKinds []string `json:"content_item_kinds"` + } `json:"internal_chat_message_metadata_passthrough"` + } `json:"payload"` + } + if err := json.Unmarshal(line, &event); err != nil { + return false + } + return !event.Timestamp.Before(start) && + event.Type == "response_item" && + event.Payload.Type == "message" && + event.Payload.Role == "user" && + slices.Contains(event.Payload.Metadata.ContentItemKinds, "user.text") +} diff --git a/cmd/launch/codex_app_test.go b/cmd/launch/codex_app_test.go index 6cb3e389b..2132aa066 100644 --- a/cmd/launch/codex_app_test.go +++ b/cmd/launch/codex_app_test.go @@ -4,14 +4,19 @@ import ( "encoding/json" "errors" "fmt" + "net/http" + "net/http/httptest" "os" "path/filepath" + "runtime" "slices" "strings" "testing" "time" + "github.com/ollama/ollama/api" "github.com/ollama/ollama/cmd/internal/fileutil" + "github.com/ollama/ollama/internal/proxy" "github.com/ollama/ollama/types/model" ) @@ -24,6 +29,15 @@ func withCodexAppPlatform(t *testing.T, goos string) { }) } +func withCodexAppRouterHealth(t *testing.T, health func() error) { + t.Helper() + old := codexAppRouterHealth + codexAppRouterHealth = health + t.Cleanup(func() { + codexAppRouterHealth = old + }) +} + func withCodexAppProcessHooks(t *testing.T, isRunning func() bool, quit func() error, open func() error) { t.Helper() oldIsRunning := codexAppIsRunning @@ -31,15 +45,11 @@ func withCodexAppProcessHooks(t *testing.T, isRunning func() bool, quit func() e oldOpen := codexAppOpenApp oldOpenPath := codexAppOpenPath oldOpenStart := codexAppOpenStart - oldForceQuit := codexAppForceQuit - oldHasWindow := codexAppHasWindow oldRunPath := codexAppRunPath oldStartID := codexAppStartID oldCanOpenID := codexAppCanOpenID oldExitTimeout := codexAppExitTimeout - oldForceExitTimeout := codexAppForceExitTimeout codexAppIsRunning = isRunning - codexAppHasWindow = isRunning codexAppQuitApp = quit codexAppOpenApp = func([]string) error { return open() } t.Cleanup(func() { @@ -48,13 +58,10 @@ func withCodexAppProcessHooks(t *testing.T, isRunning func() bool, quit func() e codexAppOpenApp = oldOpen codexAppOpenPath = oldOpenPath codexAppOpenStart = oldOpenStart - codexAppForceQuit = oldForceQuit - codexAppHasWindow = oldHasWindow codexAppRunPath = oldRunPath codexAppStartID = oldStartID codexAppCanOpenID = oldCanOpenID codexAppExitTimeout = oldExitTimeout - codexAppForceExitTimeout = oldForceExitTimeout }) } @@ -87,6 +94,898 @@ func TestCodexAppIntegration(t *testing.T) { }) } +func TestCodexAppNativeCatalogUsesDebugModelsInScratchHome(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, ".codex", "config.toml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(filepath.Dir(configPath), "auth.json"), []byte("test-auth"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(filepath.Dir(configPath), "models_cache.json"), []byte(`{"models":[{"slug":"cached"}]}`), 0o600); err != nil { + t.Fatal(err) + } + + oldExecutable := codexAppCodexExecutable + oldRun := codexAppRunDebugModels + codexAppCodexExecutable = func() (string, error) { return "/test/codex", nil } + codexAppRunDebugModels = func(executable, codexHome string, bundled bool) ([]byte, error) { + if executable != "/test/codex" || codexHome == "" || bundled { + t.Fatalf("debug models call = executable %q, CODEX_HOME %q, bundled %v", executable, codexHome, bundled) + } + for name, want := range map[string]string{ + "auth.json": "test-auth", + "models_cache.json": `{"models":[{"slug":"cached"}]}`, + } { + data, err := os.ReadFile(filepath.Join(codexHome, name)) + if err != nil || string(data) != want { + t.Fatalf("scratch %s = %q, %v; want %q", name, data, err, want) + } + } + return []byte(`{"models":[{"slug":"gpt-live","priority":1}]}`), nil + } + t.Cleanup(func() { + codexAppCodexExecutable = oldExecutable + codexAppRunDebugModels = oldRun + }) + + data, err := defaultCodexAppNativeModelCatalog(configPath) + if err != nil { + t.Fatal(err) + } + catalog, err := parseCodexAppModelCatalog(data) + if err != nil { + t.Fatal(err) + } + slug, err := codexAppRawCatalogSlug(catalog.Models[0]) + if err != nil || slug != "gpt-live" { + t.Fatalf("native catalog slug = %q, %v; want gpt-live", slug, err) + } +} + +func TestCodexAppNativeCatalogFallsBackToCacheThenBundled(t *testing.T) { + t.Run("cache", func(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, ".codex", "config.toml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(filepath.Dir(configPath), "models_cache.json"), []byte(`{"client_version":"test","models":[{"slug":"gpt-cached"}]}`), 0o600); err != nil { + t.Fatal(err) + } + + oldExecutable := codexAppCodexExecutable + oldRun := codexAppRunDebugModels + codexAppCodexExecutable = func() (string, error) { return "/test/codex", nil } + codexAppRunDebugModels = func(_ string, _ string, bundled bool) ([]byte, error) { + if bundled { + t.Fatal("bundled fallback should not run when cache is valid") + } + return nil, errors.New("offline") + } + t.Cleanup(func() { + codexAppCodexExecutable = oldExecutable + codexAppRunDebugModels = oldRun + }) + + data, err := defaultCodexAppNativeModelCatalog(configPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), `"gpt-cached"`) || strings.Contains(string(data), "client_version") { + t.Fatalf("normalized cached catalog = %s", data) + } + }) + + t.Run("bundled", func(t *testing.T) { + configPath := filepath.Join(t.TempDir(), ".codex", "config.toml") + oldExecutable := codexAppCodexExecutable + oldRun := codexAppRunDebugModels + codexAppCodexExecutable = func() (string, error) { return "/test/codex", nil } + codexAppRunDebugModels = func(_ string, _ string, bundled bool) ([]byte, error) { + if !bundled { + return nil, errors.New("offline") + } + return []byte(`{"models":[{"slug":"gpt-bundled"}]}`), nil + } + t.Cleanup(func() { + codexAppCodexExecutable = oldExecutable + codexAppRunDebugModels = oldRun + }) + + data, err := defaultCodexAppNativeModelCatalog(configPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), `"gpt-bundled"`) { + t.Fatalf("bundled catalog = %s", data) + } + }) +} + +func TestCodexAppConfigureMarksOnlyOllamaModelsForNonChatGPTSessions(t *testing.T) { + setTestHome(t, t.TempDir()) + + if err := (&CodexApp{}).ConfigureWithModels("llama3.2", testLaunchModels("llama3.2", "qwen3:8b")); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(mustCodexAppModelCatalogPath(t)) + if err != nil { + t.Fatal(err) + } + var catalog struct { + Models []map[string]any `json:"models"` + } + if err := json.Unmarshal(data, &catalog); err != nil { + t.Fatal(err) + } + if got := catalogSlugs(catalog.Models); strings.Join(got, ",") != "llama3.2,qwen3:8b,gpt-5.6-sol" { + t.Fatalf("catalog slugs = %v, want Ollama and ChatGPT models", got) + } + var nonChatGPTSlugs []string + for _, entry := range catalog.Models { + slug, _ := entry["slug"].(string) + if supported, _ := entry["supported_in_api"].(bool); supported { + nonChatGPTSlugs = append(nonChatGPTSlugs, slug) + } + } + if got := strings.Join(nonChatGPTSlugs, ","); got != "llama3.2,qwen3:8b" { + t.Fatalf("non-ChatGPT model slugs = %q, want only selected Ollama models", got) + } +} + +func TestCodexAppConfigureKeepsPickerOrderWhenPrimaryDiffers(t *testing.T) { + want := []string{ + "kimi-k3:cloud", + "glm-5.3:cloud", + "glm-5.3-flash:cloud", + "deepseek-v4-flash:cloud", + "gemma4:31b-cloud", + } + for _, primary := range []string{"glm-5.3-flash:cloud", "gemma4:31b-cloud"} { + t.Run(primary, func(t *testing.T) { + setTestHome(t, t.TempDir()) + app := &CodexApp{} + if err := app.ConfigureWithModels(primary, testLaunchModels(want...)); err != nil { + t.Fatal(err) + } + if got := app.CurrentModel(); got != primary { + t.Errorf("configured default = %q, want %q", got, primary) + } + data, err := os.ReadFile(mustCodexAppModelCatalogPath(t)) + if err != nil { + t.Fatal(err) + } + var catalog struct { + Models []struct { + Slug string `json:"slug"` + Priority int `json:"priority"` + } `json:"models"` + } + if err := json.Unmarshal(data, &catalog); err != nil { + t.Fatal(err) + } + if len(catalog.Models) != len(want)+1 { + t.Fatalf("catalog = %#v, want five Ollama models and the native model", catalog) + } + for i, model := range catalog.Models { + if i < len(want) && model.Slug != want[i] { + t.Errorf("model %d = %q, want %q", i, model.Slug, want[i]) + } + if i > 0 && model.Priority <= catalog.Models[i-1].Priority { + t.Errorf("model %q priority = %d, want after %q", model.Slug, model.Priority, catalog.Models[i-1].Slug) + } + } + }) + } +} + +func TestCodexAppRouterHealth(t *testing.T) { + t.Run("ready", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != proxy.CodexDesktopPathPrefix+"/_health" { + t.Fatalf("health path = %q", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + t.Setenv("OLLAMA_HOST", server.URL) + + if err := defaultCodexAppRouterHealth(); err != nil { + t.Fatal(err) + } + }) + + t.Run("old server", func(t *testing.T) { + server := httptest.NewServer(http.NotFoundHandler()) + defer server.Close() + t.Setenv("OLLAMA_HOST", server.URL) + + err := defaultCodexAppRouterHealth() + if err == nil || !strings.Contains(err.Error(), "does not include ChatGPT routing") { + t.Fatalf("health error = %v", err) + } + }) +} + +func TestCodexAppDesktopChecksRouterBeforeChangingProfile(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withCodexAppPlatform(t, "darwin") + healthErr := errors.New("router unavailable") + withCodexAppRouterHealth(t, func() error { return healthErr }) + + configPath := filepath.Join(tmpDir, ".codex", "config.toml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + original := "model = \"gpt-5.5\"\nmodel_provider = \"openai\"\n" + if err := os.WriteFile(configPath, []byte(original), 0o600); err != nil { + t.Fatal(err) + } + + openCalls := 0 + quitCalls := 0 + oldCanOpenID := codexAppCanOpenID + codexAppCanOpenID = func() bool { return true } + t.Cleanup(func() { codexAppCanOpenID = oldCanOpenID }) + withCodexAppProcessHooks(t, + func() bool { return true }, + func() error { quitCalls++; return nil }, + func() error { openCalls++; return nil }, + ) + + err := (&CodexApp{}).UseOllamaFromDesktop("qwen3:8b", testLaunchModels("qwen3:8b"), false) + if !errors.Is(err, healthErr) { + t.Fatalf("UseOllamaFromDesktop error = %v", err) + } + if quitCalls != 0 || openCalls != 0 { + t.Fatalf("process calls = quit %d, open %d; want none", quitCalls, openCalls) + } + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + if string(data) != original { + t.Fatalf("config changed before router health check: %q", data) + } +} + +func TestCodexAppDesktopRequiresRestartConfirmationBeforeChangingProfile(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withCodexAppPlatform(t, "darwin") + withCodexAppRouterHealth(t, func() error { return nil }) + + configPath := filepath.Join(tmpDir, ".codex", "config.toml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + original := "model = \"gpt-5.5\"\nmodel_provider = \"openai\"\n" + if err := os.WriteFile(configPath, []byte(original), 0o600); err != nil { + t.Fatal(err) + } + + openCalls := 0 + quitCalls := 0 + oldCanOpenID := codexAppCanOpenID + codexAppCanOpenID = func() bool { return true } + t.Cleanup(func() { codexAppCanOpenID = oldCanOpenID }) + withCodexAppProcessHooks(t, + func() bool { return true }, + func() error { quitCalls++; return nil }, + func() error { openCalls++; return nil }, + ) + + err := (&CodexApp{}).UseOllamaFromDesktop("qwen3:8b", testLaunchModels("qwen3:8b"), false) + if !errors.Is(err, ErrCodexAppRestartConfirmationRequired) { + t.Fatalf("UseOllamaFromDesktop error = %v, want restart confirmation", err) + } + if quitCalls != 0 || openCalls != 0 { + t.Fatalf("process calls = quit %d, open %d; want none", quitCalls, openCalls) + } + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + if string(data) != original { + t.Fatalf("config changed before restart confirmation: %q", data) + } +} + +func TestCodexAppDesktopRequiresConsentBeforeStoppingLegacyProfile(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withCodexAppPlatform(t, "darwin") + withCodexAppRouterHealth(t, func() error { return nil }) + + configPath := filepath.Join(tmpDir, ".codex", "config.toml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + original := "model = \"gpt-5.5\"\nmodel_provider = \"openai\"\n" + if err := os.WriteFile(configPath, []byte(original), 0o600); err != nil { + t.Fatal(err) + } + + oldCanOpenID := codexAppCanOpenID + oldProfileRunning := codexAppProfileIsRunning + oldStopProfile := codexAppStopProfile + codexAppCanOpenID = func() bool { return true } + codexAppProfileIsRunning = func() bool { return true } + stopCalls := 0 + codexAppStopProfile = func() error { stopCalls++; return nil } + t.Cleanup(func() { + codexAppCanOpenID = oldCanOpenID + codexAppProfileIsRunning = oldProfileRunning + codexAppStopProfile = oldStopProfile + }) + withCodexAppProcessHooks(t, + func() bool { return false }, + func() error { return nil }, + func() error { return nil }, + ) + + err := (&CodexApp{}).UseOllamaFromDesktop("qwen3:8b", testLaunchModels("qwen3:8b"), false) + if !errors.Is(err, ErrCodexAppRestartConfirmationRequired) { + t.Fatalf("UseOllamaFromDesktop error = %v, want restart confirmation", err) + } + if stopCalls != 0 { + t.Fatalf("legacy profile stops before confirmation = %d, want none", stopCalls) + } + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + if string(data) != original { + t.Fatalf("config changed before legacy profile consent: %q", data) + } +} + +func TestCodexAppDesktopRechecksRestartConfirmationBeforeProfileWrite(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withCodexAppPlatform(t, "darwin") + withCodexAppRouterHealth(t, func() error { return nil }) + + configPath := filepath.Join(tmpDir, ".codex", "config.toml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + original := "model = \"gpt-5.5\"\nmodel_provider = \"openai\"\n" + if err := os.WriteFile(configPath, []byte(original), 0o600); err != nil { + t.Fatal(err) + } + + runningChecks := 0 + openCalls := 0 + quitCalls := 0 + oldCanOpenID := codexAppCanOpenID + codexAppCanOpenID = func() bool { return true } + t.Cleanup(func() { codexAppCanOpenID = oldCanOpenID }) + withCodexAppProcessHooks(t, + func() bool { + runningChecks++ + return runningChecks > 1 + }, + func() error { quitCalls++; return nil }, + func() error { openCalls++; return nil }, + ) + + err := (&CodexApp{}).UseOllamaFromDesktop("qwen3:8b", testLaunchModels("qwen3:8b"), false) + if !errors.Is(err, ErrCodexAppRestartConfirmationRequired) { + t.Fatalf("UseOllamaFromDesktop error = %v, want late restart confirmation", err) + } + if quitCalls != 0 || openCalls != 0 { + t.Fatalf("process calls = quit %d, open %d; want none", quitCalls, openCalls) + } + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + if string(data) != original { + t.Fatalf("config changed after ChatGPT opened during preparation: %q", data) + } +} + +func TestCodexAppDesktopUsesAndRestoresRegularProfile(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withCodexAppPlatform(t, "darwin") + withCodexAppRouterHealth(t, func() error { return nil }) + t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434") + + configPath := filepath.Join(tmpDir, ".codex", "config.toml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + original := "model = \"gpt-5.5\"\nmodel_provider = \"openai\"\n" + if err := os.WriteFile(configPath, []byte(original), 0o600); err != nil { + t.Fatal(err) + } + authPath := filepath.Join(filepath.Dir(configPath), "auth.json") + auth := []byte(`{"tokens":{"access_token":"same-profile"}}`) + if err := os.WriteFile(authPath, auth, 0o600); err != nil { + t.Fatal(err) + } + + openCalls := 0 + oldCanOpenID := codexAppCanOpenID + codexAppCanOpenID = func() bool { return true } + t.Cleanup(func() { codexAppCanOpenID = oldCanOpenID }) + withCodexAppProcessHooks(t, + func() bool { return false }, + func() error { return nil }, + func() error { openCalls++; return nil }, + ) + + app := &CodexApp{} + if err := app.UseOllamaFromDesktop("qwen3:8b", testLaunchModels("qwen3:8b", "glm-5.3-flash:cloud"), false); err != nil { + t.Fatal(err) + } + if !app.OllamaConfigured() || app.CurrentModel() != "qwen3:8b" { + t.Fatalf("regular profile was not configured for Ollama") + } + if got, err := os.ReadFile(authPath); err != nil || string(got) != string(auth) { + t.Fatalf("shared auth changed: %q, %v", got, err) + } + if openCalls != 1 { + t.Fatalf("open calls = %d, want 1", openCalls) + } + + if err := app.RestoreFromDesktop(false); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(string(data)) != strings.TrimSpace(original) { + t.Fatalf("restored config = %q, want %q", data, original) + } + if openCalls != 1 { + t.Fatalf("stopped ChatGPT should remain stopped on restore; open calls = %d", openCalls) + } +} + +func TestCodexAppDesktopUpdatesStoppedProfileWithoutOpening(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withCodexAppPlatform(t, "darwin") + withCodexAppRouterHealth(t, func() error { return nil }) + t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434") + + configPath := filepath.Join(tmpDir, ".codex", "config.toml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configPath, []byte("model = \"gpt-5.5\"\n"), 0o600); err != nil { + t.Fatal(err) + } + + openCalls := 0 + oldCanOpenID := codexAppCanOpenID + codexAppCanOpenID = func() bool { return true } + t.Cleanup(func() { codexAppCanOpenID = oldCanOpenID }) + withCodexAppProcessHooks(t, + func() bool { return false }, + func() error { return nil }, + func() error { openCalls++; return nil }, + ) + + app := &CodexApp{} + if err := app.UpdateOllamaModelsFromDesktop("qwen3:8b", testLaunchModels("qwen3:8b"), false); err != nil { + t.Fatal(err) + } + if !app.OllamaConfigured() || app.CurrentModel() != "qwen3:8b" { + t.Fatal("stopped regular profile was not updated for Ollama") + } + if openCalls != 0 { + t.Fatalf("open calls = %d, want 0", openCalls) + } +} + +func TestCodexAppManagedAuthLifecycle(t *testing.T) { + t.Run("signed out user gets local auth that restore removes", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434") + + app := &CodexApp{} + if err := app.ConfigureWithModels("qwen3:8b", testLaunchModels("qwen3:8b")); err != nil { + t.Fatal(err) + } + + authPath := filepath.Join(tmpDir, ".codex", "auth.json") + data, err := os.ReadFile(authPath) + if err != nil { + t.Fatal(err) + } + var auth codexAppManagedAuth + if err := json.Unmarshal(data, &auth); err != nil { + t.Fatal(err) + } + if auth.AuthMode != "apikey" || auth.OpenAIAPIKey != proxy.CodexDesktopManagedAPIKey { + t.Fatalf("managed auth = %+v", auth) + } + info, err := os.Stat(authPath) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("auth mode = %o, want 600", got) + } + + if err := restoreCodexAppProfile(); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(authPath); !os.IsNotExist(err) { + t.Fatalf("managed auth should be removed on restore, err=%v", err) + } + }) + + t.Run("existing auth is preserved", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434") + authPath := filepath.Join(tmpDir, ".codex", "auth.json") + if err := os.MkdirAll(filepath.Dir(authPath), 0o700); err != nil { + t.Fatal(err) + } + original := []byte(`{"auth_mode":"chatgpt","tokens":{"access_token":"user-owned"}}`) + if err := os.WriteFile(authPath, original, 0o600); err != nil { + t.Fatal(err) + } + + app := &CodexApp{} + if err := app.ConfigureWithModels("qwen3:8b", testLaunchModels("qwen3:8b")); err != nil { + t.Fatal(err) + } + if got, err := os.ReadFile(authPath); err != nil || string(got) != string(original) { + t.Fatalf("auth after configure = %q, %v; want %q", got, err, original) + } + if err := restoreCodexAppProfile(); err != nil { + t.Fatal(err) + } + if got, err := os.ReadFile(authPath); err != nil || string(got) != string(original) { + t.Fatalf("auth after restore = %q, %v; want %q", got, err, original) + } + }) + + t.Run("real login that replaces local auth is preserved", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434") + + app := &CodexApp{} + if err := app.ConfigureWithModels("qwen3:8b", testLaunchModels("qwen3:8b")); err != nil { + t.Fatal(err) + } + authPath := filepath.Join(tmpDir, ".codex", "auth.json") + replacement := []byte(`{"auth_mode":"apikey","OPENAI_API_KEY":"sk-user-owned"}`) + if err := os.WriteFile(authPath, replacement, 0o600); err != nil { + t.Fatal(err) + } + + if err := restoreCodexAppProfile(); err != nil { + t.Fatal(err) + } + if got, err := os.ReadFile(authPath); err != nil || string(got) != string(replacement) { + t.Fatalf("replacement auth after restore = %q, %v; want %q", got, err, replacement) + } + }) +} + +func TestCodexAppDesktopAppliesProfileAfterRunningAppExits(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withCodexAppPlatform(t, "darwin") + withCodexAppRouterHealth(t, func() error { return nil }) + t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434") + + configPath := filepath.Join(tmpDir, ".codex", "config.toml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + original := "model = \"gpt-5.5\"\nmodel_provider = \"openai\"\n" + if err := os.WriteFile(configPath, []byte(original), 0o600); err != nil { + t.Fatal(err) + } + + running := true + events := []string{} + oldCanOpenID := codexAppCanOpenID + codexAppCanOpenID = func() bool { return true } + t.Cleanup(func() { codexAppCanOpenID = oldCanOpenID }) + withCodexAppProcessHooks(t, + func() bool { return running }, + func() error { + data, err := os.ReadFile(configPath) + if err != nil { + return err + } + if string(data) != original { + return fmt.Errorf("config changed before ChatGPT exited: %s", data) + } + events = append(events, "quit") + running = false + return nil + }, + func() error { + events = append(events, "open") + running = true + return nil + }, + ) + + if err := (&CodexApp{}).UseOllamaFromDesktop("qwen3:8b", testLaunchModels("qwen3:8b"), true); err != nil { + t.Fatal(err) + } + if !slices.Equal(events, []string{"quit", "open"}) { + t.Fatalf("events = %v, want quit then open", events) + } + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + if got, ok := codexRootStringValueOK(string(data), codexRootModelProviderKey); ok { + t.Fatalf("model provider = %q, want omitted built-in default after exit:\n%s", got, data) + } + if got := codexRootStringValue(string(data), codexRootOpenAIBaseURLKey); got != "http://127.0.0.1:11434/api/codex/v1" { + t.Fatalf("openai_base_url = %q, want Codex router after exit:\n%s", got, data) + } +} + +func TestCodexAppDesktopRestartRepairsCatalogWithoutLiveInventory(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + withCodexAppPlatform(t, "darwin") + t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434") + + app := &CodexApp{} + if err := app.ConfigureWithModels("qwen3:8b", testLaunchModels("qwen3:8b")); err != nil { + t.Fatal(err) + } + configPath := filepath.Join(tmpDir, ".codex", "config.toml") + original, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + catalogPath := mustCodexAppModelCatalogPath(t) + catalogData, err := os.ReadFile(catalogPath) + if err != nil { + t.Fatal(err) + } + var staleCatalog struct { + Models []map[string]any `json:"models"` + } + if err := json.Unmarshal(catalogData, &staleCatalog); err != nil { + t.Fatal(err) + } + foundNative := false + for _, model := range staleCatalog.Models { + if model["slug"] == "gpt-5.6-sol" { + foundNative = true + model["supported_in_api"] = true + } + } + if !foundNative { + t.Fatal("test catalog has no native model") + } + catalogData, err = json.Marshal(staleCatalog) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(catalogPath, catalogData, 0o600); err != nil { + t.Fatal(err) + } + + running := true + quitCalls := 0 + openCalls := 0 + oldCanOpenID := codexAppCanOpenID + codexAppCanOpenID = func() bool { return true } + t.Cleanup(func() { codexAppCanOpenID = oldCanOpenID }) + withCodexAppProcessHooks(t, + func() bool { return running }, + func() error { + quitCalls++ + running = false + return nil + }, + func() error { + openCalls++ + running = true + return nil + }, + ) + + if err := app.RestartFromDesktop(false); !errors.Is(err, ErrCodexAppRestartConfirmationRequired) { + t.Fatalf("RestartFromDesktop error = %v, want restart confirmation", err) + } + if quitCalls != 0 || openCalls != 0 { + t.Fatalf("process calls before confirmation = quit %d, open %d", quitCalls, openCalls) + } + if err := app.RestartFromDesktop(true); err != nil { + t.Fatal(err) + } + if quitCalls != 1 || openCalls != 1 { + t.Fatalf("process calls = quit %d, open %d; want one restart", quitCalls, openCalls) + } + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(data, original) { + t.Fatal("restart changed the existing ChatGPT profile") + } + catalogData, err = os.ReadFile(catalogPath) + if err != nil { + t.Fatal(err) + } + var repairedCatalog struct { + Models []map[string]any `json:"models"` + } + if err := json.Unmarshal(catalogData, &repairedCatalog); err != nil { + t.Fatal(err) + } + foundNative = false + foundOllama := false + for _, model := range repairedCatalog.Models { + if model["slug"] == "qwen3:8b" { + foundOllama = true + if model["supported_in_api"] != true { + t.Fatalf("Ollama model supported_in_api = %v, want true", model["supported_in_api"]) + } + } + if model["slug"] != "gpt-5.6-sol" { + continue + } + foundNative = true + if model["supported_in_api"] != false { + t.Fatalf("native model supported_in_api = %v, want false", model["supported_in_api"]) + } + } + if !foundNative { + t.Fatal("repaired catalog has no native model") + } + if !foundOllama { + t.Fatal("repaired catalog has no Ollama model") + } +} + +func TestCodexAppCountsOnlyOllamaRequestsInRegularProfile(t *testing.T) { + setTestHome(t, t.TempDir()) + requestLine := func(at time.Time, kinds ...string) []byte { + line, err := json.Marshal(map[string]any{ + "timestamp": at, + "type": "response_item", + "payload": map[string]any{ + "type": "message", + "role": "user", + "internal_chat_message_metadata_passthrough": map[string]any{ + "content_item_kinds": kinds, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + return append(line, '\n') + } + turnContextLine := func(at time.Time, model string) []byte { + line, err := json.Marshal(map[string]any{ + "timestamp": at, + "type": "turn_context", + "payload": map[string]any{ + "model": model, + }, + }) + if err != nil { + t.Fatal(err) + } + return append(line, '\n') + } + + if err := resetCodexAppRegularProfileRequestCount(); err != nil { + t.Fatal(err) + } + startPath, err := codexAppRegularProfileSessionStartPath() + if err != nil { + t.Fatal(err) + } + startData, err := os.ReadFile(startPath) + if err != nil { + t.Fatal(err) + } + start, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(string(startData))) + if err != nil { + t.Fatal(err) + } + configPath, err := codexConfigPath() + if err != nil { + t.Fatal(err) + } + regularSessionPath := filepath.Join(filepath.Dir(configPath), "sessions", "2026", "08", "28", "rollout.jsonl") + if err := os.MkdirAll(filepath.Dir(regularSessionPath), 0o700); err != nil { + t.Fatal(err) + } + routingCatalog := []byte(`{"models":[{"slug":"qwen3:8b"}]}`) + if err := os.WriteFile(codexAppRoutingCatalogPathForConfig(configPath), routingCatalog, 0o600); err != nil { + t.Fatal(err) + } + regularLines := append(turnContextLine(start.Add(time.Second), "qwen3:8b"), requestLine(start.Add(2*time.Second), "user.text")...) + regularLines = append(regularLines, turnContextLine(start.Add(3*time.Second), "gpt-5.6-sol")...) + regularLines = append(regularLines, requestLine(start.Add(4*time.Second), "user.text")...) + regularLines = append(regularLines, turnContextLine(start.Add(5*time.Second), "qwen3:8b:latest")...) + regularLines = append(regularLines, requestLine(start.Add(6*time.Second), "user.text")...) + if err := os.WriteFile(regularSessionPath, regularLines, 0o600); err != nil { + t.Fatal(err) + } + if got := codexAppRegularProfileRequestCount(); got != 2 { + t.Fatalf("regular profile Ollama request count = %d, want 2", got) + } + + file, err := os.OpenFile(regularSessionPath, os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + t.Fatal(err) + } + nativeTurn := append(turnContextLine(start.Add(7*time.Second), "gpt-5.6-sol"), requestLine(start.Add(8*time.Second), "user.text")...) + if _, err := file.Write(nativeTurn); err != nil { + _ = file.Close() + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + if got := codexAppRegularProfileRequestCount(); got != 2 { + t.Fatalf("regular profile count after native request = %d, want 2", got) + } + + file, err = os.OpenFile(regularSessionPath, os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + t.Fatal(err) + } + ollamaTurn := append(turnContextLine(start.Add(9*time.Second), "qwen3:8b"), requestLine(start.Add(10*time.Second), "user.text")...) + if _, err := file.Write(ollamaTurn); err != nil { + _ = file.Close() + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + if got := codexAppRegularProfileRequestCount(); got != 3 { + t.Fatalf("incremental regular profile Ollama request count = %d, want 3", got) + } +} + +func TestCodexAppLegacyProfileCleanupRejectsStalePID(t *testing.T) { + setTestHome(t, t.TempDir()) + withCodexAppPlatform(t, "darwin") + if err := writeCodexAppOllamaProfilePID(424242); err != nil { + t.Fatal(err) + } + + oldExecutable := codexAppProfileExecutable + oldCommand := codexAppProcessCommand + codexAppProfileExecutable = func() (string, error) { return "/Applications/ChatGPT.app/Contents/MacOS/ChatGPT", nil } + codexAppProcessCommand = func(int) (string, error) { return "/usr/bin/unrelated --serve", nil } + t.Cleanup(func() { + codexAppProfileExecutable = oldExecutable + codexAppProcessCommand = oldCommand + }) + + err := defaultCodexAppStopOllamaProfile() + if err == nil || !strings.Contains(err.Error(), "identity could not be verified") { + t.Fatalf("stale PID error = %v, want identity verification", err) + } + if _, ok := codexAppOllamaProfilePID(); ok { + t.Fatal("stale PID was not cleared") + } +} + func TestCodexAppSupportedPlatforms(t *testing.T) { for _, goos := range []string{"darwin", "windows"} { t.Run(goos, func(t *testing.T) { @@ -180,7 +1079,7 @@ func TestChatGPTMissingAppGivesDownloadRecovery(t *testing.T) { } } -func TestCodexAppConfigureActivatesOllamaProviderWithoutLegacyProfile(t *testing.T) { +func TestCodexAppConfigureAddsOllamaModelsToBuiltInProvider(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) t.Setenv("OLLAMA_HOST", "http://127.0.0.1:9999") @@ -215,12 +1114,8 @@ func TestCodexAppConfigureActivatesOllamaProviderWithoutLegacyProfile(t *testing for _, want := range []string{ `model = "llama3.2"`, - fmt.Sprintf(`model_provider = %q`, codexAppProfileName), fmt.Sprintf(`model_catalog_json = %q`, catalogPath), - codexProviderHeaderFor(codexAppProfileName), - `name = "Ollama"`, - `base_url = "http://127.0.0.1:9999/v1/"`, - `wire_api = "responses"`, + `openai_base_url = "http://127.0.0.1:9999/api/codex/v1"`, `[profiles.default]`, } { if !strings.Contains(content, want) { @@ -230,9 +1125,15 @@ func TestCodexAppConfigureActivatesOllamaProviderWithoutLegacyProfile(t *testing if got, ok := codexRootStringValueOK(content, "profile"); ok { t.Fatalf("legacy root profile should be removed, got %q in:\n%s", got, content) } + if got, ok := codexRootStringValueOK(content, codexRootModelProviderKey); ok { + t.Fatalf("root model_provider = %q, want omitted built-in default in:\n%s", got, content) + } if strings.Contains(content, codexProfileHeaderFor(codexAppProfileName)) { t.Fatalf("legacy app profile section should not be generated, got:\n%s", content) } + if strings.Contains(content, codexProviderHeaderFor(codexAppProfileName)) { + t.Fatalf("custom app provider should not be generated, got:\n%s", content) + } if got := c.CurrentModel(); got != "llama3.2" { t.Fatalf("CurrentModel = %q, want llama3.2", got) } @@ -254,8 +1155,96 @@ func TestCodexAppConfigureActivatesOllamaProviderWithoutLegacyProfile(t *testing if err := json.Unmarshal(catalogData, &catalog); err != nil { t.Fatalf("catalog should be valid JSON: %v", err) } - if got := catalogSlugs(catalog.Models); strings.Join(got, ",") != "llama3.2,qwen3:8b" { - t.Fatalf("catalog slugs = %v, want fallback models", got) + if got := catalogSlugs(catalog.Models); strings.Join(got, ",") != "llama3.2,qwen3:8b,gpt-5.6-sol" { + t.Fatalf("catalog slugs = %v, want Ollama models followed by native models", got) + } + ollamaEntry := catalog.Models[0] + for key, want := range map[string]any{ + "shell_type": "unified_exec", + "supported_in_api": true, + "include_skills_usage_instructions": true, + "include_plugin_usage_instructions": true, + "include_apps_usage_instructions": true, + "supports_reasoning_summary_parameter": false, + } { + if got := ollamaEntry[key]; got != want { + t.Fatalf("Ollama catalog %s = %#v, want %#v", key, got, want) + } + } + truncationPolicy, ok := ollamaEntry["truncation_policy"].(map[string]any) + if !ok || truncationPolicy["mode"] != "tokens" { + t.Fatalf("Ollama truncation policy = %#v, want token mode", ollamaEntry["truncation_policy"]) + } + if _, ok := ollamaEntry["tool_mode"]; ok { + t.Fatalf("Ollama catalog must not opt into a native-only code tool mode: %#v", ollamaEntry) + } +} + +func TestCodexAppReasoningEffortsExposeOffAndMaxWithoutDroppingUserChoices(t *testing.T) { + tests := []struct { + name string + text string + efforts []string + want []string + }{ + { + name: "new desktop setting", + text: `model = "gpt-5.6-sol"` + "\n", + efforts: []string{"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"}, + want: []string{"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"}, + }, + { + name: "existing section and multiline value", + text: "[desktop]\n" + + "enabled-reasoning-efforts = [\n" + + " \"low\",\n" + + " \"high\",\n" + + "]\n" + + `theme = "system"` + "\n", + efforts: []string{"none", "low", "high", "max"}, + want: []string{"none", "low", "high", "max"}, + }, + { + name: "existing dotted root value", + text: `desktop.enabled-reasoning-efforts = ["low", "ultra"]` + "\n", + efforts: []string{"none", "low", "max", "ultra"}, + want: []string{"none", "low", "max", "ultra"}, + }, + { + name: "section without trailing newline", + text: "[desktop]\n" + `theme = "system"`, + efforts: []string{"none", "medium", "max"}, + want: []string{"none", "medium", "max"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + updated := codexAppSetReasoningEfforts(tt.text, tt.efforts) + config, err := codexParseConfig(updated) + if err != nil { + t.Fatalf("updated config is invalid: %v\n%s", err, updated) + } + got, ok := codexAppConfigReasoningEfforts(config) + if !ok || !slices.Equal(got, tt.want) { + t.Fatalf("reasoning efforts = %v, %v; want %v", got, ok, tt.want) + } + if count := strings.Count(updated, codexAppReasoningEffortsKey); count != 1 { + t.Fatalf("reasoning setting count = %d, want 1 in:\n%s", count, updated) + } + if strings.Contains(tt.text, `theme = "system"`) { + if theme, ok := config.String("desktop", "theme"); !ok || theme != "system" { + t.Fatalf("desktop theme = %q, %v; want preserved", theme, ok) + } + } + }) + } + + if got, want := codexAppMergeReasoningEfforts([]string{"low", "high", "ultra", "persistent"}), []string{"none", "low", "high", "max", "ultra", "persistent"}; !slices.Equal(got, want) { + t.Fatalf("merged reasoning efforts = %v, want %v", got, want) + } + if got, want := codexAppReasoningEffortsForConfig(""), []string{"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"}; !slices.Equal(got, want) { + t.Fatalf("default desktop reasoning efforts = %v, want %v", got, want) } } @@ -308,8 +1297,14 @@ func TestCodexAppConfigureUsesAppSpecificProfileWithoutTouchingCLIProfile(t *tes if got := codexRootStringValue(content, "model"); got != "llama3.2" { t.Fatalf("root model = %q, want llama3.2", got) } - if got := codexSectionStringValue(content, codexProviderHeaderFor(codexAppProfileName), "base_url"); got != "http://127.0.0.1:9999/v1/" { - t.Fatalf("app provider base URL = %q", got) + if got := codexRootStringValue(content, codexRootOpenAIBaseURLKey); got != "http://127.0.0.1:9999/api/codex/v1" { + t.Fatalf("openai_base_url = %q, want loopback Codex router", got) + } + if got, ok := codexRootStringValueOK(content, codexRootModelProviderKey); ok { + t.Fatalf("model_provider = %q, want omitted built-in default", got) + } + if strings.Contains(content, codexProviderHeaderFor(codexAppProfileName)) { + t.Fatalf("custom app provider should be removed, got:\n%s", content) } assertBackupContains(t, filepath.Join(fileutil.BackupDir(), codexAppIntegrationName, "config.toml.*"), `profile = "default"`) } @@ -366,6 +1361,60 @@ func TestCodexAppConfigureIsIdempotentAndPreservesUnrelatedProvider(t *testing.T } } +func TestCodexAppConfigurePersistsAutoReviewModel(t *testing.T) { + for _, test := range []struct { + name string + configured string + want string + }{ + {name: "selected model default", want: "glm-5.3:cloud"}, + {name: "native explicit", configured: "native"}, + {name: "chatgpt explicit", configured: "chatgpt"}, + {name: "selected cloud model", configured: "selected", want: "glm-5.3:cloud"}, + {name: "ollama alias", configured: "ollama", want: "glm-5.3:cloud"}, + {name: "explicit configured model", configured: "qwen3:8b", want: "qwen3:8b"}, + {name: "explicit configured cloud model", configured: "deepseek-v4-flash:cloud", want: "deepseek-v4-flash:cloud"}, + } { + t.Run(test.name, func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv(codexAppAutoReviewModelEnv, test.configured) + + if err := (&CodexApp{}).ConfigureWithModels("glm-5.3:cloud", testLaunchModels("glm-5.3:cloud", "deepseek-v4-flash:cloud", "qwen3:8b")); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(codexAppRoutingCatalogPathForConfig(filepath.Join(tmpDir, ".codex", "config.toml"))) + if err != nil { + t.Fatal(err) + } + var catalog struct { + AutoReviewModel string `json:"auto_review_model"` + } + if err := json.Unmarshal(data, &catalog); err != nil { + t.Fatal(err) + } + if catalog.AutoReviewModel != test.want { + t.Fatalf("auto_review_model = %q, want %q", catalog.AutoReviewModel, test.want) + } + }) + } +} + +func TestCodexAppConfigureRejectsUnknownAutoReviewModelBeforeWriting(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv(codexAppAutoReviewModelEnv, "missing-model") + + err := (&CodexApp{}).ConfigureWithModels("llama3.2", testLaunchModels("llama3.2")) + if err == nil || !strings.Contains(err.Error(), codexAppAutoReviewModelEnv) { + t.Fatalf("ConfigureWithModels error = %v, want invalid Auto-review model", err) + } + if _, err := os.Stat(filepath.Join(tmpDir, ".codex")); !os.IsNotExist(err) { + t.Fatalf("invalid Auto-review model wrote configuration: %v", err) + } +} + func TestCodexCLIConfigRefreshLeavesCodexAppConfigActive(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) @@ -392,15 +1441,18 @@ func TestCodexCLIConfigRefreshLeavesCodexAppConfigActive(t *testing.T) { } for key, want := range map[string]string{ "model": "llama3.2", - "model_provider": codexAppProfileName, "model_catalog_json": appCatalogPath, + "openai_base_url": "http://127.0.0.1:9999/api/codex/v1", } { if got := codexRootStringValue(content, key); got != want { t.Fatalf("root %s = %q, want %q in:\n%s", key, got, want, content) } } - if got := codexSectionStringValue(content, codexProviderHeaderFor(codexAppProfileName), "base_url"); got != "http://127.0.0.1:9999/v1/" { - t.Fatalf("app provider base URL = %q", got) + if got, ok := codexRootStringValueOK(content, codexRootModelProviderKey); ok { + t.Fatalf("root model_provider = %q, want omitted built-in default in:\n%s", got, content) + } + if strings.Contains(content, codexProviderHeaderFor(codexAppProfileName)) { + t.Fatalf("additive app config should not leave a custom provider section:\n%s", content) } cliCatalogPath := filepath.Join(tmpDir, ".codex", "model.json") if strings.Contains(content, codexProfileHeader()) { @@ -439,8 +1491,21 @@ func TestCodexCLIConfigRefreshLeavesCodexAppConfigActive(t *testing.T) { if err := json.Unmarshal(appCatalogData, &appCatalog); err != nil { t.Fatalf("app catalog should be valid JSON: %v", err) } - if got := catalogSlugs(appCatalog.Models); strings.Join(got, ",") != "llama3.2,gemma4" { - t.Fatalf("app catalog slugs = %v, want original app models", got) + if got := catalogSlugs(appCatalog.Models); strings.Join(got, ",") != "llama3.2,gemma4,gpt-5.6-sol" { + t.Fatalf("app catalog slugs = %v, want Ollama models followed by the native catalog", got) + } + routingCatalogData, err := os.ReadFile(codexAppRoutingCatalogPathForConfig(configPath)) + if err != nil { + t.Fatal(err) + } + var routingCatalog struct { + Models []map[string]any `json:"models"` + } + if err := json.Unmarshal(routingCatalogData, &routingCatalog); err != nil { + t.Fatalf("routing catalog should be valid JSON: %v", err) + } + if got := catalogSlugs(routingCatalog.Models); strings.Join(got, ",") != "llama3.2,gemma4" { + t.Fatalf("routing catalog slugs = %v, want only the selected Ollama models", got) } cliCatalogData, err := os.ReadFile(cliCatalogPath) @@ -479,11 +1544,11 @@ func TestCodexAppConfigureUsesConnectableHostForUnspecifiedBindAddress(t *testin if strings.Contains(content, codexProfileHeaderFor(codexAppProfileName)) { t.Fatalf("legacy app profile section should not be generated, got:\n%s", content) } - if got := codexSectionStringValue(content, codexProviderHeaderFor(codexAppProfileName), "base_url"); got != "http://127.0.0.1:11434/v1/" { - t.Fatalf("app provider base_url = %q, want connectable loopback URL", got) + if got := codexRootStringValue(content, codexRootOpenAIBaseURLKey); got != "http://127.0.0.1:11434/api/codex/v1" { + t.Fatalf("openai_base_url = %q, want connectable loopback router URL", got) } - if got := codexRootStringValue(content, "model_provider"); got != codexAppProfileName { - t.Fatalf("root model_provider = %q, want %q", got, codexAppProfileName) + if got, ok := codexRootStringValueOK(content, codexRootModelProviderKey); ok { + t.Fatalf("root model_provider = %q, want omitted built-in default", got) } } @@ -588,7 +1653,42 @@ func TestCodexAppCurrentModelRequiresManagedActiveProfile(t *testing.T) { } } -func TestCodexAppCurrentModelReadsManagedRootConfig(t *testing.T) { +func TestCodexAppCurrentModelRecognizesManagedRootProviderForms(t *testing.T) { + for _, tt := range []struct { + name string + providerLine string + want string + }{ + {name: "omitted default provider", want: "qwen3:8b"}, + {name: "legacy explicit provider", providerLine: `model_provider = "openai"` + "\n", want: "qwen3:8b"}, + {name: "different explicit provider", providerLine: `model_provider = "custom"` + "\n"}, + } { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434") + + configPath := filepath.Join(tmpDir, ".codex", "config.toml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + content := "" + + `model = "qwen3:8b"` + "\n" + + tt.providerLine + + fmt.Sprintf(`model_catalog_json = %q`, mustWriteCodexAppTestCatalog(t, "qwen3:8b")) + "\n" + + `openai_base_url = "http://127.0.0.1:11434/api/codex/v1"` + "\n" + if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + if got := (&CodexApp{}).CurrentModel(); got != tt.want { + t.Fatalf("CurrentModel = %q, want %q", got, tt.want) + } + }) + } +} + +func TestCodexAppOllamaConfiguredKeepsOffSwitchWhenCatalogIsMissing(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434") @@ -598,20 +1698,19 @@ func TestCodexAppCurrentModelReadsManagedRootConfig(t *testing.T) { t.Fatal(err) } content := "" + - `model = "qwen3:8b"` + "\n" + - fmt.Sprintf(`model_provider = %q`, codexAppProfileName) + "\n\n" + - fmt.Sprintf(`model_catalog_json = %q`, mustWriteCodexAppTestCatalog(t, "qwen3:8b")) + "\n\n" + - codexProfileHeaderFor(codexAppProfileName) + "\n" + - fmt.Sprintf(`model_provider = %q`, codexAppProfileName) + "\n" + - fmt.Sprintf(`model_catalog_json = %q`, mustCodexAppModelCatalogPath(t)) + "\n\n" + - codexProviderHeaderFor(codexAppProfileName) + "\n" + - `base_url = "http://127.0.0.1:11434/v1/"` + "\n" + `model = "glm-5.3-flash:cloud"` + "\n" + + fmt.Sprintf(`model_catalog_json = %q`, codexAppModelCatalogPathForConfig(configPath)) + "\n" + + `openai_base_url = "http://127.0.0.1:11434/api/codex/v1"` + "\n" if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil { t.Fatal(err) } - if got := (&CodexApp{}).CurrentModel(); got != "qwen3:8b" { - t.Fatalf("CurrentModel = %q, want qwen3:8b", got) + app := &CodexApp{} + if got := app.CurrentModel(); got != "" { + t.Fatalf("CurrentModel = %q, want empty without a usable catalog", got) + } + if !app.OllamaConfigured() { + t.Fatal("OllamaConfigured = false, want the damaged managed profile to remain removable") } } @@ -740,12 +1839,8 @@ func TestCodexAppCurrentModelAcceptsLatestSuffixDrift(t *testing.T) { } content := "" + `model = "llama3.2:latest"` + "\n" + - fmt.Sprintf(`model_provider = %q`, codexAppProfileName) + "\n\n" + - fmt.Sprintf(`model_catalog_json = %q`, catalogPath) + "\n\n" + - codexProviderHeaderFor(codexAppProfileName) + "\n" + - `name = "Ollama"` + "\n" + - `base_url = "http://127.0.0.1:11434/v1/"` + "\n" + - `wire_api = "responses"` + "\n" + fmt.Sprintf(`model_catalog_json = %q`, catalogPath) + "\n" + + `openai_base_url = "http://127.0.0.1:11434/api/codex/v1"` + "\n" if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil { t.Fatal(err) } @@ -760,7 +1855,7 @@ func TestCodexAppConfigurePopulatesCatalogFromEnrichedModels(t *testing.T) { setTestHome(t, tmpDir) models := []LaunchModel{ - {Name: "gemma4", ContextLength: 65536 + len("gemma4"), Capabilities: []model.Capability{model.CapabilityVision}}, + {Name: "gemma4", ContextLength: 65536 + len("gemma4"), Capabilities: []model.Capability{model.CapabilityVision, model.CapabilityThinking}, ToolCapable: true}, {Name: "qwen3:8b"}, {Name: "llama3.2"}, } @@ -783,10 +1878,10 @@ func TestCodexAppConfigurePopulatesCatalogFromEnrichedModels(t *testing.T) { t.Fatalf("catalog should be valid JSON: %v", err) } - if got := catalogSlugs(catalog.Models); strings.Join(got, ",") != "gemma4,qwen3:8b,llama3.2" { - t.Fatalf("catalog slugs = %v, want enriched models", got) + if got := catalogSlugs(catalog.Models); strings.Join(got, ",") != "gemma4,qwen3:8b,llama3.2,gpt-5.6-sol" { + t.Fatalf("catalog slugs = %v, want enriched Ollama models plus native models", got) } - for _, model := range catalog.Models { + for _, model := range catalog.Models[:3] { slug, _ := model["slug"].(string) if model["display_name"] != slug { t.Fatalf("display_name should match slug for %q: %v", slug, model["display_name"]) @@ -794,12 +1889,37 @@ func TestCodexAppConfigurePopulatesCatalogFromEnrichedModels(t *testing.T) { if model["visibility"] != "list" { t.Fatalf("visibility for %q = %v, want list", slug, model["visibility"]) } - if model["default_reasoning_level"] != nil { - t.Fatalf("default_reasoning_level for %q = %v, want nil", slug, model["default_reasoning_level"]) - } levels, ok := model["supported_reasoning_levels"].([]any) - if !ok || len(levels) != 0 { - t.Fatalf("supported_reasoning_levels for %q = %v, want empty list", slug, model["supported_reasoning_levels"]) + if !ok { + t.Fatalf("supported_reasoning_levels for %q = %T, want list", slug, model["supported_reasoning_levels"]) + } + if slug == "gemma4" { + if model["default_reasoning_level"] != "medium" { + t.Fatalf("default_reasoning_level for %q = %v, want medium", slug, model["default_reasoning_level"]) + } + wantEfforts := []string{"none", "medium"} + gotEfforts := make([]string, 0, len(levels)) + for _, level := range levels { + entry, ok := level.(map[string]any) + if !ok { + t.Fatalf("reasoning level for %q = %T, want object", slug, level) + } + effort, _ := entry["effort"].(string) + gotEfforts = append(gotEfforts, effort) + if description, _ := entry["description"].(string); description == "" { + t.Fatalf("reasoning level %q for %q has no description", effort, slug) + } + } + if strings.Join(gotEfforts, ",") != strings.Join(wantEfforts, ",") { + t.Fatalf("reasoning levels for %q = %v, want %v", slug, gotEfforts, wantEfforts) + } + } else { + if model["default_reasoning_level"] != nil { + t.Fatalf("default_reasoning_level for %q = %v, want nil", slug, model["default_reasoning_level"]) + } + if len(levels) != 0 { + t.Fatalf("supported_reasoning_levels for %q = %v, want none", slug, levels) + } } wantContext := float64(128000) wantModalities := []string{"text"} @@ -813,6 +1933,218 @@ func TestCodexAppConfigurePopulatesCatalogFromEnrichedModels(t *testing.T) { if got := catalogInputModalities(model); strings.Join(got, ",") != strings.Join(wantModalities, ",") { t.Fatalf("input_modalities for %q = %v, want %v", slug, got, wantModalities) } + wantTools := slug == "gemma4" + if got := model["supports_search_tool"]; got != wantTools { + t.Fatalf("supports_search_tool for %q = %v, want %v", slug, got, wantTools) + } + if got := model["supports_parallel_tool_calls"]; got != wantTools { + t.Fatalf("supports_parallel_tool_calls for %q = %v, want %v", slug, got, wantTools) + } + if got := model["web_search_tool_type"]; got != "text" { + t.Fatalf("web_search_tool_type for %q = %v, want text", slug, got) + } + } + + configPath, err := codexConfigPath() + if err != nil { + t.Fatal(err) + } + routingData, err := os.ReadFile(codexAppRoutingCatalogPathForConfig(configPath)) + if err != nil { + t.Fatal(err) + } + var routingCatalog struct { + Models []struct { + Slug string `json:"slug"` + Thinking struct { + Supported bool `json:"supported"` + Levels []string `json:"levels"` + Values map[string]any `json:"values"` + } `json:"thinking"` + } `json:"models"` + } + if err := json.Unmarshal(routingData, &routingCatalog); err != nil { + t.Fatalf("routing catalog should be valid JSON: %v", err) + } + if len(routingCatalog.Models) != 3 { + t.Fatalf("routing catalog models = %#v, want 3 selected Ollama models", routingCatalog.Models) + } + gemmaThinking := routingCatalog.Models[0].Thinking + if routingCatalog.Models[0].Slug != "gemma4" || !gemmaThinking.Supported || !slices.Equal(gemmaThinking.Levels, []string{"none", "medium"}) { + t.Fatalf("gemma4 routing thinking = %+v, want binary off/medium metadata", gemmaThinking) + } + if gemmaThinking.Values["none"] != false || gemmaThinking.Values["medium"] != true { + t.Fatalf("gemma4 routing thinking values = %#v, want exact false/true values", gemmaThinking.Values) + } + for _, routed := range routingCatalog.Models[1:] { + if routed.Thinking.Supported || len(routed.Thinking.Levels) != 0 { + t.Fatalf("non-thinking model %q routing metadata = %+v, want unsupported", routed.Slug, routed.Thinking) + } + } +} + +func TestCodexAppThinkingLevelsUseRecommendationsThenFallbacks(t *testing.T) { + tests := []struct { + name string + modelName string + family string + thinking bool + recommendation *api.ModelRecommendationThinking + wantInitial string + wantLevels []string + wantValues map[string]any + }{ + {name: "non-thinking model"}, + {name: "binary fallback", thinking: true, wantInitial: "medium", wantLevels: []string{"none", "medium"}, wantValues: map[string]any{"none": false, "medium": true}}, + { + name: "recommendation with adjustable strings", + recommendation: &api.ModelRecommendationThinking{Values: []any{"low", "high", "max"}, Default: "high"}, + wantInitial: "high", + wantLevels: []string{"low", "high", "max"}, + wantValues: map[string]any{"low": "low", "high": "high", "max": "max"}, + }, + { + name: "recommendation with mixed boolean and string values", + recommendation: &api.ModelRecommendationThinking{Values: []any{false, true, "max"}, Default: true}, + wantInitial: "medium", + wantLevels: []string{"none", "medium", "max"}, + wantValues: map[string]any{"none": false, "medium": true, "max": "max"}, + }, + { + name: "recommendation with binary thinking off by default", + recommendation: &api.ModelRecommendationThinking{Values: []any{false, true}, Default: false}, + wantInitial: "none", + wantLevels: []string{"none", "medium"}, + wantValues: map[string]any{"none": false, "medium": true}, + }, + { + name: "explicit non-thinking recommendation", + thinking: true, + recommendation: &api.ModelRecommendationThinking{Values: []any{false}, Default: false}, + }, + { + name: "invalid recommendation uses capability fallback", + thinking: true, + recommendation: &api.ModelRecommendationThinking{Values: []any{"low", "high"}, Default: "max"}, + wantInitial: "medium", + wantLevels: []string{"none", "medium"}, + wantValues: map[string]any{"none": false, "medium": true}, + }, + {name: "model name alone does not infer thinking", modelName: "glm-5.3-flash:cloud"}, + {name: "similar unverified tag uses fallback", modelName: "glm-5.3-flash:custom", thinking: true, wantInitial: "medium", wantLevels: []string{"none", "medium"}}, + {name: "GLM 5.3 Flash family fallback", family: "glm5_next", thinking: true, wantInitial: "max", wantLevels: []string{"low", "high", "max"}}, + {name: "GLM 5.3 family fallback", modelName: "glm-5.3:cloud", family: "glm_dsa_moe", thinking: true, wantInitial: "max", wantLevels: []string{"low", "high", "max"}}, + {name: "GPT-OSS family", family: "gpt-oss", thinking: true, wantInitial: "medium", wantLevels: []string{"low", "medium", "high"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + launchModel := LaunchModel{Name: tt.modelName, Thinking: tt.recommendation} + launchModel.Details.Family = tt.family + if tt.thinking { + launchModel.Capabilities = []model.Capability{model.CapabilityThinking} + } + metadata := codexAppModelMetadataFromLaunchModel(launchModel) + gotInitial, gotLevels := metadata.thinking.defaultLevel, metadata.thinking.levels + if gotInitial != tt.wantInitial || !slices.Equal(gotLevels, tt.wantLevels) { + t.Fatalf("thinking levels = %q, %v; want %q, %v", gotInitial, gotLevels, tt.wantInitial, tt.wantLevels) + } + if tt.wantValues != nil { + for level, want := range tt.wantValues { + if got := metadata.thinking.values[level]; got != want { + t.Fatalf("thinking value for %q = %#v, want %#v", level, got, want) + } + } + } + }) + } +} + +func TestCodexAppConfigureWritesRecommendationThinkingContract(t *testing.T) { + setTestHome(t, t.TempDir()) + model := LaunchModel{ + Name: "deepseek-v4-flash:cloud", + Thinking: &api.ModelRecommendationThinking{ + Values: []any{false, true, "max"}, + Default: true, + }, + } + if err := (&CodexApp{}).ConfigureWithModels(model.Name, []LaunchModel{model}); err != nil { + t.Fatal(err) + } + + catalogData, err := os.ReadFile(mustCodexAppModelCatalogPath(t)) + if err != nil { + t.Fatal(err) + } + var catalog struct { + Models []struct { + Slug string `json:"slug"` + DefaultReasoningLevel string `json:"default_reasoning_level"` + SupportedReasoningLevels []struct { + Effort string `json:"effort"` + } `json:"supported_reasoning_levels"` + } `json:"models"` + } + if err := json.Unmarshal(catalogData, &catalog); err != nil { + t.Fatal(err) + } + if len(catalog.Models) == 0 || catalog.Models[0].Slug != model.Name { + t.Fatalf("catalog models = %#v, want selected model first", catalog.Models) + } + if got := catalog.Models[0].DefaultReasoningLevel; got != "medium" { + t.Fatalf("default reasoning level = %q, want medium for Ollama true", got) + } + var levels []string + for _, level := range catalog.Models[0].SupportedReasoningLevels { + levels = append(levels, level.Effort) + } + if !slices.Equal(levels, []string{"none", "medium", "max"}) { + t.Fatalf("reasoning levels = %v, want none/medium/max", levels) + } + + configPath, err := codexConfigPath() + if err != nil { + t.Fatal(err) + } + routingData, err := os.ReadFile(codexAppRoutingCatalogPathForConfig(configPath)) + if err != nil { + t.Fatal(err) + } + var routing struct { + Models []struct { + Thinking struct { + Values map[string]any `json:"values"` + } `json:"thinking"` + } `json:"models"` + } + if err := json.Unmarshal(routingData, &routing); err != nil { + t.Fatal(err) + } + values := routing.Models[0].Thinking.Values + if values["none"] != false || values["medium"] != true || values["max"] != "max" { + t.Fatalf("routing thinking values = %#v, want exact endpoint values", values) + } +} + +func TestDefaultCodexAppRunDebugModelsUsesScratchWorkingDirectory(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell fixture is Unix-only") + } + executable := filepath.Join(t.TempDir(), "codex-fixture") + if err := os.WriteFile(executable, []byte("#!/bin/sh\npwd\n"), 0o755); err != nil { + t.Fatal(err) + } + scratch := t.TempDir() + output, err := defaultCodexAppRunDebugModels(executable, scratch, false) + if err != nil { + t.Fatal(err) + } + got := strings.TrimSpace(string(output)) + gotInfo, gotErr := os.Stat(got) + scratchInfo, scratchErr := os.Stat(scratch) + if gotErr != nil || scratchErr != nil || !os.SameFile(gotInfo, scratchInfo) { + t.Fatalf("working directory = %q, want scratch CODEX_HOME %q", got, scratch) } } @@ -854,8 +2186,8 @@ func TestCodexAppConfigureCatalogIncludesExactSelectedModel(t *testing.T) { if err := json.Unmarshal(data, &catalog); err != nil { t.Fatalf("catalog should be valid JSON: %v", err) } - if got := catalogSlugs(catalog.Models); strings.Join(got, ",") != "llama3.2,qwen3:8b" { - t.Fatalf("catalog slugs = %v, want exact selected model without :latest duplicate", got) + if got := catalogSlugs(catalog.Models); strings.Join(got, ",") != "llama3.2,qwen3:8b,gpt-5.6-sol" { + t.Fatalf("catalog slugs = %v, want exact selected model plus native models without :latest duplicate", got) } if got := catalog.Models[0]["context_window"]; got != float64(65_536) { t.Fatalf("selected model context_window = %v, want 65536", got) @@ -940,7 +2272,9 @@ func TestCodexAppConfigureMigratesLegacyManagedConfigWithoutPollutingRestoreStat `base_url = "http://127.0.0.1:9999/v1/"` + "\n" + `wire_api = "responses"` + "\n\n" + "[profiles.default]\n" + - `model = "gpt-5.5"` + "\n" + `model = "gpt-5.5"` + "\n\n" + + "[desktop]\n" + + `enabled-reasoning-efforts = ["low", "high"]` + "\n" if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil { t.Fatal(err) } @@ -966,6 +2300,9 @@ func TestCodexAppConfigureMigratesLegacyManagedConfigWithoutPollutingRestoreStat if state.HadModel || state.HadModelProvider || state.HadModelCatalogJSON { t.Fatalf("legacy restore state should not capture managed root values: %+v", state) } + if !state.HadDesktopReasoningEfforts || !slices.Equal(state.DesktopReasoningEfforts, []string{"low", "high"}) { + t.Fatalf("desktop reasoning restore state = (%v, %v), want original user choices", state.HadDesktopReasoningEfforts, state.DesktopReasoningEfforts) + } data, err := os.ReadFile(configPath) if err != nil { @@ -999,6 +2336,13 @@ func TestCodexAppConfigureMigratesLegacyManagedConfigWithoutPollutingRestoreStat if strings.Contains(restored, codexProfileHeaderFor(codexAppProfileName)) || strings.Contains(restored, codexProviderHeaderFor(codexAppProfileName)) { t.Fatalf("owned app config should be removed on restore, got:\n%s", restored) } + restoredConfig, err := codexParseConfig(restored) + if err != nil { + t.Fatal(err) + } + if efforts, ok := codexAppConfigReasoningEfforts(restoredConfig); !ok || !slices.Equal(efforts, []string{"low", "high"}) { + t.Fatalf("restored desktop reasoning efforts = %v, %v; want [low high]", efforts, ok) + } if openCalls != 1 { t.Fatalf("open calls = %d, want 1", openCalls) } @@ -1027,7 +2371,8 @@ func TestCodexAppRestoreRestoresPreviousProfile(t *testing.T) { "profile = \"default\"\n" + "model = \"gpt-5.5\"\n" + "model_provider = \"openai\"\n" + - "model_catalog_json = \"/tmp/original-catalog.json\"\n\n" + + "model_catalog_json = \"/tmp/original-catalog.json\"\n" + + "openai_base_url = \"https://api.openai.com/v1\"\n\n" + "[profiles.default]\n" + "model = \"gpt-5.5\"\n" if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil { @@ -1058,6 +2403,7 @@ func TestCodexAppRestoreRestoresPreviousProfile(t *testing.T) { "model": "gpt-5.5", "model_provider": "openai", "model_catalog_json": "/tmp/original-catalog.json", + "openai_base_url": "https://api.openai.com/v1", } { if got := codexRootStringValue(restored, key); got != want { t.Fatalf("root %s = %q, want %q in:\n%s", key, got, want, restored) @@ -1071,6 +2417,133 @@ func TestCodexAppRestoreRestoresPreviousProfile(t *testing.T) { } } +func TestCodexAppRestoreRestoresDesktopReasoningEffortsExactly(t *testing.T) { + tests := []struct { + name string + desktopValue string + wantOriginal []string + }{ + { + name: "existing user choices", + desktopValue: `enabled-reasoning-efforts = ["minimal", "high", "persistent"]` + "\n", + wantOriginal: []string{"minimal", "high", "persistent"}, + }, + {name: "setting originally absent"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434") + withCodexAppPlatform(t, "darwin") + withCodexAppProcessHooks(t, func() bool { return false }, func() error { return nil }, func() error { return nil }) + + configPath := filepath.Join(tmpDir, ".codex", "config.toml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + existing := `model = "gpt-5.6-sol"` + "\n\n" + + "[desktop]\n" + + tt.desktopValue + + `theme = "system"` + "\n" + if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + app := &CodexApp{} + if err := app.ConfigureWithModels("gemma4", []LaunchModel{{ + Name: "gemma4", + Capabilities: []model.Capability{model.CapabilityThinking}, + }}); err != nil { + t.Fatalf("ConfigureWithModels returned error: %v", err) + } + + managedData, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + managedConfig, err := codexParseConfig(string(managedData)) + if err != nil { + t.Fatal(err) + } + managedEfforts, ok := codexAppConfigReasoningEfforts(managedConfig) + if !ok || !slices.Contains(managedEfforts, "none") || !slices.Contains(managedEfforts, "max") { + t.Fatalf("managed reasoning efforts = %v, %v; want none and max enabled", managedEfforts, ok) + } + + if err := app.Restore(); err != nil { + t.Fatalf("Restore returned error: %v", err) + } + restoredData, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + restoredConfig, err := codexParseConfig(string(restoredData)) + if err != nil { + t.Fatal(err) + } + gotOriginal, hadOriginal := codexAppConfigReasoningEfforts(restoredConfig) + if tt.wantOriginal == nil { + if hadOriginal { + t.Fatalf("restored reasoning efforts = %v, want setting removed", gotOriginal) + } + } else if !hadOriginal || !slices.Equal(gotOriginal, tt.wantOriginal) { + t.Fatalf("restored reasoning efforts = %v, %v; want %v", gotOriginal, hadOriginal, tt.wantOriginal) + } + if theme, ok := restoredConfig.String("desktop", "theme"); !ok || theme != "system" { + t.Fatalf("desktop theme = %q, %v; want preserved", theme, ok) + } + }) + } +} + +func TestCodexAppRestorePreservesNativeModelSelectedWhileConnected(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("OLLAMA_HOST", "http://127.0.0.1:9999") + + configPath, err := codexConfigPath() + if err != nil { + t.Fatal(err) + } + catalogPath := codexAppModelCatalogPathForConfig(configPath) + routingPath := codexAppRoutingCatalogPathForConfig(configPath) + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + state := codexAppRestoreState{ + HadModel: true, + Model: "gpt-5.5", + HadModelProvider: true, + ModelProvider: "openai", + HadOpenAIBaseURL: true, + OpenAIBaseURL: "https://api.openai.com/v1", + HadModelCatalogJSON: false, + } + for _, tt := range []struct { + name string + current string + routing string + wantModel string + }{ + {name: "native selection", current: "gpt-5.6-sol", routing: `{"models":[{"slug":"glm-5.3-flash:cloud"}]}`, wantModel: "gpt-5.6-sol"}, + {name: "Ollama selection", current: "glm-5.3-flash:cloud", routing: `{"models":[{"slug":"glm-5.3-flash:cloud"}]}`, wantModel: "gpt-5.5"}, + {name: "damaged routing catalog", current: "glm-5.3-flash:cloud", routing: `{`, wantModel: "gpt-5.5"}, + } { + t.Run(tt.name, func(t *testing.T) { + if err := os.WriteFile(routingPath, []byte(tt.routing), 0o600); err != nil { + t.Fatal(err) + } + managed := fmt.Sprintf("model = %q\nmodel_catalog_json = %q\nopenai_base_url = %q\n", tt.current, catalogPath, codexAppProxyBaseURL()) + restored := codexAppRestoreRootValues(managed, state) + if got := codexRootStringValue(restored, codexRootModelKey); got != tt.wantModel { + t.Fatalf("restored model = %q, want %q in:\n%s", got, tt.wantModel, restored) + } + }) + } +} + func TestCodexAppRestoreMissingConfigRemovesRestoreState(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) @@ -1093,6 +2566,10 @@ func TestCodexAppRestoreMissingConfigRemovesRestoreState(t *testing.T) { if err := os.WriteFile(codexAppRestoreStatePath(), []byte(restoreState), 0o644); err != nil { t.Fatal(err) } + configPath := filepath.Join(tmpDir, ".codex", "config.toml") + if created, err := ensureCodexAppManagedAuth(configPath); err != nil || !created { + t.Fatalf("create managed auth = %v, %v; want true, nil", created, err) + } if err := (&CodexApp{}).Restore(); err != nil { t.Fatalf("Restore returned error: %v", err) @@ -1101,6 +2578,9 @@ func TestCodexAppRestoreMissingConfigRemovesRestoreState(t *testing.T) { if _, err := os.Stat(codexAppRestoreStatePath()); !os.IsNotExist(err) { t.Fatalf("restore state should be removed when config is missing, got err=%v", err) } + if _, err := os.Stat(filepath.Join(filepath.Dir(configPath), "auth.json")); !os.IsNotExist(err) { + t.Fatalf("managed auth should be removed when config is missing, got err=%v", err) + } if openCalls != 1 { t.Fatalf("open calls = %d, want 1", openCalls) } @@ -1556,10 +3036,6 @@ func TestCodexAppRunCtrlCAbortsEntireRestartFlow(t *testing.T) { func() error { calls = append(calls, "open"); return nil }, ) codexAppExitTimeout = 5 * time.Second - codexAppForceQuit = func() error { - calls = append(calls, "force") - return nil - } err := (&CodexApp{}).Run("qwen3.5", nil, nil) if !errors.Is(err, ErrCancelled) { @@ -1568,69 +3044,65 @@ func TestCodexAppRunCtrlCAbortsEntireRestartFlow(t *testing.T) { if !spinnerStopped { t.Fatal("expected the shared spinner to be stopped on cancel") } - // The flow must abort after quit: no force-quit, no reopen, despite the app - // still being "running" (which would otherwise trigger the force-quit path). + // The flow must abort after the graceful quit request without reopening. want := []string{"quit"} if !slices.Equal(calls, want) { t.Fatalf("calls = %v, want the whole flow to abort after quit: %v", calls, want) } } -func TestCodexAppRunForceStopsMacAfterGracefulTimeout(t *testing.T) { - withCodexAppPlatform(t, "darwin") - restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true}) - defer restoreConfirm() +func TestCodexAppRunNeverForcesAStuckAppToQuit(t *testing.T) { + for _, goos := range []string{"darwin", "windows"} { + t.Run(goos, func(t *testing.T) { + withCodexAppPlatform(t, goos) + restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true}) + defer restoreConfirm() - running := true - calls := make([]string, 0) - withCodexAppProcessHooks(t, - func() bool { return running }, - func() error { - calls = append(calls, "quit") - return nil - }, - func() error { - calls = append(calls, "open") - return nil - }, - ) - codexAppExitTimeout = 0 - codexAppForceQuit = func() error { - calls = append(calls, "force") - running = false - return nil - } + calls := make([]string, 0) + withCodexAppProcessHooks(t, + func() bool { return true }, + func() error { calls = append(calls, "quit"); return nil }, + func() error { + calls = append(calls, "open") + return nil + }, + ) + if goos == "windows" { + codexAppStartID = func() string { return "OpenAI.Codex_2p2nqsd0c76g0!App" } + } + codexAppExitTimeout = 0 - if err := (&CodexApp{}).Run("qwen3.5", nil, nil); err != nil { - t.Fatalf("Run returned error: %v", err) - } - want := []string{"quit", "force", "open"} - if strings.Join(calls, ",") != strings.Join(want, ",") { - t.Fatalf("calls = %v, want %v", calls, want) + err := (&CodexApp{}).Run("qwen3.5", nil, nil) + if err == nil || !strings.Contains(err.Error(), "did not quit") { + t.Fatalf("Run error = %v, want a manual-close error", err) + } + if want := []string{"quit"}; !slices.Equal(calls, want) { + t.Fatalf("calls = %v, want only the graceful quit request %v", calls, want) + } + }) } } -func TestCodexAppRunReturnsMacForceStopError(t *testing.T) { +func TestCodexAppDesktopRestartTimeoutPreservesProfile(t *testing.T) { withCodexAppPlatform(t, "darwin") - restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true}) - defer restoreConfirm() - + codexAppExitTimeout = 0 + changeCalls := 0 + openCalls := 0 withCodexAppProcessHooks(t, func() bool { return true }, func() error { return nil }, - func() error { - t.Fatal("app should not reopen when force stop fails") - return nil - }, + func() error { openCalls++; return nil }, ) - codexAppExitTimeout = 0 - codexAppForceQuit = func() error { - return fmt.Errorf("operation not permitted") - } - err := (&CodexApp{}).Run("qwen3.5", nil, nil) - if err == nil || !strings.Contains(err.Error(), "force stop ChatGPT") || !strings.Contains(err.Error(), "operation not permitted") { - t.Fatalf("Run error = %v, want force stop failure", err) + err := codexAppApplyProfileFromDesktop(func() error { + changeCalls++ + return nil + }, true, true) + if err == nil || !strings.Contains(err.Error(), "did not quit") { + t.Fatalf("restart error = %v, want a manual-close error", err) + } + if changeCalls != 0 || openCalls != 0 { + t.Fatalf("profile changes/open calls = %d/%d, want 0/0", changeCalls, openCalls) } } @@ -1703,79 +3175,6 @@ func TestCodexAppRunRestartsWindowsStartAppID(t *testing.T) { } } -func TestCodexAppRunForceStopsWindowsBackgroundProcessesBeforeReopening(t *testing.T) { - withCodexAppPlatform(t, "windows") - restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true}) - defer restoreConfirm() - - windowOpen := true - running := true - calls := make([]string, 0) - withCodexAppProcessHooks(t, - func() bool { return running }, - func() error { - calls = append(calls, "quit") - windowOpen = false - return nil - }, - func() error { - t.Fatal("open app fallback should not be used") - return nil - }, - ) - codexAppHasWindow = func() bool { return windowOpen } - codexAppForceQuit = func() error { - calls = append(calls, "force") - running = false - return nil - } - codexAppStartID = func() string { return "OpenAI.Codex_2p2nqsd0c76g0!App" } - codexAppOpenStart = func(appID string) error { - calls = append(calls, "open:"+appID) - return nil - } - - if err := (&CodexApp{}).Run("qwen3.5", nil, nil); err != nil { - t.Fatalf("Run returned error: %v", err) - } - want := []string{"quit", "force", "open:OpenAI.Codex_2p2nqsd0c76g0!App"} - if strings.Join(calls, ",") != strings.Join(want, ",") { - t.Fatalf("calls = %v, want %v", calls, want) - } -} - -func TestCodexAppRunReturnsWindowsForceStopError(t *testing.T) { - withCodexAppPlatform(t, "windows") - restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true}) - defer restoreConfirm() - - windowOpen := true - withCodexAppProcessHooks(t, - func() bool { return true }, - func() error { - windowOpen = false - return nil - }, - func() error { - t.Fatal("open app fallback should not be used") - return nil - }, - ) - codexAppHasWindow = func() bool { return windowOpen } - codexAppForceQuit = func() error { - return fmt.Errorf("access denied") - } - codexAppOpenStart = func(string) error { - t.Fatal("app should not reopen when force stop fails") - return nil - } - - err := (&CodexApp{}).Run("qwen3.5", nil, nil) - if err == nil || !strings.Contains(err.Error(), "force stop ChatGPT") || !strings.Contains(err.Error(), "access denied") { - t.Fatalf("Run error = %v, want force stop failure", err) - } -} - func TestCodexAppRunRejectsExtraArgs(t *testing.T) { withCodexAppPlatform(t, "darwin") err := (&CodexApp{}).Run("qwen3.5", nil, []string{"--foo"}) @@ -1877,5 +3276,12 @@ func mustWriteCodexAppTestCatalog(t *testing.T, slugs ...string) string { if err := os.WriteFile(catalogPath, data, 0o644); err != nil { t.Fatal(err) } + configPath, err := codexConfigPath() + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(codexAppRoutingCatalogPathForConfig(configPath), data, 0o644); err != nil { + t.Fatal(err) + } return catalogPath } diff --git a/cmd/launch/integrations_test.go b/cmd/launch/integrations_test.go index 8725c0e7d..01662afb2 100644 --- a/cmd/launch/integrations_test.go +++ b/cmd/launch/integrations_test.go @@ -57,7 +57,7 @@ func TestIntegrationLookup(t *testing.T) { {"claude mixed case", "Claude", true, "Claude Code"}, {"claude desktop", "claude-desktop", true, "Claude Desktop"}, {"claude desktop alias", "claude-app", true, "Claude Desktop"}, - {"codex", "codex", true, "Codex"}, + {"codex", "codex", true, "Codex CLI"}, {"chatgpt", "chatgpt", true, "ChatGPT"}, {"codex app legacy alias", "codex-app", true, "ChatGPT"}, {"codex app desktop alias", "codex-desktop", true, "ChatGPT"}, diff --git a/cmd/launch/launch.go b/cmd/launch/launch.go index fc9b976e5..a995ea2ae 100644 --- a/cmd/launch/launch.go +++ b/cmd/launch/launch.go @@ -257,6 +257,7 @@ type ModelItem struct { RequiredPlan string ToolCapable bool Capabilities []modelpkg.Capability + Thinking *api.ModelRecommendationThinking Size int64 Details api.ModelDetails } @@ -769,7 +770,7 @@ func (c *launcherClient) launchEditorIntegration(ctx context.Context, name strin var launchModels []LaunchModel liveConfigMatches := slices.Equal(editor.Models(), models) if needsConfigure || req.ModelOverride != "" || !savedMatchesModels(saved, models) || !liveConfigMatches { - launchModels = c.modelInventory().Resolve(ctx, models) + launchModels = c.resolveRunModels(ctx, models) if err := prepareEditorIntegration(name, editor, launchModels); err != nil { return err } @@ -806,7 +807,7 @@ func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, nam if err != nil { return err } - if err := prepareManagedSingleIntegration(name, managed, target, c.modelInventory().Resolve(ctx, configureModels)); err != nil { + if err := prepareManagedSingleIntegration(name, managed, target, c.resolveRunModels(ctx, configureModels)); err != nil { return err } if refresher, ok := managed.(ManagedRuntimeRefresher); ok { @@ -1224,6 +1225,7 @@ func (c *launcherClient) requestRecommendations(ctx context.Context) ([]ModelIte VRAMBytes: rec.VRAMBytes, MaxOutputTokens: rec.MaxOutputTokens, RequiredPlan: strings.TrimSpace(rec.RequiredPlan), + Thinking: rec.Thinking.Clone(), Details: api.ModelDetails{ ContextLength: rec.ContextLength, }, @@ -1443,7 +1445,24 @@ func hasLocalModel(inventory []LaunchModel, name string) bool { } func (c *launcherClient) resolveRunModels(ctx context.Context, models []string) []LaunchModel { - return c.modelInventory().Resolve(ctx, models) + recommendations := c.recommendations(ctx) + resolved := c.modelInventory().Resolve(ctx, models) + byName := make(map[string]*api.ModelRecommendationThinking, len(recommendations)) + for _, recommendation := range recommendations { + if recommendation.Thinking != nil { + byName[launchModelRecommendationKey(recommendation.Name)] = recommendation.Thinking + } + } + for i := range resolved { + if thinking := byName[launchModelRecommendationKey(resolved[i].Name)]; thinking != nil { + resolved[i].Thinking = thinking.Clone() + } + } + return resolved +} + +func launchModelRecommendationKey(name string) string { + return strings.ToLower(strings.TrimSuffix(strings.TrimSpace(name), ":latest")) } func runIntegration(runner Runner, modelName string, models []LaunchModel, args []string) error { diff --git a/cmd/launch/launch_test.go b/cmd/launch/launch_test.go index 2c4fd6fb5..80aa147a8 100644 --- a/cmd/launch/launch_test.go +++ b/cmd/launch/launch_test.go @@ -46,6 +46,33 @@ func (r *launcherEditorRunner) Models() []string { return append([]string(nil), r.models...) } +func TestResolveRunModelsCarriesRecommendationThinkingMetadata(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/experimental/model-recommendations": + fmt.Fprint(w, `{"recommendations":[{"model":"deepseek-v4-flash:cloud","description":"Coding","context_length":1048576,"max_output_tokens":65536,"thinking":{"values":[false,true,"max"],"default":true}}]}`) + case "/api/tags": + fmt.Fprint(w, `{"models":[{"name":"deepseek-v4-flash:cloud","remote_model":"deepseek-v4-flash"}]}`) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + t.Setenv("OLLAMA_HOST", server.URL) + + client, err := newLauncherClient(defaultLaunchPolicy(false, false)) + if err != nil { + t.Fatal(err) + } + models := client.resolveRunModels(context.Background(), []string{"deepseek-v4-flash:cloud"}) + if len(models) != 1 || models[0].Thinking == nil { + t.Fatalf("resolved models = %#v, want recommendation thinking metadata", models) + } + if !slices.Equal(models[0].Thinking.Values, []any{false, true, "max"}) || models[0].Thinking.Default != true { + t.Fatalf("thinking = %#v, want exact endpoint values/default", models[0].Thinking) + } +} + type launcherSingleRunner struct { ranModel string } diff --git a/cmd/launch/model_inventory.go b/cmd/launch/model_inventory.go index 707ebe02a..b91c20c9e 100644 --- a/cmd/launch/model_inventory.go +++ b/cmd/launch/model_inventory.go @@ -17,6 +17,7 @@ type LaunchModel struct { Remote bool ToolCapable bool Capabilities []modelpkg.Capability + Thinking *api.ModelRecommendationThinking ContextLength int MaxOutputTokens int EmbeddingLength int @@ -167,6 +168,7 @@ func launchModelMatches(candidate, name string) bool { func cloneLaunchModel(model LaunchModel) LaunchModel { model.Capabilities = append([]modelpkg.Capability(nil), model.Capabilities...) + model.Thinking = model.Thinking.Clone() model.Details.Families = append([]string(nil), model.Details.Families...) return model } diff --git a/cmd/launch/registry.go b/cmd/launch/registry.go index f0453aaf2..ac3220a1b 100644 --- a/cmd/launch/registry.go +++ b/cmd/launch/registry.go @@ -98,7 +98,7 @@ var integrationSpecs = []*IntegrationSpec{ Name: chatGPTIntegrationName, Runner: &CodexApp{}, Aliases: []string{codexAppIntegrationName, "codex-desktop", "codex-gui"}, - Description: "Complete work with ChatGPT", + Description: "Use Ollama models in ChatGPT", Install: IntegrationInstallSpec{ CheckInstalled: func() bool { return codexAppInstalled() diff --git a/cmd/launch/test_config_helpers_test.go b/cmd/launch/test_config_helpers_test.go index d68e750b7..ac0bce9e9 100644 --- a/cmd/launch/test_config_helpers_test.go +++ b/cmd/launch/test_config_helpers_test.go @@ -39,6 +39,13 @@ func buildTestIntegrationAliases() map[string]bool { func setTestHome(t *testing.T, dir string) { t.Helper() setLaunchTestHome(t, dir) + oldNativeCatalog := codexAppNativeCatalog + codexAppNativeCatalog = func(string) ([]byte, error) { + return []byte(`{"models":[{"slug":"gpt-5.6-sol","display_name":"GPT-5.6-Sol","description":"Native Codex test model","priority":10,"supported_in_api":true,"base_instructions":"You are Codex, an agent based on GPT-5. Work carefully."}]}`), nil + } + t.Cleanup(func() { + codexAppNativeCatalog = oldNativeCatalog + }) } func testLaunchModels(names ...string) []LaunchModel { diff --git a/cmd/tui/tui_test.go b/cmd/tui/tui_test.go index ead25b84a..728bbda1f 100644 --- a/cmd/tui/tui_test.go +++ b/cmd/tui/tui_test.go @@ -24,7 +24,7 @@ func launcherTestState() *launch.LauncherState { }, "codex": { Name: "codex", - DisplayName: "Codex", + DisplayName: "Codex CLI", Description: "OpenAI's open-source coding agent", Selectable: true, Changeable: true, diff --git a/internal/proxy/codex_desktop.go b/internal/proxy/codex_desktop.go new file mode 100644 index 000000000..f5c975195 --- /dev/null +++ b/internal/proxy/codex_desktop.go @@ -0,0 +1,645 @@ +package proxy + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/http/httputil" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/klauspost/compress/zstd" +) + +const ( + // CodexDesktopPathPrefix is the loopback-only Ollama server route used as + // Codex's openai_base_url. Codex appends /v1/responses and related paths. + CodexDesktopPathPrefix = "/api/codex" + + // CodexDesktopModelCatalogFilename is the combined native and Ollama catalog + // shown by the Codex model picker. + CodexDesktopModelCatalogFilename = "ollama-launch-models.json" + + // CodexDesktopRoutingCatalogFilename is the Ollama-only routing allow-list, + // separate from the combined picker catalog. + CodexDesktopRoutingCatalogFilename = "ollama-launch-codex-routing.json" + + // CodexDesktopManagedAPIKey is a local-only sentinel, never an OpenAI credential. + // Reject it before forwarding any request to OpenAI. + CodexDesktopManagedAPIKey = "ollama-local-codex" + + defaultMaxBodyBytes = int64(64 << 20) + defaultOpenAIURL = "https://api.openai.com/v1" +) + +// CodexDesktopConfig describes the upstreams and Ollama-only routing catalog. +type CodexDesktopConfig struct { + OllamaURL string + ChatGPTURL string + OpenAIURL string + RoutingCatalogPath string + ActivityLogPath string + MaxBodyBytes int64 + Logger *slog.Logger + Transport http.RoundTripper +} + +// CodexDesktop routes catalog-listed models to Ollama and other requests to +// their native upstream, using the existing Ollama listener. +type CodexDesktop struct { + ollamaURL *url.URL + chatGPTURL *url.URL + openAIURL *url.URL + routingCatalogPath string + activityLogPath string + maxBodyBytes int64 + logger *slog.Logger + proxy *httputil.ReverseProxy + ollamaRequests atomic.Uint64 + chatGPTRequests atomic.Uint64 + upstreamErrors atomic.Uint64 + lastRoute atomic.Value + activityLogMu sync.Mutex +} + +type routeSnapshot struct { + Model string + Route string + UpstreamStatus int +} + +type statusResponse struct { + OK bool `json:"ok"` + OllamaRequests uint64 `json:"ollama_requests"` + ChatGPTRequests uint64 `json:"chatgpt_requests"` + UpstreamErrors uint64 `json:"upstream_errors"` + LastModel string `json:"last_model,omitempty"` + LastRoute string `json:"last_route,omitempty"` + LastUpstreamStatus int `json:"last_upstream_status,omitempty"` +} + +func NewCodexDesktop(config CodexDesktopConfig) (*CodexDesktop, error) { + ollamaURL, err := parseBaseURL("Ollama", config.OllamaURL) + if err != nil { + return nil, err + } + chatGPTURL, err := parseBaseURL("ChatGPT", config.ChatGPTURL) + if err != nil { + return nil, err + } + openAIRawURL := strings.TrimSpace(config.OpenAIURL) + if openAIRawURL == "" { + openAIRawURL = defaultOpenAIURL + } + openAIURL, err := parseBaseURL("OpenAI API", openAIRawURL) + if err != nil { + return nil, err + } + + maxBodyBytes := config.MaxBodyBytes + if maxBodyBytes <= 0 { + maxBodyBytes = defaultMaxBodyBytes + } + logger := config.Logger + if logger == nil { + logger = slog.Default() + } + transport := config.Transport + if transport == nil { + transport = http.DefaultTransport.(*http.Transport).Clone() + } + + handler := &CodexDesktop{ + ollamaURL: ollamaURL, + chatGPTURL: chatGPTURL, + openAIURL: openAIURL, + routingCatalogPath: config.RoutingCatalogPath, + activityLogPath: strings.TrimSpace(config.ActivityLogPath), + maxBodyBytes: maxBodyBytes, + logger: logger, + } + handler.proxy = &httputil.ReverseProxy{ + Rewrite: handler.rewrite, + ModifyResponse: handler.modifyResponse, + ErrorHandler: handler.upstreamError, + Transport: transport, + // Flush immediately so upstream SSE streams reach Codex as they arrive. + FlushInterval: -1, + } + return handler, nil +} + +func parseBaseURL(name, raw string) (*url.URL, error) { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil { + return nil, fmt.Errorf("parse %s URL: %w", name, err) + } + if u.Scheme == "" || u.Host == "" { + return nil, fmt.Errorf("invalid %s URL %q", name, raw) + } + return u, nil +} + +func (h *CodexDesktop) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if !isLoopbackRequest(r) { + writeJSONError(w, http.StatusForbidden, "Codex proxy only accepts loopback requests") + return + } + + suffix, ok := strings.CutPrefix(r.URL.Path, CodexDesktopPathPrefix) + if !ok || suffix == "" { + http.NotFound(w, r) + return + } + if suffix == "/_health" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, `{"ok":true}`) + return + } + if suffix == "/_status" { + h.writeStatus(w) + return + } + started := time.Now() + if isWebSocketUpgrade(r) { + // Codex treats 426 as a session-wide fallback to HTTP, which allows per-request routing. + h.logActivity(started, r.Method, suffix, "", "none", http.StatusUpgradeRequired, "http_fallback") + writeJSONError(w, http.StatusUpgradeRequired, "Codex proxy uses the HTTP Responses transport") + return + } + + rawBody, decodedBody, err := h.readBodies(r) + if err != nil { + h.logActivity(started, r.Method, suffix, "", "none", http.StatusBadRequest, "request_error") + writeJSONError(w, http.StatusBadRequest, err.Error()) + return + } + + model, hasModel := extractModel(decodedBody) + routed := false + var autoReview autoReviewState + var routedModel routingModel + if hasModel { + catalog, err := loadRoutingCatalog(h.routingCatalogPath) + if err != nil { + h.logActivity(started, r.Method, suffix, model, "none", http.StatusServiceUnavailable, "catalog_error") + writeJSONError(w, http.StatusServiceUnavailable, "read Codex Ollama model catalog: "+err.Error()) + return + } + model, decodedBody, err = autoReview.resolveModel(model, catalog, decodedBody) + if err != nil { + h.logActivity(started, r.Method, suffix, model, "ollama", http.StatusBadRequest, "request_error") + writeJSONError(w, http.StatusBadRequest, "prepare Codex Auto-review request for Ollama: "+err.Error()) + return + } + routedModel, routed = catalog.models[modelKey(model)] + } + decodedBody, err = autoReview.prepareRequest(routed, suffix, decodedBody) + if err != nil { + h.logActivity(started, r.Method, suffix, model, "ollama", http.StatusBadRequest, "request_error") + writeJSONError(w, http.StatusBadRequest, "prepare Codex Auto-review request for Ollama: "+err.Error()) + return + } + if !routed && usesManagedAPIKey(r.Header) { + h.lastRoute.Store(routeSnapshot{Model: model, Route: "none", UpstreamStatus: http.StatusUnauthorized}) + h.logActivity(started, r.Method, suffix, model, "none", http.StatusUnauthorized, "auth_required") + writeJSONError(w, http.StatusUnauthorized, "OpenAI models require signing in to ChatGPT or adding an OpenAI API key") + return + } + + // ChatGPT-Account-ID distinguishes account sessions from API-key requests. + targetBase := h.openAIURL + targetSuffix := strings.TrimPrefix(suffix, "/v1") + route := "openai" + if usesChatGPTBackend(r.Header) { + targetBase = h.chatGPTURL + route = "chatgpt" + } + var ( + requestBody []byte + requestBodyNormalized bool + ) + if routed { + targetBase = h.ollamaURL + targetSuffix = suffix + route = "ollama" + requestBody, err = normalizeOllamaRequestBody(decodedBody, routedModel) + if err != nil { + h.logActivity(started, r.Method, suffix, model, "ollama", http.StatusBadRequest, "request_error") + writeJSONError(w, http.StatusBadRequest, "prepare Codex request for Ollama: "+err.Error()) + return + } + } else { + requestBody, requestBodyNormalized, err = normalizeNativeRequestBody(decodedBody) + if err != nil { + h.logActivity(started, r.Method, suffix, model, route, http.StatusBadRequest, "request_error") + writeJSONError(w, http.StatusBadRequest, "prepare Codex request for OpenAI: "+err.Error()) + return + } + if !requestBodyNormalized { + requestBody = rawBody + } + } + h.lastRoute.Store(routeSnapshot{Model: model, Route: route}) + + state := &proxyRequest{ + started: started, + method: r.Method, + suffix: suffix, + model: model, + hasModel: hasModel, + routed: routed, + route: route, + autoReview: autoReview, + target: targetBase, + targetSuffix: targetSuffix, + body: requestBody, + normalized: requestBodyNormalized, + } + h.logger.Debug("routing Codex request", "path", suffix, "model", model, "ollama", routed) + recorder := &responseRecorder{ResponseWriter: w} + h.proxy.ServeHTTP(recorder, r.WithContext(context.WithValue(r.Context(), proxyRequestKey{}, state))) + + result := state.result + if result == "" { + result = "ok" + if recorder.writeErr != nil { + if r.Context().Err() != nil && errors.Is(recorder.writeErr, r.Context().Err()) { + result = "canceled" + } else { + result = "stream_error" + h.upstreamErrors.Add(1) + h.logger.Warn("Codex proxy response write failed", "path", suffix, "model", model, "ollama", routed, "error", recorder.writeErr) + } + } + } + status := state.status + if status == 0 { + status = http.StatusBadGateway + } + h.logActivity(started, r.Method, suffix, model, route, status, result) +} + +type proxyRequestKey struct{} + +// proxyRequest shares routing and response state between reverse-proxy hooks. +type proxyRequest struct { + started time.Time + method string + suffix string + model string + hasModel bool + routed bool + route string + autoReview autoReviewState + target *url.URL + targetSuffix string + body []byte + normalized bool + status int // upstream response status once known + result string // terminal activity-log result set by a hook +} + +type proxyError struct { + status int + message string + result string +} + +func (e *proxyError) Error() string { return e.message } + +func (h *CodexDesktop) rewrite(pr *httputil.ProxyRequest) { + state := pr.In.Context().Value(proxyRequestKey{}).(*proxyRequest) + pr.Out.URL = resolveTarget(state.target, state.targetSuffix, pr.In.URL.RawQuery) + // An empty Host makes the outbound Host header follow the upstream URL. + pr.Out.Host = "" + pr.Out.Body = io.NopCloser(bytes.NewReader(state.body)) + pr.Out.ContentLength = int64(len(state.body)) + if state.routed { + // Never forward Codex credentials to Ollama. + pr.Out.Header = make(http.Header) + copyOllamaRequestHeaders(pr.Out.Header, pr.In.Header) + return + } + if state.normalized { + pr.Out.Header.Del("Content-Encoding") + } + // ReverseProxy strips hop-by-hop headers; leave X-Forwarded-* unset. +} + +func (h *CodexDesktop) modifyResponse(resp *http.Response) error { + state := resp.Request.Context().Value(proxyRequestKey{}).(*proxyRequest) + state.status = resp.StatusCode + h.lastRoute.Store(routeSnapshot{Model: state.model, Route: state.route, UpstreamStatus: resp.StatusCode}) + if resp.StatusCode >= http.StatusInternalServerError { + h.upstreamErrors.Add(1) + state.result = "upstream_error" + } + if isAcceptedModelRequest(state.method, state.suffix, state.hasModel, resp.StatusCode) { + if state.routed { + h.ollamaRequests.Add(1) + } else { + h.chatGPTRequests.Add(1) + } + } + if state.routed { + if err := h.rewriteAccessErrors(resp); err != nil { + return err + } + } + if !state.autoReview.buffersResponse(resp.StatusCode) { + return nil + } + responseBody, err := io.ReadAll(io.LimitReader(resp.Body, h.maxBodyBytes+1)) + if err != nil { + h.upstreamErrors.Add(1) + return &proxyError{ + status: http.StatusBadGateway, + message: "read Codex Auto-review response from Ollama: " + err.Error(), + result: "stream_error", + } + } + if int64(len(responseBody)) > h.maxBodyBytes { + h.upstreamErrors.Add(1) + return &proxyError{ + status: http.StatusBadGateway, + message: fmt.Sprintf("Codex Auto-review response exceeds %d bytes", h.maxBodyBytes), + result: "response_error", + } + } + responseBody, _, err = transformAutoReviewResponse(responseBody, resp.Header.Get("Content-Type")) + if err != nil { + h.upstreamErrors.Add(1) + return &proxyError{ + status: http.StatusBadGateway, + message: "invalid Codex Auto-review response from Ollama: " + err.Error(), + result: "response_error", + } + } + // Recompute framing after rewriting the response. + resp.Body.Close() + resp.Body = io.NopCloser(bytes.NewReader(responseBody)) + resp.ContentLength = -1 + resp.Header.Del("Content-Length") + return nil +} + +func (h *CodexDesktop) upstreamError(w http.ResponseWriter, r *http.Request, err error) { + state, _ := r.Context().Value(proxyRequestKey{}).(*proxyRequest) + if state != nil { + state.status = http.StatusBadGateway + } + var handled *proxyError + if errors.As(err, &handled) { + if state != nil { + state.result = handled.result + } + writeJSONError(w, handled.status, handled.message) + return + } + h.upstreamErrors.Add(1) + if state != nil { + state.result = "upstream_error" + } + writeJSONError(w, http.StatusBadGateway, err.Error()) +} + +type responseRecorder struct { + http.ResponseWriter + writeErr error +} + +func (r *responseRecorder) Write(body []byte) (int, error) { + n, err := r.ResponseWriter.Write(body) + if err != nil { + r.writeErr = err + } + return n, err +} + +func (r *responseRecorder) Flush() { + if flusher, ok := r.ResponseWriter.(http.Flusher); ok { + flusher.Flush() + } +} + +func (r *responseRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { + if hijacker, ok := r.ResponseWriter.(http.Hijacker); ok { + return hijacker.Hijack() + } + return nil, nil, errors.New("response writer does not support hijacking") +} + +func (h *CodexDesktop) logActivity(started time.Time, method, path, model, route string, status int, result string) { + h.writeActivity( + "route=%s model=%q method=%s path=%s status=%d duration=%s result=%s", + route, + model, + method, + path, + status, + time.Since(started).Round(time.Millisecond), + result, + ) +} + +func (h *CodexDesktop) writeActivity(format string, args ...any) { + if h.activityLogPath == "" { + return + } + h.activityLogMu.Lock() + defer h.activityLogMu.Unlock() + + if err := os.MkdirAll(filepath.Dir(h.activityLogPath), 0o700); err != nil { + h.logger.Warn("failed to create Codex proxy log directory", "error", err) + return + } + file, err := os.OpenFile(h.activityLogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + h.logger.Warn("failed to open Codex proxy activity log", "error", err) + return + } + defer file.Close() + + _, err = fmt.Fprintf(file, "%s %s\n", time.Now().Format(time.RFC3339), fmt.Sprintf(format, args...)) + if err != nil { + h.logger.Warn("failed to write Codex proxy activity log", "error", err) + } +} + +func (h *CodexDesktop) writeStatus(w http.ResponseWriter) { + status := statusResponse{ + OK: true, + OllamaRequests: h.ollamaRequests.Load(), + ChatGPTRequests: h.chatGPTRequests.Load(), + UpstreamErrors: h.upstreamErrors.Load(), + } + if value := h.lastRoute.Load(); value != nil { + last := value.(routeSnapshot) + status.LastModel = last.Model + status.LastRoute = last.Route + status.LastUpstreamStatus = last.UpstreamStatus + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(status) +} + +func isAcceptedModelRequest(method, path string, hasModel bool, status int) bool { + return method == http.MethodPost && + path == "/v1/responses" && + hasModel && + status >= http.StatusOK && + status < http.StatusMultipleChoices +} + +func (h *CodexDesktop) readBodies(r *http.Request) ([]byte, []byte, error) { + if r.Body == nil { + return nil, nil, nil + } + raw, err := io.ReadAll(io.LimitReader(r.Body, h.maxBodyBytes+1)) + if err != nil { + return nil, nil, fmt.Errorf("read request body: %w", err) + } + if int64(len(raw)) > h.maxBodyBytes { + return nil, nil, fmt.Errorf("request body exceeds %d bytes", h.maxBodyBytes) + } + if !strings.EqualFold(strings.TrimSpace(r.Header.Get("Content-Encoding")), "zstd") { + return raw, raw, nil + } + + decoder, err := zstd.NewReader(bytes.NewReader(raw), zstd.WithDecoderMaxMemory(uint64(h.maxBodyBytes))) + if err != nil { + return nil, nil, fmt.Errorf("decompress zstd request body: %w", err) + } + defer decoder.Close() + decoded, err := io.ReadAll(io.LimitReader(decoder, h.maxBodyBytes+1)) + if err != nil { + return nil, nil, fmt.Errorf("decompress zstd request body: %w", err) + } + if int64(len(decoded)) > h.maxBodyBytes { + return nil, nil, fmt.Errorf("decompressed request body exceeds %d bytes", h.maxBodyBytes) + } + return raw, decoded, nil +} + +type routingThinkingMetadata struct { + Supported bool `json:"supported"` + Levels []string `json:"levels,omitempty"` + Values map[string]json.RawMessage `json:"values,omitempty"` +} + +type routingModel struct { + Slug string `json:"slug"` + Thinking *routingThinkingMetadata `json:"thinking,omitempty"` +} + +type routingCatalog struct { + models map[string]routingModel + autoReviewModel string +} + +func loadRoutingCatalog(path string) (routingCatalog, error) { + if strings.TrimSpace(path) == "" { + return routingCatalog{}, fmt.Errorf("model catalog path is empty") + } + data, err := os.ReadFile(path) + if err != nil { + return routingCatalog{}, err + } + var catalog struct { + Models []routingModel `json:"models"` + AutoReviewModel string `json:"auto_review_model"` + } + if err := json.Unmarshal(data, &catalog); err != nil { + return routingCatalog{}, err + } + models := make(map[string]routingModel, len(catalog.Models)) + for _, model := range catalog.Models { + if key := modelKey(model.Slug); key != "" { + models[key] = model + } + } + autoReviewModel := strings.TrimSpace(catalog.AutoReviewModel) + if autoReviewModel != "" { + if _, ok := models[modelKey(autoReviewModel)]; !ok { + return routingCatalog{}, fmt.Errorf("Auto-review model %q is not in the Ollama routing catalog", autoReviewModel) + } + } + return routingCatalog{models: models, autoReviewModel: autoReviewModel}, nil +} + +func modelKey(model string) string { + return strings.TrimSuffix(strings.TrimSpace(model), ":latest") +} + +func resolveTarget(base *url.URL, suffix, rawQuery string) *url.URL { + target := *base + target.Path = strings.TrimRight(base.Path, "/") + "/" + strings.TrimLeft(suffix, "/") + target.RawPath = "" + target.RawQuery = rawQuery + target.Fragment = "" + return &target +} + +func isLoopbackRequest(r *http.Request) bool { + host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr)) + if err != nil { + host = strings.TrimSpace(r.RemoteAddr) + } + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(strings.Trim(host, "[]")) + return ip != nil && ip.IsLoopback() +} + +func isWebSocketUpgrade(r *http.Request) bool { + if !strings.EqualFold(strings.TrimSpace(r.Header.Get("Upgrade")), "websocket") { + return false + } + for _, raw := range r.Header.Values("Connection") { + for _, token := range strings.Split(raw, ",") { + if strings.EqualFold(strings.TrimSpace(token), "upgrade") { + return true + } + } + } + return false +} + +func usesChatGPTBackend(header http.Header) bool { + return strings.TrimSpace(header.Get("ChatGPT-Account-ID")) != "" +} + +func usesManagedAPIKey(header http.Header) bool { + parts := strings.Fields(header.Get("Authorization")) + return len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") && parts[1] == CodexDesktopManagedAPIKey +} + +func copyOllamaRequestHeaders(dst, src http.Header) { + for _, key := range []string{"Accept", "Content-Type", "OpenAI-Beta", "User-Agent"} { + for _, value := range src.Values(key) { + dst.Add(key, value) + } + } +} + +func writeJSONError(w http.ResponseWriter, status int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{"error": message}) +} diff --git a/internal/proxy/codex_desktop_autoreview.go b/internal/proxy/codex_desktop_autoreview.go new file mode 100644 index 000000000..0b5f548a8 --- /dev/null +++ b/internal/proxy/codex_desktop_autoreview.go @@ -0,0 +1,623 @@ +package proxy + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" +) + +const ( + // autoReviewModel is the native Codex reviewer alias. The routing catalog + // may map requests for this alias to a selected Ollama model. + autoReviewModel = "codex-auto-review" + guardianDecisionToolName = "submit_guardian_decision" + + guardianDecisionInstruction = ` + +When you have finished reviewing the action, call submit_guardian_decision exactly once with your final decision. Do not return the final decision as assistant text.` +) + +var guardianDecisionTool = map[string]any{ + "type": "function", + "name": guardianDecisionToolName, + "description": "Submit the final Codex Guardian approval decision after completing any necessary investigation.", + "strict": true, + "parameters": map[string]any{ + "type": "object", + "properties": map[string]any{ + "risk_level": map[string]any{ + "type": "string", + "enum": []string{"low", "medium", "high", "critical"}, + }, + "user_authorization": map[string]any{ + "type": "string", + "enum": []string{"unknown", "low", "medium", "high"}, + }, + "outcome": map[string]any{ + "type": "string", + "enum": []string{"allow", "deny"}, + }, + "rationale": map[string]any{ + "type": "string", + "minLength": 1, + }, + }, + "required": []string{"risk_level", "user_authorization", "outcome", "rationale"}, + "additionalProperties": false, + }, +} + +type autoReviewState struct { + alias bool // requested model is the native auto-review alias + eligible bool // routed auto-review call that needs translation +} + +func (s *autoReviewState) resolveModel(model string, catalog routingCatalog, body []byte) (string, []byte, error) { + if modelKey(model) != modelKey(autoReviewModel) { + return model, body, nil + } + s.alias = true + if catalog.autoReviewModel == "" { + return model, body, nil + } + replaced, err := replaceRequestModel(body, catalog.autoReviewModel) + if err != nil { + return catalog.autoReviewModel, body, err + } + return catalog.autoReviewModel, replaced, nil +} + +func (s *autoReviewState) prepareRequest(routed bool, suffix string, body []byte) ([]byte, error) { + s.eligible = s.alias && routed && suffix == "/v1/responses" + if !s.eligible { + return body, nil + } + return prepareAutoReviewRequest(body) +} + +func (s *autoReviewState) buffersResponse(status int) bool { + return s.eligible && status >= http.StatusOK && status < http.StatusMultipleChoices +} + +// Codex expects JSON text, so consume the proxy-owned decision tool in the response path. +func prepareAutoReviewRequest(body []byte) ([]byte, error) { + var payload map[string]json.RawMessage + if err := json.Unmarshal(body, &payload); err != nil { + return nil, err + } + + var tools []json.RawMessage + if raw := bytes.TrimSpace(payload["tools"]); len(raw) > 0 && !bytes.Equal(raw, []byte("null")) { + if err := json.Unmarshal(raw, &tools); err != nil { + return nil, fmt.Errorf("decode tools: %w", err) + } + } + for _, raw := range tools { + var tool struct { + Name string `json:"name"` + } + if err := json.Unmarshal(raw, &tool); err != nil { + return nil, fmt.Errorf("decode tool: %w", err) + } + if tool.Name == guardianDecisionToolName { + return nil, fmt.Errorf("tool name %q is reserved by the Codex proxy", guardianDecisionToolName) + } + } + decisionTool, err := json.Marshal(guardianDecisionTool) + if err != nil { + return nil, fmt.Errorf("encode Guardian decision tool: %w", err) + } + tools = append(tools, decisionTool) + encodedTools, err := json.Marshal(tools) + if err != nil { + return nil, fmt.Errorf("encode tools: %w", err) + } + payload["tools"] = encodedTools + + var input []json.RawMessage + if err := json.Unmarshal(payload["input"], &input); err != nil { + return nil, fmt.Errorf("decode input: %w", err) + } + foundUserMessage := false + for i := len(input) - 1; i >= 0; i-- { + var message struct { + Type string `json:"type"` + Role string `json:"role"` + Content json.RawMessage `json:"content"` + } + if err := json.Unmarshal(input[i], &message); err != nil { + return nil, fmt.Errorf("decode input item: %w", err) + } + if message.Role != "user" || (message.Type != "" && message.Type != "message") { + continue + } + + content, changed, err := appendAutoReviewInstructionToContent(message.Content) + if err != nil { + return nil, err + } + if !changed { + continue + } + foundUserMessage = true + + var item map[string]json.RawMessage + if err := json.Unmarshal(input[i], &item); err != nil { + return nil, fmt.Errorf("decode user message: %w", err) + } + item["content"] = content + input[i], err = json.Marshal(item) + if err != nil { + return nil, fmt.Errorf("encode user message: %w", err) + } + break + } + if !foundUserMessage { + return nil, errors.New("Guardian request has no user message") + } + encodedInput, err := json.Marshal(input) + if err != nil { + return nil, fmt.Errorf("encode input: %w", err) + } + payload["input"] = encodedInput + encoded, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("encode request: %w", err) + } + return encoded, nil +} + +func appendAutoReviewInstructionToContent(content json.RawMessage) (json.RawMessage, bool, error) { + trimmed := bytes.TrimSpace(content) + if len(trimmed) == 0 { + return content, false, nil + } + if trimmed[0] == '"' { + var text string + if err := json.Unmarshal(trimmed, &text); err != nil { + return nil, false, fmt.Errorf("decode user message content: %w", err) + } + encoded, err := json.Marshal(text + guardianDecisionInstruction) + if err != nil { + return nil, false, fmt.Errorf("encode user message content: %w", err) + } + return encoded, true, nil + } + if trimmed[0] != '[' { + return content, false, nil + } + + var parts []json.RawMessage + if err := json.Unmarshal(trimmed, &parts); err != nil { + return nil, false, fmt.Errorf("decode user message content: %w", err) + } + instruction, err := json.Marshal(map[string]string{ + "type": "input_text", + "text": guardianDecisionInstruction, + }) + if err != nil { + return nil, false, err + } + parts = append(parts, instruction) + encoded, err := json.Marshal(parts) + if err != nil { + return nil, false, fmt.Errorf("encode user message content: %w", err) + } + return encoded, true, nil +} + +type guardianDecision struct { + RiskLevel string `json:"risk_level"` + UserAuthorization string `json:"user_authorization"` + Outcome string `json:"outcome"` + Rationale string `json:"rationale"` +} + +type autoReviewOutputItem struct { + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Arguments string `json:"arguments"` +} + +func transformAutoReviewResponse(body []byte, contentType string) ([]byte, bool, error) { + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(contentType)), "text/event-stream") { + return transformAutoReviewEventStream(body) + } + return transformAutoReviewJSON(body) +} + +func transformAutoReviewJSON(body []byte) ([]byte, bool, error) { + var response map[string]json.RawMessage + if err := json.Unmarshal(body, &response); err != nil { + return nil, false, fmt.Errorf("decode response: %w", err) + } + var status string + if err := json.Unmarshal(response["status"], &status); err != nil { + return nil, false, fmt.Errorf("decode response status: %w", err) + } + if status != "completed" { + return body, false, nil + } + var output []json.RawMessage + if err := json.Unmarshal(response["output"], &output); err != nil { + return nil, false, fmt.Errorf("decode response output: %w", err) + } + inspection, err := inspectAutoReviewOutput(output) + if err != nil || inspection.passthrough { + return body, false, err + } + transformedOutput := make([]json.RawMessage, 0, len(output)-len(inspection.discardedMessageIDs)) + for i, raw := range output { + var item autoReviewOutputItem + if err := json.Unmarshal(raw, &item); err != nil { + return nil, false, fmt.Errorf("decode response output item: %w", err) + } + if _, discard := inspection.discardedMessageIDs[item.ID]; discard && item.Type == "message" { + continue + } + if item.Type == "function_call" && item.Name == guardianDecisionToolName { + output[i], err = json.Marshal(autoReviewDecisionMessage(inspection.itemID, inspection.decisionJSON)) + if err != nil { + return nil, false, fmt.Errorf("encode Guardian decision message: %w", err) + } + } + transformedOutput = append(transformedOutput, output[i]) + } + response["output"], err = json.Marshal(transformedOutput) + if err != nil { + return nil, false, fmt.Errorf("encode response output: %w", err) + } + transformed, err := json.Marshal(response) + if err != nil { + return nil, false, fmt.Errorf("encode response: %w", err) + } + return transformed, true, nil +} + +type autoReviewInspection struct { + decisionJSON string + itemID string + discardedMessageIDs map[string]struct{} + passthrough bool +} + +func inspectAutoReviewOutput(output []json.RawMessage) (autoReviewInspection, error) { + result := autoReviewInspection{discardedMessageIDs: make(map[string]struct{})} + decisionCalls := 0 + otherTerminalOutput := false + for _, raw := range output { + var item autoReviewOutputItem + if err := json.Unmarshal(raw, &item); err != nil { + return result, fmt.Errorf("decode response output item: %w", err) + } + switch item.Type { + case "reasoning": + continue + case "message": + // A validated decision call takes precedence over accompanying prose. + if item.ID == "" { + return result, errors.New("Guardian assistant message has no item ID") + } + result.discardedMessageIDs[item.ID] = struct{}{} + case "function_call": + if item.Name != guardianDecisionToolName { + if decisionCalls > 0 { + otherTerminalOutput = true + } else { + result.passthrough = true + } + continue + } + decisionCalls++ + if decisionCalls > 1 { + return result, errors.New("Guardian called the decision tool more than once") + } + decisionJSON, err := validateGuardianDecision(item.Arguments) + if err != nil { + return result, err + } + result.decisionJSON = decisionJSON + result.itemID = item.ID + default: + otherTerminalOutput = true + } + } + if result.passthrough && decisionCalls == 0 { + return result, nil + } + if decisionCalls == 0 { + return result, errors.New("Guardian did not call submit_guardian_decision") + } + if result.passthrough || otherTerminalOutput { + return result, errors.New("Guardian mixed its decision with other terminal output") + } + return result, nil +} + +func validateGuardianDecision(arguments string) (string, error) { + decoder := json.NewDecoder(strings.NewReader(arguments)) + decoder.DisallowUnknownFields() + var decision guardianDecision + if err := decoder.Decode(&decision); err != nil { + return "", fmt.Errorf("decode Guardian decision arguments: %w", err) + } + if decoder.Decode(&struct{}{}) != io.EOF { + return "", errors.New("Guardian decision arguments contain trailing data") + } + if !oneOf(decision.RiskLevel, "low", "medium", "high", "critical") { + return "", fmt.Errorf("invalid Guardian risk_level %q", decision.RiskLevel) + } + if !oneOf(decision.UserAuthorization, "unknown", "low", "medium", "high") { + return "", fmt.Errorf("invalid Guardian user_authorization %q", decision.UserAuthorization) + } + if !oneOf(decision.Outcome, "allow", "deny") { + return "", fmt.Errorf("invalid Guardian outcome %q", decision.Outcome) + } + if strings.TrimSpace(decision.Rationale) == "" { + return "", errors.New("Guardian rationale is empty") + } + encoded, err := json.Marshal(decision) + if err != nil { + return "", fmt.Errorf("encode Guardian decision: %w", err) + } + return string(encoded), nil +} + +func oneOf(value string, allowed ...string) bool { + for _, candidate := range allowed { + if value == candidate { + return true + } + } + return false +} + +func autoReviewDecisionMessage(itemID, decision string) map[string]any { + return map[string]any{ + "id": itemID, + "type": "message", + "status": "completed", + "role": "assistant", + "content": []any{map[string]any{ + "type": "output_text", + "text": decision, + "annotations": []any{}, + "logprobs": []any{}, + }}, + } +} + +type serverSentEvent struct { + event string + data []byte +} + +func transformAutoReviewEventStream(body []byte) ([]byte, bool, error) { + events, err := parseServerSentEvents(body) + if err != nil { + return nil, false, err + } + var completedOutput []json.RawMessage + terminalFailure := false + for _, event := range events { + if event.event == "response.failed" || event.event == "response.incomplete" { + terminalFailure = true + } + if event.event != "response.completed" { + continue + } + var payload struct { + Response struct { + Output []json.RawMessage `json:"output"` + } `json:"response"` + } + if err := json.Unmarshal(event.data, &payload); err != nil { + return nil, false, fmt.Errorf("decode response.completed event: %w", err) + } + completedOutput = payload.Response.Output + } + if completedOutput == nil { + if terminalFailure { + return body, false, nil + } + return nil, false, errors.New("Guardian stream has no response.completed event") + } + inspection, err := inspectAutoReviewOutput(completedOutput) + if err != nil || inspection.passthrough { + return body, false, err + } + outputIndexes := make(map[int]int, len(completedOutput)-len(inspection.discardedMessageIDs)) + nextOutputIndex := 0 + for oldOutputIndex, raw := range completedOutput { + var item autoReviewOutputItem + if err := json.Unmarshal(raw, &item); err != nil { + return nil, false, fmt.Errorf("decode completed output item: %w", err) + } + if _, discard := inspection.discardedMessageIDs[item.ID]; discard && item.Type == "message" { + continue + } + outputIndexes[oldOutputIndex] = nextOutputIndex + nextOutputIndex++ + } + + var transformed []serverSentEvent + addedDecisionMessage := false + finishedDecisionMessage := false + for _, event := range events { + if bytes.Equal(bytes.TrimSpace(event.data), []byte("[DONE]")) { + transformed = append(transformed, event) + continue + } + var payload map[string]any + if err := json.Unmarshal(event.data, &payload); err != nil { + return nil, false, fmt.Errorf("decode %s event: %w", event.event, err) + } + if _, discard := inspection.discardedMessageIDs[autoReviewEventItemID(payload)]; discard { + continue + } + if outputIndex, ok := payload["output_index"].(float64); ok { + if mapped, keep := outputIndexes[int(outputIndex)]; keep { + payload["output_index"] = mapped + } + } + switch event.event { + case "response.output_item.added": + item, _ := payload["item"].(map[string]any) + if itemString(item, "id") == inspection.itemID && itemString(item, "name") == guardianDecisionToolName { + outputIndex := payload["output_index"] + transformed = append(transformed, + newServerSentEvent("response.output_item.added", map[string]any{ + "output_index": outputIndex, + "item": map[string]any{ + "id": inspection.itemID, "type": "message", "status": "in_progress", "role": "assistant", "content": []any{}, + }, + }), + newServerSentEvent("response.content_part.added", map[string]any{ + "item_id": inspection.itemID, "output_index": outputIndex, "content_index": 0, + "part": map[string]any{"type": "output_text", "text": "", "annotations": []any{}, "logprobs": []any{}}, + }), + ) + addedDecisionMessage = true + continue + } + case "response.function_call_arguments.delta", "response.function_call_arguments.done": + if itemString(payload, "item_id") == inspection.itemID { + continue + } + case "response.output_item.done": + item, _ := payload["item"].(map[string]any) + if itemString(item, "id") == inspection.itemID && itemString(item, "name") == guardianDecisionToolName { + if !addedDecisionMessage { + return nil, false, errors.New("Guardian decision stream has no output_item.added event") + } + outputIndex := payload["output_index"] + message := autoReviewDecisionMessage(inspection.itemID, inspection.decisionJSON) + content := message["content"].([]any)[0] + transformed = append(transformed, + newServerSentEvent("response.output_text.delta", map[string]any{ + "item_id": inspection.itemID, "output_index": outputIndex, "content_index": 0, "delta": inspection.decisionJSON, "logprobs": []any{}, + }), + newServerSentEvent("response.output_text.done", map[string]any{ + "item_id": inspection.itemID, "output_index": outputIndex, "content_index": 0, "text": inspection.decisionJSON, "logprobs": []any{}, + }), + newServerSentEvent("response.content_part.done", map[string]any{ + "item_id": inspection.itemID, "output_index": outputIndex, "content_index": 0, "part": content, + }), + newServerSentEvent("response.output_item.done", map[string]any{ + "output_index": outputIndex, "item": message, + }), + ) + finishedDecisionMessage = true + continue + } + case "response.completed": + if !finishedDecisionMessage { + return nil, false, errors.New("Guardian decision stream has no output_item.done event") + } + response, ok := payload["response"].(map[string]any) + if !ok { + return nil, false, errors.New("response.completed event has no response object") + } + output, ok := response["output"].([]any) + if !ok { + return nil, false, errors.New("response.completed event has no output array") + } + transformedOutput := make([]any, 0, len(output)-len(inspection.discardedMessageIDs)) + for _, value := range output { + item, _ := value.(map[string]any) + if _, discard := inspection.discardedMessageIDs[itemString(item, "id")]; discard && itemString(item, "type") == "message" { + continue + } + if itemString(item, "id") == inspection.itemID && itemString(item, "name") == guardianDecisionToolName { + value = autoReviewDecisionMessage(inspection.itemID, inspection.decisionJSON) + } + transformedOutput = append(transformedOutput, value) + } + response["output"] = transformedOutput + } + encoded, err := json.Marshal(payload) + if err != nil { + return nil, false, fmt.Errorf("encode %s event: %w", event.event, err) + } + transformed = append(transformed, serverSentEvent{event: event.event, data: encoded}) + } + encoded, err := encodeServerSentEvents(transformed) + if err != nil { + return nil, false, err + } + return encoded, true, nil +} + +func autoReviewEventItemID(payload map[string]any) string { + if itemID := itemString(payload, "item_id"); itemID != "" { + return itemID + } + item, _ := payload["item"].(map[string]any) + return itemString(item, "id") +} + +func itemString(item map[string]any, key string) string { + value, _ := item[key].(string) + return value +} + +func newServerSentEvent(event string, payload map[string]any) serverSentEvent { + payload["type"] = event + encoded, _ := json.Marshal(payload) + return serverSentEvent{event: event, data: encoded} +} + +func parseServerSentEvents(body []byte) ([]serverSentEvent, error) { + normalized := bytes.ReplaceAll(body, []byte("\r\n"), []byte("\n")) + var events []serverSentEvent + for _, frame := range bytes.Split(normalized, []byte("\n\n")) { + if len(bytes.TrimSpace(frame)) == 0 { + continue + } + var event serverSentEvent + var dataLines [][]byte + for _, line := range bytes.Split(frame, []byte("\n")) { + switch { + case bytes.HasPrefix(line, []byte("event:")): + event.event = strings.TrimSpace(string(bytes.TrimPrefix(line, []byte("event:")))) + case bytes.HasPrefix(line, []byte("data:")): + dataLines = append(dataLines, bytes.TrimSpace(bytes.TrimPrefix(line, []byte("data:")))) + } + } + if event.event == "" || len(dataLines) == 0 { + return nil, errors.New("malformed Guardian event stream") + } + event.data = bytes.Join(dataLines, []byte("\n")) + events = append(events, event) + } + return events, nil +} + +func encodeServerSentEvents(events []serverSentEvent) ([]byte, error) { + var result bytes.Buffer + sequenceNumber := 0 + for _, event := range events { + data := event.data + if !bytes.Equal(bytes.TrimSpace(data), []byte("[DONE]")) { + var payload map[string]any + if err := json.Unmarshal(data, &payload); err != nil { + return nil, fmt.Errorf("decode transformed %s event: %w", event.event, err) + } + payload["type"] = event.event + payload["sequence_number"] = sequenceNumber + sequenceNumber++ + var err error + data, err = json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("encode transformed %s event: %w", event.event, err) + } + } + fmt.Fprintf(&result, "event: %s\ndata: %s\n\n", event.event, data) + } + return result.Bytes(), nil +} diff --git a/internal/proxy/codex_desktop_errors.go b/internal/proxy/codex_desktop_errors.go new file mode 100644 index 000000000..8b0c015d6 --- /dev/null +++ b/internal/proxy/codex_desktop_errors.go @@ -0,0 +1,206 @@ +package proxy + +import ( + "bufio" + "bytes" + "encoding/json" + "io" + "net/http" + "strings" +) + +const ( + codexSubscriptionMessage = "This model requires a subscription or extra usage credits. Please upgrade at https://ollama.com/upgrade or add extra usage at https://ollama.com/settings to use this model." + codexSignInMessage = "This model requires an Ollama account. Please sign in to Ollama to use this model." +) + +// rewriteAccessErrors changes only Ollama access-error messages. Successful +// events and unrelated errors retain their original bytes and status codes. +func (h *CodexDesktop) rewriteAccessErrors(resp *http.Response) error { + if encoding := resp.Header.Get("Content-Encoding"); encoding != "" && encoding != "identity" { + return nil + } + if strings.HasPrefix(strings.ToLower(resp.Header.Get("Content-Type")), "text/event-stream") { + resp.Body = &codexAccessErrorStream{ + ReadCloser: resp.Body, + reader: bufio.NewReader(resp.Body), + rewrite: func(body []byte) ([]byte, bool) { return h.rewriteAccessErrorJSON(body, resp.StatusCode) }, + limit: h.maxBodyBytes, + } + resp.ContentLength = -1 + resp.Header.Del("Content-Length") + return nil + } + if resp.StatusCode < http.StatusBadRequest { + return nil + } + body, err := io.ReadAll(io.LimitReader(resp.Body, h.maxBodyBytes+1)) + if err != nil { + return err + } + if int64(len(body)) > h.maxBodyBytes { + resp.Body = struct { + io.Reader + io.Closer + }{io.MultiReader(bytes.NewReader(body), resp.Body), resp.Body} + return nil + } + resp.Body.Close() + rewritten, changed := h.rewriteAccessErrorJSON(body, resp.StatusCode) + resp.Body = io.NopCloser(bytes.NewReader(rewritten)) + if changed { + resp.ContentLength = int64(len(rewritten)) + resp.Header.Del("Content-Length") + } + return nil +} + +func (h *CodexDesktop) rewriteAccessErrorJSON(body []byte, status int) ([]byte, bool) { + var payload map[string]json.RawMessage + if json.Unmarshal(body, &payload) != nil || payload == nil { + return body, false + } + var kind string + _ = json.Unmarshal(payload["type"], &kind) + if kind == "response.failed" { + rewritten, changed := h.rewriteAccessErrorJSON(payload["response"], status) + if !changed { + return body, false + } + payload["response"] = rewritten + } else { + var message string + var fields map[string]json.RawMessage + stringError := json.Unmarshal(payload["error"], &message) == nil + flatError := false + if !stringError { + if _, nested := payload["error"]; !nested && kind == "error" { + fields = payload + flatError = true + } else if json.Unmarshal(payload["error"], &fields) != nil || fields == nil { + return body, false + } + if json.Unmarshal(fields["message"], &message) != nil { + return body, false + } + } + var code, errorType string + _ = json.Unmarshal(fields["code"], &code) + _ = json.Unmarshal(fields["type"], &errorType) + var rewritten string + switch { + case status == http.StatusUnauthorized || code == "authentication_error" || code == "unauthorized" || errorType == "authentication_error" || strings.EqualFold(strings.TrimSpace(message), "unauthorized"): + rewritten = codexSignInMessage + // Codex displays these errors as plain text, including raw HTTP error + // bodies. Keep device sign-in URLs out of the user-facing response. + delete(payload, "signin_url") + delete(fields, "signin_url") + case strings.Contains(strings.ToLower(message), "this model requires a subscription or extra usage"): + rewritten = codexSubscriptionMessage + default: + return body, false + } + h.logger.Debug("Codex Ollama access error", "status", status, "code", code, "error", message) + encoded, _ := json.Marshal(rewritten) + if stringError { + payload["error"] = encoded + } else { + fields["message"] = encoded + if !flatError { + payload["error"], _ = json.Marshal(fields) + } + } + } + rewritten, err := json.Marshal(payload) + if err != nil { + return body, false + } + return rewritten, true +} + +// Buffer one SSE frame, not the whole response. Oversized frames fall back to +// passthrough so cosmetic error changes cannot interrupt a valid model stream. +type codexAccessErrorStream struct { + io.ReadCloser + reader *bufio.Reader + rewrite func([]byte) ([]byte, bool) + limit int64 + pending []byte + err error + passthrough bool +} + +func (s *codexAccessErrorStream) Read(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + if len(s.pending) == 0 { + if s.err != nil { + return 0, s.err + } + if s.passthrough { + return s.reader.Read(p) + } + } + if len(s.pending) == 0 && s.err == nil { + var frame []byte + partialLine := false + for { + line, err := s.reader.ReadSlice('\n') + frame = append(frame, line...) + if err != nil && err != bufio.ErrBufferFull { + s.err = err + } + if int64(len(frame)) > s.limit { + s.passthrough = true + break + } + if s.err != nil { + break + } + if !partialLine && (bytes.Equal(line, []byte("\n")) || bytes.Equal(line, []byte("\r\n"))) { + break + } + partialLine = err == bufio.ErrBufferFull + } + s.pending = frame + if !s.passthrough { + s.pending = rewriteCodexErrorFrame(frame, s.rewrite) + } + } + if len(s.pending) > 0 { + n := copy(p, s.pending) + s.pending = s.pending[n:] + return n, nil + } + return 0, s.err +} + +func rewriteCodexErrorFrame(frame []byte, rewrite func([]byte) ([]byte, bool)) []byte { + lines := bytes.SplitAfter(frame, []byte("\n")) + var data [][]byte + for _, line := range lines { + if value, ok := bytes.CutPrefix(line, []byte("data:")); ok { + data = append(data, bytes.TrimSpace(value)) + } + } + rewritten, changed := rewrite(bytes.Join(data, []byte("\n"))) + if !changed { + return frame + } + var result bytes.Buffer + written := false + for _, line := range lines { + if bytes.HasPrefix(line, []byte("data:")) { + if !written { + result.WriteString("data: ") + result.Write(rewritten) + result.WriteByte('\n') + written = true + } + } else { + result.Write(line) + } + } + return result.Bytes() +} diff --git a/internal/proxy/codex_desktop_errors_test.go b/internal/proxy/codex_desktop_errors_test.go new file mode 100644 index 000000000..866b86fea --- /dev/null +++ b/internal/proxy/codex_desktop_errors_test.go @@ -0,0 +1,232 @@ +package proxy + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "testing/iotest" + "time" +) + +const ( + testCodexSubscriptionError = "this model requires a subscription or extra usage, upgrade for access at https://ollama.com/upgrade or add extra usage at https://ollama.com/settings (ref: test-reference)" + testCodexSubscriptionCopy = "This model requires a subscription or extra usage credits. Please upgrade at https://ollama.com/upgrade or add extra usage at https://ollama.com/settings to use this model." + testCodexSignInCopy = "This model requires an Ollama account. Please sign in to Ollama to use this model." +) + +func TestCodexDesktopAccessErrorCopy(t *testing.T) { + for _, tt := range []struct { + name string + status int + contentType string + body string + want string + }{ + { + name: "subscription JSON", status: http.StatusForbidden, contentType: "application/json", + body: fmt.Sprintf(`{"error":{"message":%q,"type":"permission_error","code":"subscription_required"},"request_id":"keep-me"}`, testCodexSubscriptionError), + want: testCodexSubscriptionCopy, + }, + { + name: "unauthorized JSON", status: http.StatusUnauthorized, contentType: "application/json", + body: `{"error":"unauthorized","signin_url":"https://ollama.com/connect?name=test&key=test-key"}`, + want: testCodexSignInCopy, + }, + { + name: "unauthorized JSON with embedded sign-in URL", status: http.StatusUnauthorized, contentType: "application/json", + body: `{"error":{"message":"Sign in: https://ollama.com/connect?name=test&key=test-key","code":"authentication_error","signin_url":"https://ollama.com/connect?name=test&key=test-key"}}`, + want: testCodexSignInCopy, + }, + { + name: "subscription stream", status: http.StatusOK, contentType: "text/event-stream; charset=utf-8", + body: fmt.Sprintf("event: response.failed\ndata: {\"type\":\"response.failed\",\"sequence_number\":4,\"response\":{\"id\":\"resp_test\",\"status\":\"failed\",\"error\":{\"code\":\"api_error\",\"message\":%q}}}\n\n", testCodexSubscriptionError), + want: testCodexSubscriptionCopy, + }, + { + name: "unauthorized stream", status: http.StatusOK, contentType: "text/event-stream", + body: "event: response.failed\ndata: {\"type\":\"response.failed\",\"response\":{\"status\":\"failed\",\"error\":{\"code\":\"authentication_error\",\"message\":\"Sign in: https://ollama.com/connect?name=test&key=test-key\"},\"signin_url\":\"https://ollama.com/connect?name=test&key=test-key\"}}\n\n", + want: testCodexSignInCopy, + }, + { + name: "nested SSE error", status: http.StatusOK, contentType: "text/event-stream", + body: "event: error\ndata: {\"type\":\"error\",\"error\":{\"type\":\"authentication_error\",\"message\":\"unauthorized\",\"signin_url\":\"https://ollama.com/connect?name=test&key=test-key\"}}\n\n", + want: testCodexSignInCopy, + }, + { + name: "multiline stream with CRLF", status: http.StatusOK, contentType: "text/event-stream", + body: "id: keep-me\r\nevent: error\r\ndata: {\"type\":\"error\",\r\ndata: \"code\":\"authentication_error\",\"message\":\"unauthorized\",\"signin_url\":\"https://ollama.com/connect?name=test&key=test-key\"}\r\n\r\n", + want: testCodexSignInCopy, + }, + { + name: "unrelated forbidden error", status: http.StatusForbidden, contentType: "application/json", + body: `{"error":{"message":"cloud is disabled","code":"cloud_disabled"}}`, + }, + { + name: "rate limit", status: http.StatusTooManyRequests, contentType: "application/json", + body: `{"error":{"message":"too many requests","code":"rate_limit_exceeded"}}`, + }, + { + name: "successful JSON", status: http.StatusOK, contentType: "application/json", + body: `{"status":"completed","output":[{"text":"unauthorized"}]}`, + }, + { + name: "successful stream mentioning error text", status: http.StatusOK, contentType: "text/event-stream", + body: fmt.Sprintf("event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"delta\":%q}\n\n: heartbeat\n\ndata: [DONE]\n\n", testCodexSubscriptionError), + }, + { + name: "malformed error", status: http.StatusForbidden, contentType: "application/json", + body: `{"error":`, + }, + } { + t.Run(tt.name, func(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", tt.contentType) + w.Header().Set("Content-Length", strconv.Itoa(len(tt.body))) + w.Header().Set("X-Request-ID", "keep-me") + w.WriteHeader(tt.status) + _, _ = io.WriteString(w, tt.body) + })) + defer upstream.Close() + handler := newTestCodexDesktop(t, upstream.URL, upstream.URL, writeCatalog(t, "test:cloud")) + endpoint := httptest.NewServer(handler) + defer endpoint.Close() + + for _, model := range []string{"test:cloud", "gpt-native"} { + req, err := http.NewRequest(http.MethodPost, endpoint.URL+CodexDesktopPathPrefix+"/v1/responses", strings.NewReader(fmt.Sprintf(`{"model":%q,"stream":true}`, model))) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer test-chatgpt") + req.Header.Set("ChatGPT-Account-ID", "test-account") + resp, err := endpoint.Client().Do(req) + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != tt.status || resp.Header.Get("X-Request-ID") != "keep-me" { + t.Errorf("status/headers changed: %d %v", resp.StatusCode, resp.Header) + } + if model == "gpt-native" || tt.want == "" { + if string(body) != tt.body { + t.Errorf("%s response changed: %s", model, body) + } + continue + } + if !strings.Contains(string(body), tt.want) { + t.Errorf("body = %s, want copy %q", body, tt.want) + } + if tt.want == testCodexSignInCopy { + for _, unwanted := range []string{"signin_url", "https://ollama.com/connect", "ollama://connect", "test-key"} { + if strings.Contains(string(body), unwanted) { + t.Errorf("device sign-in link is still visible: %s", body) + } + } + } + if tt.want == testCodexSubscriptionCopy && strings.Contains(string(body), "test-reference") { + t.Errorf("old subscription message is still visible: %s", body) + } + if strings.Contains(tt.contentType, "event-stream") { + if !strings.Contains(string(body), "event:") { + t.Errorf("SSE framing was lost: %s", body) + } + } else if !json.Valid(body) { + t.Errorf("invalid JSON response: %s", body) + } + } + }) + } +} + +func TestCodexDesktopAccessErrorCopySignInMessage(t *testing.T) { + handler := newTestCodexDesktop(t, "http://localhost", "http://localhost", "unused") + body, changed := handler.rewriteAccessErrorJSON([]byte(`{"error":"unauthorized"}`), http.StatusUnauthorized) + want, err := json.Marshal(map[string]string{"error": testCodexSignInCopy}) + if err != nil { + t.Fatal(err) + } + if !changed || !bytes.Equal(body, want) { + t.Fatalf("error = %s, want only the short sign-in message %s", body, want) + } +} + +func TestCodexAccessErrorStreamFragmentedFrames(t *testing.T) { + body := "event: error\ndata: {\"type\":\"error\",\"code\":\"authentication_error\",\"message\":\"unauthorized\"}\n\n" + handler := newTestCodexDesktop(t, "http://localhost", "http://localhost", "unused") + upstream := io.NopCloser(iotest.OneByteReader(strings.NewReader(body))) + stream := &codexAccessErrorStream{ + ReadCloser: upstream, + reader: bufio.NewReader(upstream), + rewrite: func(body []byte) ([]byte, bool) { return handler.rewriteAccessErrorJSON(body, http.StatusOK) }, + limit: defaultMaxBodyBytes, + } + defer stream.Close() + got, err := io.ReadAll(stream) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(got), testCodexSignInCopy) || !bytes.HasSuffix(got, []byte("\n\n")) { + t.Fatalf("fragmented event = %s, want rewritten message and intact SSE framing", got) + } +} + +func TestCodexAccessErrorStreamOversizedFramePassesThrough(t *testing.T) { + body := "data: " + strings.Repeat("x", 8192) + "\n\n: heartbeat\n\n" + upstream := io.NopCloser(strings.NewReader(body)) + stream := &codexAccessErrorStream{ + ReadCloser: upstream, + reader: bufio.NewReader(upstream), + rewrite: func([]byte) ([]byte, bool) { + t.Error("oversized frame should not be rewritten") + return nil, false + }, + limit: 4096, + } + defer stream.Close() + got, err := io.ReadAll(stream) + if err != nil || string(got) != body { + t.Fatalf("large event was not passed through unchanged: length=%d, error=%v", len(got), err) + } +} + +func TestCodexDesktopAccessErrorCopyDoesNotBufferSuccessfulStream(t *testing.T) { + first := "event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}\n\n" + release := make(chan struct{}) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, first) + w.(http.Flusher).Flush() + select { + case <-release: + case <-r.Context().Done(): + } + })) + defer upstream.Close() + defer close(release) + handler := newTestCodexDesktop(t, upstream.URL, upstream.URL, writeCatalog(t, "test:cloud")) + endpoint := httptest.NewServer(handler) + defer endpoint.Close() + client := &http.Client{Timeout: 3 * time.Second} + resp, err := client.Post(endpoint.URL+CodexDesktopPathPrefix+"/v1/responses", "application/json", strings.NewReader(`{"model":"test:cloud","stream":true}`)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + got := make([]byte, len(first)) + if _, err := io.ReadFull(resp.Body, got); err != nil { + t.Fatal(err) + } + if string(got) != first { + t.Fatalf("first event = %q, want %q", got, first) + } +} diff --git a/internal/proxy/codex_desktop_normalize.go b/internal/proxy/codex_desktop_normalize.go new file mode 100644 index 000000000..3b9f2f55b --- /dev/null +++ b/internal/proxy/codex_desktop_normalize.go @@ -0,0 +1,333 @@ +package proxy + +import ( + "bytes" + "encoding/json" + "fmt" + "slices" + "strings" +) + +func extractModel(body []byte) (string, bool) { + if len(body) == 0 { + return "", false + } + var payload struct { + Model string `json:"model"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return "", false + } + payload.Model = strings.TrimSpace(payload.Model) + return payload.Model, payload.Model != "" +} + +func replaceRequestModel(body []byte, model string) ([]byte, error) { + var payload map[string]json.RawMessage + if err := json.Unmarshal(body, &payload); err != nil { + return nil, err + } + encodedModel, err := json.Marshal(model) + if err != nil { + return nil, err + } + payload["model"] = encodedModel + return json.Marshal(payload) +} + +// normalizeOllamaRequestBody removes incompatible provider state while preserving Ollama tool history. +func normalizeOllamaRequestBody(body []byte, model routingModel) ([]byte, error) { + normalized, _, err := normalizeRequestInput(body, normalizeOllamaInputItem) + if err != nil || model.Thinking == nil { + return normalized, err + } + return normalizeOllamaThinking(normalized, *model.Thinking) +} + +func normalizeOllamaThinking(body []byte, metadata routingThinkingMetadata) ([]byte, error) { + var payload map[string]json.RawMessage + if err := json.Unmarshal(body, &payload); err != nil { + return nil, err + } + _, hadThinkOverride := payload["think"] + delete(payload, "think") + if !metadata.Supported { + if _, ok := payload["reasoning"]; !ok && !hadThinkOverride { + return body, nil + } + delete(payload, "reasoning") + return json.Marshal(payload) + } + + reasoningData, ok := payload["reasoning"] + if !ok || len(metadata.Levels) == 0 { + if hadThinkOverride { + return json.Marshal(payload) + } + return body, nil + } + var reasoning map[string]json.RawMessage + if err := json.Unmarshal(reasoningData, &reasoning); err != nil { + return nil, fmt.Errorf("decode reasoning: %w", err) + } + var effort string + if rawEffort, ok := reasoning["effort"]; ok { + if err := json.Unmarshal(rawEffort, &effort); err != nil { + return nil, fmt.Errorf("decode reasoning effort: %w", err) + } + } + if effort == "" { + if hadThinkOverride { + return json.Marshal(payload) + } + return body, nil + } + + normalizedEffort := normalizeThinkingEffort(effort, metadata.Levels) + if normalizedEffort == "" { + // Omit stale effort selections so the model can use its default. + delete(reasoning, "effort") + } else { + encodedEffort, err := json.Marshal(normalizedEffort) + if err != nil { + return nil, fmt.Errorf("encode reasoning effort: %w", err) + } + reasoning["effort"] = encodedEffort + if rawValue, ok := metadata.Values[normalizedEffort]; ok { + // Preserve boolean thinking values that Codex's named efforts cannot represent. + payload["think"] = rawValue + } + } + encodedReasoning, err := json.Marshal(reasoning) + if err != nil { + return nil, fmt.Errorf("encode reasoning: %w", err) + } + payload["reasoning"] = encodedReasoning + return json.Marshal(payload) +} + +func normalizeThinkingEffort(effort string, levels []string) string { + var normalized string + switch effort { + case "minimal": + normalized = "low" + case "xhigh", "ultra": + normalized = "max" + case "none", "low", "medium", "high", "max": + normalized = effort + default: + return "" + } + + if slices.Equal(levels, []string{"none", "medium"}) && normalized != "none" { + // Binary thinking uses "medium" for on, not an adjustable effort level. + return "medium" + } + if slices.Contains(levels, normalized) { + return normalized + } + if normalized == "max" && slices.Contains(levels, "high") { + // A stale xhigh or ultra choice should use the strongest supported level. + return "high" + } + return "" +} + +// normalizeNativeRequestBody strips Ollama state that OpenAI cannot decrypt, +// preserving visible messages and tool history. +func normalizeNativeRequestBody(body []byte) ([]byte, bool, error) { + return normalizeRequestInput(body, normalizeChatGPTInputItem) +} + +func normalizeRequestInput( + body []byte, + normalizeItem func(json.RawMessage) (json.RawMessage, bool, error), +) ([]byte, bool, error) { + if len(body) == 0 { + return body, false, nil + } + + var payload map[string]json.RawMessage + if err := json.Unmarshal(body, &payload); err != nil { + return nil, false, err + } + input, ok := payload["input"] + if !ok || len(input) == 0 || input[0] != '[' { + return body, false, nil + } + + var items []json.RawMessage + if err := json.Unmarshal(input, &items); err != nil { + return nil, false, fmt.Errorf("decode input: %w", err) + } + + normalized := make([]json.RawMessage, 0, len(items)) + changed := false + for _, item := range items { + converted, keep, err := normalizeItem(item) + if err != nil { + return nil, false, err + } + if keep { + normalized = append(normalized, converted) + } + if !keep || !bytes.Equal(item, converted) { + changed = true + } + } + if !changed { + return body, false, nil + } + + encodedInput, err := json.Marshal(normalized) + if err != nil { + return nil, false, fmt.Errorf("encode input: %w", err) + } + payload["input"] = encodedInput + encoded, err := json.Marshal(payload) + if err != nil { + return nil, false, fmt.Errorf("encode request: %w", err) + } + return encoded, true, nil +} + +func normalizeOllamaInputItem(item json.RawMessage) (json.RawMessage, bool, error) { + var header struct { + Type string `json:"type"` + Role string `json:"role"` + } + if err := json.Unmarshal(item, &header); err != nil { + return nil, false, fmt.Errorf("decode input item: %w", err) + } + + // Ollama accepts message shorthand without an explicit type as well as + // the supported Responses item types below. + itemType := header.Type + if itemType == "" && header.Role != "" { + itemType = "message" + } + switch itemType { + case "message": + if header.Role != "developer" { + return item, true, nil + } + // Map developer instructions to system for models without developer-role support. + var message map[string]json.RawMessage + if err := json.Unmarshal(item, &message); err != nil { + return nil, false, fmt.Errorf("decode developer message: %w", err) + } + message["role"] = json.RawMessage(`"system"`) + converted, err := json.Marshal(message) + if err != nil { + return nil, false, fmt.Errorf("encode system message: %w", err) + } + return converted, true, nil + case "function_call", "function_call_output": + return item, true, nil + case "tool_search_call", "tool_search_output", "compaction_trigger": + // Preserve client-executed control items for the Responses adapter. + return item, true, nil + case "compaction": + // Only Ollama compaction state can be expanded by the server middleware. + return item, isOllamaCompactionItem(item), nil + case "reasoning": + var reasoning struct { + ID string `json:"id"` + } + if err := json.Unmarshal(item, &reasoning); err != nil { + return nil, false, fmt.Errorf("decode reasoning item: %w", err) + } + // Keep Ollama reasoning for tool loops, but omit native encrypted state. + return item, isOllamaReasoningItemID(reasoning.ID), nil + case "custom_tool_call": + var call struct { + ID string `json:"id,omitempty"` + CallID string `json:"call_id"` + Name string `json:"name"` + Input string `json:"input"` + } + if err := json.Unmarshal(item, &call); err != nil { + return nil, false, fmt.Errorf("decode custom tool call: %w", err) + } + arguments, err := json.Marshal(map[string]string{"input": call.Input}) + if err != nil { + return nil, false, fmt.Errorf("encode custom tool input: %w", err) + } + converted, err := json.Marshal(map[string]any{ + "id": call.ID, + "type": "function_call", + "call_id": call.CallID, + "name": call.Name, + "arguments": string(arguments), + }) + return converted, true, err + case "custom_tool_call_output": + var output struct { + CallID string `json:"call_id"` + Output json.RawMessage `json:"output"` + } + if err := json.Unmarshal(item, &output); err != nil { + return nil, false, fmt.Errorf("decode custom tool output: %w", err) + } + converted, err := json.Marshal(map[string]any{ + "type": "function_call_output", + "call_id": output.CallID, + "output": output.Output, + }) + return converted, true, err + default: + // Ignore unsupported items without rejecting the remaining history. + return nil, false, nil + } +} + +func isOllamaCompactionItem(item json.RawMessage) bool { + var wire struct { + EncryptedContent string `json:"encrypted_content"` + } + if json.Unmarshal(item, &wire) != nil || wire.EncryptedContent == "" { + return false + } + var payload struct { + Type string `json:"type"` + } + return json.Unmarshal([]byte(wire.EncryptedContent), &payload) == nil && + payload.Type == "ollama_compaction" +} + +func normalizeChatGPTInputItem(item json.RawMessage) (json.RawMessage, bool, error) { + var header struct { + ID string `json:"id"` + Type string `json:"type"` + } + if err := json.Unmarshal(item, &header); err != nil { + return nil, false, fmt.Errorf("decode input item: %w", err) + } + if header.Type == "reasoning" && isOllamaReasoningItemID(header.ID) { + return nil, false, nil + } + if header.Type == "compaction" && isOllamaCompactionItem(item) { + // OpenAI cannot decrypt Ollama compaction state; omit it when switching providers. + return nil, false, nil + } + return item, true, nil +} + +func isOllamaReasoningItemID(id string) bool { + suffix, ok := strings.CutPrefix(strings.TrimSpace(id), "rs_") + if !ok { + return false + } + if responseSuffix, ok := strings.CutPrefix(suffix, "resp_"); ok { + suffix = responseSuffix + } + if suffix == "" || len(suffix) > 6 { + return false + } + for _, char := range suffix { + if char < '0' || char > '9' { + return false + } + } + return true +} diff --git a/internal/proxy/codex_desktop_test.go b/internal/proxy/codex_desktop_test.go new file mode 100644 index 000000000..1f9652396 --- /dev/null +++ b/internal/proxy/codex_desktop_test.go @@ -0,0 +1,1586 @@ +package proxy + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/klauspost/compress/zstd" +) + +func TestCodexDesktopRoutesCatalogModelToOllamaAndStripsCredentials(t *testing.T) { + var gotPath, gotQuery, gotAuthorization, gotEncoding, gotMetadata string + var gotBody []byte + ollama := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotQuery = r.URL.RawQuery + gotAuthorization = r.Header.Get("Authorization") + gotEncoding = r.Header.Get("Content-Encoding") + gotMetadata = r.Header.Get("X-Codex-Turn-Metadata") + gotBody, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "data: routed\n\n") + })) + defer ollama.Close() + + chatGPT := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("catalog model should not reach ChatGPT") + })) + defer chatGPT.Close() + + catalogPath := writeCatalog(t, "glm-5.2:cloud") + handler := newTestCodexDesktop(t, ollama.URL, chatGPT.URL+"/backend-api/codex", catalogPath) + proxy := httptest.NewServer(handler) + defer proxy.Close() + + payload := []byte(`{"model":"glm-5.2:cloud","stream":true,"reasoning":{"effort":"high"},"tools":[{"type":"web_search","external_web_access":false}]}`) + encoder, err := zstd.NewWriter(nil) + if err != nil { + t.Fatal(err) + } + compressed := encoder.EncodeAll(payload, nil) + encoder.Close() + + for _, authorization := range []string{"Bearer chatgpt-secret", "Bearer " + CodexDesktopManagedAPIKey} { + req, err := http.NewRequest(http.MethodPost, proxy.URL+CodexDesktopPathPrefix+"/v1/responses?trace=1", bytes.NewReader(compressed)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Content-Encoding", "zstd") + req.Header.Set("ChatGPT-Account-ID", "account-123") + req.Header.Set("Authorization", authorization) + req.Header.Set("X-Codex-Turn-Metadata", `{"thread":"secret"}`) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + responseBody, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + if resp.StatusCode != http.StatusOK || string(responseBody) != "data: routed\n\n" { + t.Fatalf("response with %q = %d %q", authorization, resp.StatusCode, responseBody) + } + if gotPath != "/v1/responses" || gotQuery != "trace=1" { + t.Fatalf("Ollama target = %s?%s", gotPath, gotQuery) + } + if gotAuthorization != "" || gotEncoding != "" || gotMetadata != "" { + t.Fatalf("credentials leaked to Ollama: authorization=%q encoding=%q metadata=%q", gotAuthorization, gotEncoding, gotMetadata) + } + if string(gotBody) != string(payload) { + t.Fatalf("Ollama body = %q, want decompressed %q", gotBody, payload) + } + } +} + +func TestCodexDesktopRoutesOllamaAndNativeModelsThroughOneEndpoint(t *testing.T) { + var ollamaCalls, chatGPTCalls int + ollama := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + ollamaCalls++ + w.WriteHeader(http.StatusNoContent) + })) + defer ollama.Close() + chatGPT := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + chatGPTCalls++ + w.WriteHeader(http.StatusNoContent) + })) + defer chatGPT.Close() + + handler := newTestCodexDesktop(t, ollama.URL, chatGPT.URL+"/backend-api/codex", writeCatalog(t, "glm-5.2:cloud")) + endpoint := httptest.NewServer(handler) + defer endpoint.Close() + + for _, model := range []string{"glm-5.2:cloud", "gpt-5.6-sol"} { + req, err := http.NewRequest( + http.MethodPost, + endpoint.URL+CodexDesktopPathPrefix+"/v1/responses", + strings.NewReader(fmt.Sprintf(`{"model":%q}`, model)), + ) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer chatgpt-secret") + req.Header.Set("ChatGPT-Account-ID", "account-123") + resp, err := endpoint.Client().Do(req) + if err != nil { + t.Fatal(err) + } + _ = resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("model %q status = %d", model, resp.StatusCode) + } + } + + if ollamaCalls != 1 || chatGPTCalls != 1 { + t.Fatalf("routes through one endpoint: Ollama calls=%d ChatGPT calls=%d", ollamaCalls, chatGPTCalls) + } +} + +func TestNormalizeOllamaThinkingUsesRoutedModelContract(t *testing.T) { + tests := []struct { + name string + model routingModel + effort string + wantReasoning bool + wantEffort string + omitEffort bool + wantThink any + hasThink bool + }{ + { + name: "legacy catalog preserves request", + effort: "high", + wantReasoning: true, + wantEffort: "high", + }, + { + name: "non-thinking model drops stale reasoning", + model: routingModel{Thinking: &routingThinkingMetadata{}}, + effort: "high", + wantReasoning: false, + }, + { + name: "binary thinking stays off", + model: routingModel{Thinking: &routingThinkingMetadata{ + Supported: true, + Levels: []string{"none", "medium"}, + Values: map[string]json.RawMessage{"none": json.RawMessage("false"), "medium": json.RawMessage("true")}, + }}, + effort: "none", + wantReasoning: true, + wantEffort: "none", + wantThink: false, + hasThink: true, + }, + { + name: "binary thinking uses medium for its enabled choice", + model: routingModel{Thinking: &routingThinkingMetadata{ + Supported: true, + Levels: []string{"none", "medium"}, + Values: map[string]json.RawMessage{"none": json.RawMessage("false"), "medium": json.RawMessage("true")}, + }}, + effort: "medium", + wantReasoning: true, + wantEffort: "medium", + wantThink: true, + hasThink: true, + }, + { + name: "binary thinking maps a stale enabled level to medium", + model: routingModel{Thinking: &routingThinkingMetadata{ + Supported: true, + Levels: []string{"none", "medium"}, + Values: map[string]json.RawMessage{"none": json.RawMessage("false"), "medium": json.RawMessage("true")}, + }}, + effort: "high", + wantReasoning: true, + wantEffort: "medium", + wantThink: true, + hasThink: true, + }, + { + name: "exact GLM ladder clamps xhigh to max", + model: routingModel{Thinking: &routingThinkingMetadata{ + Supported: true, + Levels: []string{"low", "high", "max"}, + Values: map[string]json.RawMessage{"low": json.RawMessage(`"low"`), "high": json.RawMessage(`"high"`), "max": json.RawMessage(`"max"`)}, + }}, + effort: "xhigh", + wantReasoning: true, + wantEffort: "max", + wantThink: "max", + hasThink: true, + }, + { + name: "exact GLM ladder omits unsupported medium", + model: routingModel{Thinking: &routingThinkingMetadata{ + Supported: true, + Levels: []string{"low", "high", "max"}, + }}, + effort: "medium", + wantReasoning: true, + omitEffort: true, + }, + { + name: "always-thinking GLM omits stale off", + model: routingModel{Thinking: &routingThinkingMetadata{ + Supported: true, + Levels: []string{"low", "high", "max"}, + }}, + effort: "none", + wantReasoning: true, + omitEffort: true, + }, + { + name: "minimal maps to Ollama low", + model: routingModel{Thinking: &routingThinkingMetadata{ + Supported: true, + Levels: []string{"low", "medium", "high"}, + Values: map[string]json.RawMessage{"low": json.RawMessage(`"low"`), "medium": json.RawMessage(`"medium"`), "high": json.RawMessage(`"high"`)}, + }}, + effort: "minimal", + wantReasoning: true, + wantEffort: "low", + wantThink: "low", + hasThink: true, + }, + { + name: "stale xhigh clamps to the strongest supported effort", + model: routingModel{Thinking: &routingThinkingMetadata{ + Supported: true, + Levels: []string{"low", "medium", "high"}, + Values: map[string]json.RawMessage{"low": json.RawMessage(`"low"`), "medium": json.RawMessage(`"medium"`), "high": json.RawMessage(`"high"`)}, + }}, + effort: "xhigh", + wantReasoning: true, + wantEffort: "high", + wantThink: "high", + hasThink: true, + }, + { + name: "unknown effort is omitted instead of enabling binary thinking", + model: routingModel{Thinking: &routingThinkingMetadata{ + Supported: true, + Levels: []string{"none", "medium"}, + }}, + effort: "unexpected", + wantReasoning: true, + omitEffort: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body, err := json.Marshal(map[string]any{ + "model": "test-model", + "reasoning": map[string]any{ + "effort": tt.effort, + "summary": "auto", + }, + }) + if err != nil { + t.Fatal(err) + } + normalized, err := normalizeOllamaRequestBody(body, tt.model) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(normalized, &payload); err != nil { + t.Fatal(err) + } + reasoning, ok := payload["reasoning"].(map[string]any) + if ok != tt.wantReasoning { + t.Fatalf("reasoning present = %v, want %v in %s", ok, tt.wantReasoning, normalized) + } + gotThink, hasThink := payload["think"] + if hasThink != tt.hasThink || (hasThink && gotThink != tt.wantThink) { + t.Fatalf("think = %#v, %v; want %#v, %v in %s", gotThink, hasThink, tt.wantThink, tt.hasThink, normalized) + } + if !tt.wantReasoning { + return + } + gotEffort, hasEffort := reasoning["effort"].(string) + if tt.omitEffort { + if hasEffort { + t.Fatalf("reasoning effort = %q, want model default with no explicit effort in %s", gotEffort, normalized) + } + } else if !hasEffort || gotEffort != tt.wantEffort { + t.Fatalf("reasoning effort = %q, %v; want %q in %s", gotEffort, hasEffort, tt.wantEffort, normalized) + } + if got, _ := reasoning["summary"].(string); got != "auto" { + t.Fatalf("reasoning summary = %q, want preserved", got) + } + }) + } +} + +func TestNormalizeOllamaThinkingRejectsMalformedReasoning(t *testing.T) { + model := routingModel{Thinking: &routingThinkingMetadata{ + Supported: true, + Levels: []string{"none", "medium"}, + }} + _, err := normalizeOllamaRequestBody([]byte(`{"model":"test-model","reasoning":"high"}`), model) + if err == nil || !strings.Contains(err.Error(), "decode reasoning") { + t.Fatalf("normalize error = %v, want malformed reasoning error", err) + } +} + +func TestLoadCatalogModelsReadsOptionalThinkingMetadata(t *testing.T) { + path := filepath.Join(t.TempDir(), CodexDesktopModelCatalogFilename) + data := []byte(`{"models":[{"slug":"legacy"},{"slug":"binary","thinking":{"supported":true,"levels":["none","medium"],"values":{"none":false,"medium":true}}}]}`) + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + catalog, err := loadRoutingCatalog(path) + if err != nil { + t.Fatal(err) + } + models := catalog.models + if models["legacy"].Thinking != nil { + t.Fatalf("legacy model thinking metadata = %+v, want absent", models["legacy"].Thinking) + } + binary := models["binary"].Thinking + if binary == nil || !binary.Supported || strings.Join(binary.Levels, ",") != "none,medium" { + t.Fatalf("binary model thinking metadata = %+v, want off/medium contract", binary) + } + if string(binary.Values["none"]) != "false" || string(binary.Values["medium"]) != "true" { + t.Fatalf("binary model thinking values = %#v, want exact false/true values", binary.Values) + } +} + +func TestCodexDesktopRoutesAutoReviewToConfiguredOllamaModel(t *testing.T) { + var gotBody []byte + ollama := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotBody, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, autoReviewJSONResponse(`{"risk_level":"low","user_authorization":"high","outcome":"allow","rationale":"The user requested this action."}`)) + })) + defer ollama.Close() + + chatGPT := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("configured Auto-review request should not reach ChatGPT") + })) + defer chatGPT.Close() + + handler := newTestCodexDesktop(t, ollama.URL, chatGPT.URL, writeCatalogWithAutoReview(t, "glm-5.3-flash:cloud", "glm-5.3-flash:cloud")) + proxy := httptest.NewServer(handler) + defer proxy.Close() + + resp, err := http.Post( + proxy.URL+CodexDesktopPathPrefix+"/v1/responses", + "application/json", + strings.NewReader(`{"model":"codex-auto-review","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"review"}]}]}`), + ) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + + var forwarded struct { + Model string `json:"model"` + Tools []struct { + Type string `json:"type"` + Name string `json:"name"` + Strict bool `json:"strict"` + Parameters struct { + Required []string `json:"required"` + AdditionalProperties bool `json:"additionalProperties"` + } `json:"parameters"` + } `json:"tools"` + Input []struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + } `json:"input"` + } + if err := json.Unmarshal(gotBody, &forwarded); err != nil { + t.Fatal(err) + } + if forwarded.Model != "glm-5.3-flash:cloud" { + t.Fatalf("forwarded model = %q, want selected Ollama model", forwarded.Model) + } + if len(forwarded.Input) != 1 || len(forwarded.Input[0].Content) != 2 { + t.Fatalf("forwarded input = %#v", forwarded.Input) + } + instruction := forwarded.Input[0].Content[1] + if instruction.Type != "input_text" || !strings.Contains(instruction.Text, "call submit_guardian_decision exactly once") { + t.Fatalf("Auto-review decision instruction = %#v", instruction) + } + if len(forwarded.Tools) != 1 || forwarded.Tools[0].Type != "function" || forwarded.Tools[0].Name != guardianDecisionToolName || !forwarded.Tools[0].Strict { + t.Fatalf("Auto-review tools = %#v", forwarded.Tools) + } + if len(forwarded.Tools[0].Parameters.Required) != 4 || forwarded.Tools[0].Parameters.AdditionalProperties { + t.Fatalf("Auto-review tool schema = %#v", forwarded.Tools[0].Parameters) + } + + var raw map[string]json.RawMessage + if err := json.Unmarshal(gotBody, &raw); err != nil { + t.Fatal(err) + } + if _, ok := raw["response_format"]; ok { + t.Fatalf("Auto-review request unexpectedly used structured outputs: %s", gotBody) + } + if _, ok := raw["text"]; ok { + t.Fatalf("Auto-review request unexpectedly added a structured text format: %s", gotBody) + } + + var response struct { + Output []struct { + Type string `json:"type"` + Content []struct { + Text string `json:"text"` + } `json:"content"` + } `json:"output"` + } + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + t.Fatal(err) + } + if len(response.Output) != 1 || response.Output[0].Type != "message" || len(response.Output[0].Content) != 1 || response.Output[0].Content[0].Text != `{"risk_level":"low","user_authorization":"high","outcome":"allow","rationale":"The user requested this action."}` { + t.Fatalf("translated Auto-review response = %#v", response.Output) + } +} + +func TestPrepareAutoReviewRequestSupportsStringContentAndPreservesTools(t *testing.T) { + body := []byte(`{"model":"codex-auto-review","tools":[{"type":"function","name":"inspect","description":"Inspect","strict":true,"parameters":{"type":"object"}}],"input":[{"role":"user","content":"review"}]}`) + updated, err := prepareAutoReviewRequest(body) + if err != nil { + t.Fatal(err) + } + var payload struct { + Tools []struct { + Name string `json:"name"` + } `json:"tools"` + Input []struct { + Content string `json:"content"` + } `json:"input"` + } + if err := json.Unmarshal(updated, &payload); err != nil { + t.Fatal(err) + } + if len(payload.Input) != 1 || !strings.HasPrefix(payload.Input[0].Content, "review") || !strings.Contains(payload.Input[0].Content, guardianDecisionToolName) { + t.Fatalf("updated input = %#v", payload.Input) + } + if len(payload.Tools) != 2 || payload.Tools[0].Name != "inspect" || payload.Tools[1].Name != guardianDecisionToolName { + t.Fatalf("updated tools = %#v", payload.Tools) + } +} + +func TestPrepareAutoReviewRequestRejectsRequestsWithoutUserMessages(t *testing.T) { + body := []byte(`{"model":"codex-auto-review","input":[{"role":"developer","content":"policy"}]}`) + if _, err := prepareAutoReviewRequest(body); err == nil || !strings.Contains(err.Error(), "no user message") { + t.Fatalf("error = %v, want missing user message", err) + } +} + +func TestPrepareAutoReviewRequestUsesLastUserMessage(t *testing.T) { + body := []byte(`{"input":[{"role":"user","content":"earlier"},{"role":"assistant","content":"reviewed"},{"role":"user","content":"latest"}]}`) + updated, err := prepareAutoReviewRequest(body) + if err != nil { + t.Fatal(err) + } + var payload struct { + Input []struct { + Content string `json:"content"` + } `json:"input"` + } + if err := json.Unmarshal(updated, &payload); err != nil { + t.Fatal(err) + } + if payload.Input[0].Content != "earlier" { + t.Fatalf("earlier user message was changed: %q", payload.Input[0].Content) + } + if !strings.HasPrefix(payload.Input[2].Content, "latest") || !strings.Contains(payload.Input[2].Content, guardianDecisionToolName) { + t.Fatalf("latest user message = %q", payload.Input[2].Content) + } +} + +func TestTransformAutoReviewEventStreamConvertsDecisionToolCall(t *testing.T) { + arguments := `{"risk_level":"medium","user_authorization":"low","outcome":"deny","rationale":"The action exceeds the user's authorization."}` + body := autoReviewEventStream(t, guardianDecisionToolName, arguments) + transformed, changed, err := transformAutoReviewResponse(body, "text/event-stream; charset=utf-8") + if err != nil { + t.Fatal(err) + } + if !changed { + t.Fatal("decision tool call was not transformed") + } + text := string(transformed) + if strings.Contains(text, "response.function_call_arguments") || strings.Contains(text, `"type":"function_call"`) { + t.Fatalf("transformed stream still contains the decision tool call:\n%s", text) + } + for _, want := range []string{ + "event: response.output_text.delta", + "event: response.output_text.done", + `\"outcome\":\"deny\"`, + `"type":"message"`, + } { + if !strings.Contains(text, want) { + t.Fatalf("transformed stream missing %q:\n%s", want, text) + } + } + + events, err := parseServerSentEvents(transformed) + if err != nil { + t.Fatal(err) + } + for i, event := range events { + if bytes.Equal(bytes.TrimSpace(event.data), []byte("[DONE]")) { + continue + } + var payload struct { + Sequence int `json:"sequence_number"` + } + if err := json.Unmarshal(event.data, &payload); err != nil { + t.Fatal(err) + } + if payload.Sequence != i { + t.Fatalf("event %d sequence_number = %d", i, payload.Sequence) + } + } +} + +func TestTransformAutoReviewResponsePassesThroughInvestigativeToolCall(t *testing.T) { + body := []byte(autoReviewJSONResponseForTool("inspect_command", `{"command":"rm -rf tmp"}`)) + transformed, changed, err := transformAutoReviewResponse(body, "application/json") + if err != nil { + t.Fatal(err) + } + if changed || !bytes.Equal(transformed, body) { + t.Fatalf("investigative tool call changed: %s", transformed) + } +} + +func TestTransformAutoReviewResponseRejectsInvalidDecision(t *testing.T) { + body := []byte(autoReviewJSONResponse(`{"risk_level":"low","user_authorization":"high","outcome":"maybe","rationale":"Invalid outcome."}`)) + if _, _, err := transformAutoReviewResponse(body, "application/json"); err == nil || !strings.Contains(err.Error(), "invalid Guardian outcome") { + t.Fatalf("error = %v, want invalid outcome", err) + } +} + +func TestTransformAutoReviewResponseRejectsTextDecision(t *testing.T) { + body := []byte(`{"id":"resp_1","status":"completed","output":[{"id":"msg_1","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"{\\\"outcome\\\":\\\"allow\\\"}"}]}]}`) + if _, _, err := transformAutoReviewResponse(body, "application/json"); err == nil || !strings.Contains(err.Error(), "did not call") { + t.Fatalf("error = %v, want missing decision tool call", err) + } +} + +func TestTransformAutoReviewJSONDiscardsProseWithValidDecision(t *testing.T) { + arguments := `{"risk_level":"low","user_authorization":"high","outcome":"allow","rationale":"The command is read-only and explicitly requested."}` + body := []byte(fmt.Sprintf(`{"id":"resp_1","status":"completed","output":[{"id":"fc_1","type":"function_call","status":"completed","call_id":"call_1","name":%q,"arguments":%q},{"id":"msg_1","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Submitting approval."}]}]}`, guardianDecisionToolName, arguments)) + transformed, changed, err := transformAutoReviewResponse(body, "application/json") + if err != nil { + t.Fatal(err) + } + if !changed { + t.Fatal("mixed decision response was not transformed") + } + var response struct { + Output []struct { + ID string `json:"id"` + Type string `json:"type"` + Content []struct { + Text string `json:"text"` + } `json:"content"` + } `json:"output"` + } + if err := json.Unmarshal(transformed, &response); err != nil { + t.Fatal(err) + } + if len(response.Output) != 1 || response.Output[0].ID != "fc_1" || response.Output[0].Type != "message" || len(response.Output[0].Content) != 1 || response.Output[0].Content[0].Text != arguments { + t.Fatalf("transformed output = %#v", response.Output) + } +} + +func TestTransformAutoReviewEventStreamDiscardsProseWithValidDecision(t *testing.T) { + arguments := `{"risk_level":"low","user_authorization":"high","outcome":"allow","rationale":"The command is read-only and explicitly requested."}` + extraMessage := map[string]any{ + "id": "msg_1", "type": "message", "status": "completed", "role": "assistant", + "content": []any{map[string]any{"type": "output_text", "text": "Submitting approval.", "annotations": []any{}, "logprobs": []any{}}}, + } + body := autoReviewEventStream(t, guardianDecisionToolName, arguments, extraMessage) + transformed, changed, err := transformAutoReviewResponse(body, "text/event-stream") + if err != nil { + t.Fatal(err) + } + if !changed { + t.Fatal("mixed decision stream was not transformed") + } + text := string(transformed) + if strings.Contains(text, "Submitting approval") || strings.Contains(text, "msg_1") { + t.Fatalf("transformed stream retained assistant prose:\n%s", text) + } + events, err := parseServerSentEvents(transformed) + if err != nil { + t.Fatal(err) + } + var completed map[string]any + for _, event := range events { + if event.event == "response.completed" { + if err := json.Unmarshal(event.data, &completed); err != nil { + t.Fatal(err) + } + } + } + response := completed["response"].(map[string]any) + output := response["output"].([]any) + if len(output) != 1 || itemString(output[0].(map[string]any), "id") != "fc_1" || itemString(output[0].(map[string]any), "type") != "message" { + t.Fatalf("completed output = %#v", output) + } +} + +func TestTransformAutoReviewResponseRejectsDecisionWithAnotherToolCall(t *testing.T) { + decision := `{"risk_level":"low","user_authorization":"high","outcome":"allow","rationale":"Allowed."}` + body := []byte(fmt.Sprintf(`{"id":"resp_1","status":"completed","output":[{"id":"fc_1","type":"function_call","name":%q,"arguments":%q},{"id":"fc_2","type":"function_call","name":"inspect_command","arguments":"{}"}]}`, guardianDecisionToolName, decision)) + if _, _, err := transformAutoReviewResponse(body, "application/json"); err == nil || !strings.Contains(err.Error(), "other terminal output") { + t.Fatalf("error = %v, want mixed terminal output", err) + } +} + +func TestTransformAutoReviewResponsePreservesProviderFailure(t *testing.T) { + body := []byte(`{"id":"resp_1","status":"failed","output":[],"error":{"code":"provider_error","message":"unavailable"}}`) + transformed, changed, err := transformAutoReviewResponse(body, "application/json") + if err != nil { + t.Fatal(err) + } + if changed || !bytes.Equal(transformed, body) { + t.Fatalf("provider failure changed: %s", transformed) + } +} + +func TestCodexDesktopKeepsAutoReviewOnChatGPTByDefault(t *testing.T) { + ollama := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("native Auto-review request should not reach Ollama") + })) + defer ollama.Close() + + var gotModel string + chatGPT := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload struct { + Model string `json:"model"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + gotModel = payload.Model + w.WriteHeader(http.StatusOK) + })) + defer chatGPT.Close() + + handler := newTestCodexDesktop(t, ollama.URL, chatGPT.URL, writeCatalog(t, "glm-5.3-flash:cloud")) + proxy := httptest.NewServer(handler) + defer proxy.Close() + + req, err := http.NewRequest(http.MethodPost, proxy.URL+CodexDesktopPathPrefix+"/v1/responses", strings.NewReader(`{"model":"codex-auto-review"}`)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("ChatGPT-Account-ID", "account-123") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + if gotModel != autoReviewModel { + t.Fatalf("forwarded model = %q, want native reviewer alias", gotModel) + } +} + +func TestCodexDesktopRejectsAutoReviewModelOutsideRoutingCatalog(t *testing.T) { + called := false + upstream := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + called = true + })) + defer upstream.Close() + + handler := newTestCodexDesktop(t, upstream.URL, upstream.URL, writeCatalogWithAutoReview(t, "qwen3:8b", "glm-5.3-flash:cloud")) + req := httptest.NewRequest(http.MethodPost, "http://localhost"+CodexDesktopPathPrefix+"/v1/responses", strings.NewReader(`{"model":"codex-auto-review"}`)) + req.RemoteAddr = "127.0.0.1:1234" + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, req) + if recorder.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503: %s", recorder.Code, recorder.Body.String()) + } + if called { + t.Fatal("invalid Auto-review configuration reached an upstream") + } +} + +func TestCodexDesktopNormalizesCodexOnlyHistoryForOllama(t *testing.T) { + var gotBody []byte + ollama := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + })) + defer ollama.Close() + chatGPT := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("Ollama model should not reach ChatGPT") + })) + defer chatGPT.Close() + + handler := newTestCodexDesktop(t, ollama.URL, chatGPT.URL, writeCatalog(t, "glm-5.2:cloud")) + proxy := httptest.NewServer(handler) + defer proxy.Close() + + payload := `{ + "model":"glm-5.2:cloud", + "stream":true, + "input":[ + {"type":"compaction","encrypted_content":"opaque"}, + {"type":"message","role":"developer","content":[{"type":"input_text","text":"Plan Mode"}]}, + {"type":"custom_tool_call","id":"ctc_1","status":"completed","call_id":"call_1","name":"apply_patch","input":"*** Begin Patch"}, + {"type":"custom_tool_call_output","call_id":"call_1","output":"Success"}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"continue"}]}, + {"type":"future_codex_item","secret":"ignored"} + ] + }` + resp, err := http.Post(proxy.URL+CodexDesktopPathPrefix+"/v1/responses", "application/json", strings.NewReader(payload)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + + var forwarded struct { + Input []map[string]any `json:"input"` + } + if err := json.Unmarshal(gotBody, &forwarded); err != nil { + t.Fatal(err) + } + if len(forwarded.Input) != 4 { + t.Fatalf("forwarded input = %#v", forwarded.Input) + } + developer := forwarded.Input[0] + if developer["type"] != "message" || developer["role"] != "system" { + t.Fatalf("developer instructions were not promoted for Ollama: %#v", developer) + } + call := forwarded.Input[1] + if call["type"] != "function_call" || call["call_id"] != "call_1" || call["name"] != "apply_patch" { + t.Fatalf("converted call = %#v", call) + } + if call["arguments"] != `{"input":"*** Begin Patch"}` { + t.Fatalf("converted arguments = %#v", call["arguments"]) + } + output := forwarded.Input[2] + if output["type"] != "function_call_output" || output["call_id"] != "call_1" || output["output"] != "Success" { + t.Fatalf("converted output = %#v", output) + } + if forwarded.Input[3]["type"] != "message" || forwarded.Input[3]["role"] != "user" { + t.Fatalf("user message was not preserved: %#v", forwarded.Input[3]) + } +} + +func TestCodexDesktopPreservesToolSearchAndOllamaCompactionForOllama(t *testing.T) { + var gotBody []byte + ollama := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + })) + defer ollama.Close() + chatGPT := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("Ollama model should not reach ChatGPT") + })) + defer chatGPT.Close() + + handler := newTestCodexDesktop(t, ollama.URL, chatGPT.URL, writeCatalog(t, "glm-5.3-flash:cloud")) + proxy := httptest.NewServer(handler) + defer proxy.Close() + + payload := `{ + "model":"glm-5.3-flash:cloud", + "input":[ + {"type":"compaction","encrypted_content":"native-opaque-state"}, + {"type":"compaction","encrypted_content":"{\"type\":\"ollama_compaction\",\"version\":1,\"summary\":\"summary\",\"retained\":[]}"}, + {"type":"tool_search_call","id":"ts_1","call_id":"call_search","execution":"client","status":"completed","arguments":{"query":"notion"}}, + {"type":"tool_search_output","id":"tso_1","call_id":"call_search","execution":"client","status":"completed","tools":[{"type":"function","name":"notion.search"}]}, + {"type":"message","role":"user","content":"continue"}, + {"type":"compaction_trigger"} + ] + }` + resp, err := http.Post(proxy.URL+CodexDesktopPathPrefix+"/v1/responses", "application/json", strings.NewReader(payload)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + + var forwarded struct { + Input []map[string]any `json:"input"` + } + if err := json.Unmarshal(gotBody, &forwarded); err != nil { + t.Fatal(err) + } + if len(forwarded.Input) != 5 { + t.Fatalf("forwarded input = %#v", forwarded.Input) + } + wantTypes := []string{"compaction", "tool_search_call", "tool_search_output", "message", "compaction_trigger"} + for i, want := range wantTypes { + if got := forwarded.Input[i]["type"]; got != want { + t.Fatalf("forwarded input[%d] type = %#v, want %q", i, got, want) + } + } +} + +func TestCodexDesktopFiltersNativeReasoningWhenSwitchingToOllama(t *testing.T) { + var gotBody []byte + ollama := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + })) + defer ollama.Close() + chatGPT := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("Ollama model should not reach ChatGPT") + })) + defer chatGPT.Close() + + handler := newTestCodexDesktop(t, ollama.URL, chatGPT.URL, writeCatalog(t, "glm-5.3-flash:cloud")) + proxy := httptest.NewServer(handler) + defer proxy.Close() + + payload := `{ + "model":"glm-5.3-flash:cloud", + "input":[ + {"type":"reasoning","id":"rs_098c6fb068ce51bf016a9709ab7dcc87d185ecc21991f0f39c","encrypted_content":"gAAAAAB-native"}, + {"type":"reasoning","id":"rs_713083","encrypted_content":"Ollama plaintext thinking"}, + {"type":"reasoning","id":"rs_resp_123456","encrypted_content":"More Ollama thinking"}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"continue"}]} + ] + }` + resp, err := http.Post(proxy.URL+CodexDesktopPathPrefix+"/v1/responses", "application/json", strings.NewReader(payload)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + + var forwarded struct { + Input []map[string]any `json:"input"` + } + if err := json.Unmarshal(gotBody, &forwarded); err != nil { + t.Fatal(err) + } + if len(forwarded.Input) != 3 { + t.Fatalf("forwarded input = %#v", forwarded.Input) + } + if forwarded.Input[0]["id"] != "rs_713083" || forwarded.Input[1]["id"] != "rs_resp_123456" { + t.Fatalf("Ollama reasoning was not preserved: %#v", forwarded.Input) + } + if forwarded.Input[2]["type"] != "message" { + t.Fatalf("message was not preserved: %#v", forwarded.Input[2]) + } +} + +func TestCodexDesktopPassesNativeModelToChatGPT(t *testing.T) { + ollama := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("native model should not reach Ollama") + })) + defer ollama.Close() + + var gotPath, gotAuthorization, gotMetadata string + var gotBody []byte + chatGPT := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuthorization = r.Header.Get("Authorization") + gotMetadata = r.Header.Get("X-Codex-Turn-Metadata") + gotBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusAccepted) + _, _ = io.WriteString(w, "native") + })) + defer chatGPT.Close() + + handler := newTestCodexDesktop(t, ollama.URL, chatGPT.URL+"/backend-api/codex", writeCatalog(t, "glm-5.2:cloud")) + proxy := httptest.NewServer(handler) + defer proxy.Close() + + payload := []byte(`{"model":"gpt-5.6-sol","stream":true,"input":[{"type":"message","role":"developer","content":[{"type":"input_text","text":"native instructions"}]}]}`) + req, err := http.NewRequest(http.MethodPost, proxy.URL+CodexDesktopPathPrefix+"/v1/responses", bytes.NewReader(payload)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer chatgpt-secret") + req.Header.Set("ChatGPT-Account-ID", "account-123") + req.Header.Set("X-Codex-Turn-Metadata", `{"thread":"kept"}`) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusAccepted { + t.Fatalf("status = %d", resp.StatusCode) + } + if gotPath != "/backend-api/codex/responses" { + t.Fatalf("ChatGPT path = %q", gotPath) + } + if gotAuthorization != "Bearer chatgpt-secret" || gotMetadata != `{"thread":"kept"}` { + t.Fatalf("native headers were not preserved: authorization=%q metadata=%q", gotAuthorization, gotMetadata) + } + if string(gotBody) != string(payload) { + t.Fatalf("ChatGPT body = %q, want %q", gotBody, payload) + } +} + +func TestCodexDesktopPassesNativeModelToOpenAIAPIWithAPIKey(t *testing.T) { + ollama := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("native model should not reach Ollama") + })) + defer ollama.Close() + chatGPT := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("API-key request should not reach the ChatGPT subscription endpoint") + })) + defer chatGPT.Close() + + var gotPath, gotAuthorization, gotOrganization string + openAI := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuthorization = r.Header.Get("Authorization") + gotOrganization = r.Header.Get("OpenAI-Organization") + w.WriteHeader(http.StatusAccepted) + })) + defer openAI.Close() + + handler, err := NewCodexDesktop(CodexDesktopConfig{ + OllamaURL: ollama.URL, + ChatGPTURL: chatGPT.URL + "/backend-api/codex", + OpenAIURL: openAI.URL + "/v1", + RoutingCatalogPath: writeCatalog(t, "glm-5.2:cloud"), + }) + if err != nil { + t.Fatal(err) + } + proxy := httptest.NewServer(handler) + defer proxy.Close() + + req, err := http.NewRequest( + http.MethodPost, + proxy.URL+CodexDesktopPathPrefix+"/v1/responses", + strings.NewReader(`{"model":"gpt-5.6-sol"}`), + ) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer sk-test") + req.Header.Set("OpenAI-Organization", "org-test") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusAccepted { + t.Fatalf("status = %d", resp.StatusCode) + } + if gotPath != "/v1/responses" { + t.Fatalf("OpenAI API path = %q", gotPath) + } + if gotAuthorization != "Bearer sk-test" || gotOrganization != "org-test" { + t.Fatalf("OpenAI API headers were not preserved: authorization=%q organization=%q", gotAuthorization, gotOrganization) + } +} + +func TestCodexDesktopRejectsManagedAPIKeyForNativeModel(t *testing.T) { + ollamaCalled := false + ollama := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + ollamaCalled = true + })) + defer ollama.Close() + chatGPTCalled := false + chatGPT := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + chatGPTCalled = true + })) + defer chatGPT.Close() + openAICalled := false + openAI := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + openAICalled = true + })) + defer openAI.Close() + + handler, err := NewCodexDesktop(CodexDesktopConfig{ + OllamaURL: ollama.URL, + ChatGPTURL: chatGPT.URL + "/backend-api/codex", + OpenAIURL: openAI.URL + "/v1", + RoutingCatalogPath: writeCatalog(t, "glm-5.2:cloud"), + }) + if err != nil { + t.Fatal(err) + } + proxy := httptest.NewServer(handler) + defer proxy.Close() + + req, err := http.NewRequest( + http.MethodPost, + proxy.URL+CodexDesktopPathPrefix+"/v1/responses", + strings.NewReader(`{"model":"gpt-5.6-sol"}`), + ) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer "+CodexDesktopManagedAPIKey) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401: %s", resp.StatusCode, body) + } + if !strings.Contains(string(body), "OpenAI models require signing in") { + t.Fatalf("body = %q, want sign-in recovery", body) + } + if ollamaCalled || chatGPTCalled || openAICalled { + t.Fatalf("managed API key escaped local rejection: ollama=%v chatgpt=%v openai=%v", ollamaCalled, chatGPTCalled, openAICalled) + } +} + +func TestCodexDesktopPreservesCompressedNativeRequestWithoutOllamaReasoning(t *testing.T) { + ollama := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("native model should not reach Ollama") + })) + defer ollama.Close() + + var gotEncoding string + var gotBody []byte + chatGPT := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotEncoding = r.Header.Get("Content-Encoding") + gotBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + })) + defer chatGPT.Close() + + handler := newTestCodexDesktop(t, ollama.URL, chatGPT.URL+"/backend-api/codex", writeCatalog(t, "glm-5.3-flash:cloud")) + proxy := httptest.NewServer(handler) + defer proxy.Close() + + payload := []byte(`{"model":"gpt-5.6-sol","input":[{"type":"reasoning","id":"rs_098c6fb068ce51bf016a9709ab7dcc87d185ecc21991f0f39c","encrypted_content":"gAAAAAB-native"}]}`) + encoder, err := zstd.NewWriter(nil) + if err != nil { + t.Fatal(err) + } + compressed := encoder.EncodeAll(payload, nil) + encoder.Close() + + req, err := http.NewRequest(http.MethodPost, proxy.URL+CodexDesktopPathPrefix+"/v1/responses", bytes.NewReader(compressed)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Content-Encoding", "zstd") + req.Header.Set("ChatGPT-Account-ID", "account-123") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + if gotEncoding != "zstd" { + t.Fatalf("content encoding = %q, want zstd", gotEncoding) + } + if !bytes.Equal(gotBody, compressed) { + t.Fatal("native request body was rewritten") + } +} + +func TestCodexDesktopFiltersOllamaProviderStateWhenSwitchingToNativeModel(t *testing.T) { + ollama := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("native model should not reach Ollama") + })) + defer ollama.Close() + + var gotAuthorization, gotEncoding string + var gotBody []byte + chatGPT := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuthorization = r.Header.Get("Authorization") + gotEncoding = r.Header.Get("Content-Encoding") + gotBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + })) + defer chatGPT.Close() + + handler := newTestCodexDesktop(t, ollama.URL, chatGPT.URL+"/backend-api/codex", writeCatalog(t, "glm-5.3-flash:cloud")) + proxy := httptest.NewServer(handler) + defer proxy.Close() + + payload := []byte(`{ + "model":"gpt-5.6-sol", + "input":[ + {"type":"reasoning","id":"rs_713083","encrypted_content":"The user wants info about the repo"}, + {"type":"reasoning","id":"rs_resp_123456","encrypted_content":"More plaintext thinking"}, + {"type":"compaction","encrypted_content":"{\"type\":\"ollama_compaction\",\"version\":1,\"summary\":\"local summary\",\"retained\":[]}"}, + {"type":"compaction","encrypted_content":"gAAAAAB-native-compaction"}, + {"type":"reasoning","id":"rs_098c6fb068ce51bf016a9709ab7dcc87d185ecc21991f0f39c","encrypted_content":"gAAAAAB-native"}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"continue"}]} + ] + }`) + encoder, err := zstd.NewWriter(nil) + if err != nil { + t.Fatal(err) + } + compressed := encoder.EncodeAll(payload, nil) + encoder.Close() + + req, err := http.NewRequest(http.MethodPost, proxy.URL+CodexDesktopPathPrefix+"/v1/responses", bytes.NewReader(compressed)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Content-Encoding", "zstd") + req.Header.Set("Authorization", "Bearer chatgpt-secret") + req.Header.Set("ChatGPT-Account-ID", "account-123") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + if gotAuthorization != "Bearer chatgpt-secret" { + t.Fatalf("authorization = %q", gotAuthorization) + } + if gotEncoding != "" { + t.Fatalf("normalized body retained content encoding %q", gotEncoding) + } + + var forwarded struct { + Input []map[string]any `json:"input"` + } + if err := json.Unmarshal(gotBody, &forwarded); err != nil { + t.Fatal(err) + } + if len(forwarded.Input) != 3 { + t.Fatalf("forwarded input = %#v", forwarded.Input) + } + if forwarded.Input[0]["type"] != "compaction" || + forwarded.Input[0]["encrypted_content"] != "gAAAAAB-native-compaction" { + t.Fatalf("native encrypted compaction was not preserved: %#v", forwarded.Input[0]) + } + if forwarded.Input[1]["id"] != "rs_098c6fb068ce51bf016a9709ab7dcc87d185ecc21991f0f39c" || + forwarded.Input[1]["encrypted_content"] != "gAAAAAB-native" { + t.Fatalf("native encrypted reasoning was not preserved: %#v", forwarded.Input[1]) + } + if forwarded.Input[2]["type"] != "message" { + t.Fatalf("message was not preserved: %#v", forwarded.Input[2]) + } +} + +func TestIsOllamaReasoningItemID(t *testing.T) { + for _, test := range []struct { + id string + want bool + }{ + {id: "rs_0", want: true}, + {id: "rs_713083", want: true}, + {id: "rs_resp_123456", want: true}, + {id: "rs_1234567", want: false}, + {id: "rs_resp_1234567", want: false}, + {id: "rs_098c6fb068ce51bf016a9709ab7dcc87d185ecc21991f0f39c", want: false}, + {id: "reasoning_123", want: false}, + } { + if got := isOllamaReasoningItemID(test.id); got != test.want { + t.Errorf("isOllamaReasoningItemID(%q) = %v, want %v", test.id, got, test.want) + } + } +} + +func TestCodexDesktopRequestsHTTPFallbackForWebSocketUpgrade(t *testing.T) { + ollama := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("WebSocket fallback should not reach Ollama") + })) + defer ollama.Close() + chatGPT := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("WebSocket fallback should not reach ChatGPT") + })) + defer chatGPT.Close() + + handler := newTestCodexDesktop(t, ollama.URL, chatGPT.URL+"/backend-api/codex", writeCatalog(t, "glm-5.2:cloud")) + proxy := httptest.NewServer(handler) + defer proxy.Close() + + req, err := http.NewRequest(http.MethodGet, proxy.URL+CodexDesktopPathPrefix+"/v1/responses", nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Connection", "keep-alive, Upgrade") + req.Header.Set("Upgrade", "websocket") + req.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==") + req.Header.Set("Sec-WebSocket-Version", "13") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusUpgradeRequired { + t.Fatalf("status = %d, want 426", resp.StatusCode) + } +} + +func TestCodexDesktopStatusReportsObservedRoutes(t *testing.T) { + ollama := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer ollama.Close() + + chatGPT := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusAccepted) + })) + defer chatGPT.Close() + + handler := newTestCodexDesktop(t, ollama.URL, chatGPT.URL+"/backend-api/codex", writeCatalog(t, "glm-5.2:cloud")) + proxy := httptest.NewServer(handler) + defer proxy.Close() + + for _, payload := range []string{ + `{"model":"glm-5.2:cloud"}`, + `{"model":"gpt-5.6-sol"}`, + } { + req, err := http.NewRequest(http.MethodPost, proxy.URL+CodexDesktopPathPrefix+"/v1/responses", strings.NewReader(payload)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("ChatGPT-Account-ID", "account-123") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + _ = resp.Body.Close() + } + + resp, err := http.Get(proxy.URL + CodexDesktopPathPrefix + "/_status") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var status statusResponse + if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { + t.Fatal(err) + } + if !status.OK || status.OllamaRequests != 1 || status.ChatGPTRequests != 1 || status.UpstreamErrors != 0 { + t.Fatalf("status = %+v", status) + } + if status.LastModel != "gpt-5.6-sol" || status.LastRoute != "chatgpt" || status.LastUpstreamStatus != http.StatusAccepted { + t.Fatalf("last route = %+v", status) + } +} + +func TestCodexDesktopCountersExcludeProbesAndFailedRetries(t *testing.T) { + ollama := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer ollama.Close() + chatGPT := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusMethodNotAllowed) + })) + defer chatGPT.Close() + + handler := newTestCodexDesktop(t, ollama.URL, chatGPT.URL+"/backend-api/codex", writeCatalog(t, "glm-5.2:cloud")) + proxy := httptest.NewServer(handler) + defer proxy.Close() + + probeReq, err := http.NewRequest(http.MethodGet, proxy.URL+CodexDesktopPathPrefix+"/v1/responses", nil) + if err != nil { + t.Fatal(err) + } + probeReq.Header.Set("ChatGPT-Account-ID", "account-123") + probeResp, err := http.DefaultClient.Do(probeReq) + if err != nil { + t.Fatal(err) + } + _ = probeResp.Body.Close() + failedResp, err := http.Post( + proxy.URL+CodexDesktopPathPrefix+"/v1/responses", + "application/json", + strings.NewReader(`{"model":"glm-5.2:cloud"}`), + ) + if err != nil { + t.Fatal(err) + } + _ = failedResp.Body.Close() + + statusResp, err := http.Get(proxy.URL + CodexDesktopPathPrefix + "/_status") + if err != nil { + t.Fatal(err) + } + defer statusResp.Body.Close() + var status statusResponse + if err := json.NewDecoder(statusResp.Body).Decode(&status); err != nil { + t.Fatal(err) + } + if status.OllamaRequests != 0 || status.ChatGPTRequests != 0 { + t.Fatalf("failed requests were counted: %+v", status) + } + if status.UpstreamErrors != 1 { + t.Fatalf("upstream errors = %d, want 1", status.UpstreamErrors) + } +} + +func TestCodexDesktopWritesSafeActivityLog(t *testing.T) { + ollama := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer ollama.Close() + + activityLogPath := filepath.Join(t.TempDir(), "logs", "codex-proxy.log") + handler, err := NewCodexDesktop(CodexDesktopConfig{ + OllamaURL: ollama.URL, + ChatGPTURL: "https://chatgpt.com/backend-api/codex", + RoutingCatalogPath: writeCatalog(t, "glm-5.2:cloud"), + ActivityLogPath: activityLogPath, + }) + if err != nil { + t.Fatal(err) + } + proxy := httptest.NewServer(handler) + defer proxy.Close() + + prompt := "do-not-record-this-prompt" + req, err := http.NewRequest( + http.MethodPost, + proxy.URL+CodexDesktopPathPrefix+"/v1/responses", + strings.NewReader(`{"model":"glm-5.2:cloud","input":"`+prompt+`"}`), + ) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer do-not-record-this-token") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + _ = resp.Body.Close() + + // logActivity runs when the server finishes the request, which can be a + // scheduler beat after the client has received the full response. Poll + // for the log line instead of assuming it is already on disk. + var logText string + deadline := time.Now().Add(2 * time.Second) + for { + data, readErr := os.ReadFile(activityLogPath) + if readErr == nil { + logText = string(data) + if strings.Contains(logText, `route=ollama model="glm-5.2:cloud"`) { + break + } + } + if time.Now().After(deadline) { + if readErr != nil { + t.Fatal(readErr) + } + t.Fatalf("activity log missing %q:\n%s", `route=ollama model="glm-5.2:cloud"`, logText) + } + time.Sleep(time.Millisecond) + } + for _, want := range []string{ + `route=ollama model="glm-5.2:cloud"`, + "method=POST path=/v1/responses status=200", + "result=ok", + } { + if !strings.Contains(logText, want) { + t.Fatalf("activity log missing %q:\n%s", want, logText) + } + } + for _, secret := range []string{prompt, "do-not-record-this-token", "Authorization"} { + if strings.Contains(logText, secret) { + t.Fatalf("activity log recorded sensitive value %q:\n%s", secret, logText) + } + } +} + +func TestCodexDesktopRejectsNonLoopbackClients(t *testing.T) { + handler := newTestCodexDesktop(t, "http://127.0.0.1:11434", "https://chatgpt.com/backend-api/codex", writeCatalog(t, "glm")) + req := httptest.NewRequest(http.MethodGet, "http://example.test"+CodexDesktopPathPrefix+"/_health", nil) + req.RemoteAddr = "192.0.2.10:1234" + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, req) + if recorder.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", recorder.Code) + } +} + +func TestCodexDesktopFailsClosedWhenCatalogIsMissing(t *testing.T) { + handler := newTestCodexDesktop(t, "http://127.0.0.1:11434", "https://chatgpt.com/backend-api/codex", filepath.Join(t.TempDir(), "missing.json")) + req := httptest.NewRequest(http.MethodPost, "http://localhost"+CodexDesktopPathPrefix+"/v1/responses", strings.NewReader(`{"model":"glm"}`)) + req.RemoteAddr = "127.0.0.1:1234" + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, req) + if recorder.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503: %s", recorder.Code, recorder.Body.String()) + } +} + +func newTestCodexDesktop(t *testing.T, ollamaURL, chatGPTURL, catalogPath string) *CodexDesktop { + t.Helper() + handler, err := NewCodexDesktop(CodexDesktopConfig{ + OllamaURL: ollamaURL, + ChatGPTURL: chatGPTURL, + RoutingCatalogPath: catalogPath, + }) + if err != nil { + t.Fatal(err) + } + return handler +} + +func writeCatalog(t *testing.T, models ...string) string { + return writeCatalogWithAutoReview(t, "", models...) +} + +func writeCatalogWithAutoReview(t *testing.T, autoReviewModel string, models ...string) string { + t.Helper() + entries := make([]map[string]string, 0, len(models)) + for _, model := range models { + entries = append(entries, map[string]string{"slug": model}) + } + data, err := json.Marshal(map[string]any{"models": entries, "auto_review_model": autoReviewModel}) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), CodexDesktopModelCatalogFilename) + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func autoReviewJSONResponse(arguments string) string { + return autoReviewJSONResponseForTool(guardianDecisionToolName, arguments) +} + +func autoReviewJSONResponseForTool(name, arguments string) string { + return fmt.Sprintf(`{"id":"resp_1","status":"completed","output":[{"id":"fc_1","type":"function_call","status":"completed","call_id":"call_1","name":%q,"arguments":%q}]}`, name, arguments) +} + +func autoReviewEventStream(t *testing.T, name, arguments string, extraOutput ...map[string]any) []byte { + t.Helper() + inProgressItem := map[string]any{ + "id": "fc_1", "type": "function_call", "status": "in_progress", "call_id": "call_1", "name": name, "arguments": "", + } + completedItem := map[string]any{ + "id": "fc_1", "type": "function_call", "status": "completed", "call_id": "call_1", "name": name, "arguments": arguments, + } + events := []serverSentEvent{ + newServerSentEvent("response.created", map[string]any{"response": map[string]any{"id": "resp_1", "status": "in_progress", "output": []any{}}}), + newServerSentEvent("response.output_item.added", map[string]any{"output_index": 0, "item": inProgressItem}), + newServerSentEvent("response.function_call_arguments.delta", map[string]any{"item_id": "fc_1", "output_index": 0, "delta": arguments}), + newServerSentEvent("response.function_call_arguments.done", map[string]any{"item_id": "fc_1", "output_index": 0, "arguments": arguments}), + newServerSentEvent("response.output_item.done", map[string]any{"output_index": 0, "item": completedItem}), + } + output := []any{completedItem} + for i, item := range extraOutput { + outputIndex := i + 1 + itemID := itemString(item, "id") + content := item["content"].([]any)[0].(map[string]any) + text := itemString(content, "text") + events = append(events, + newServerSentEvent("response.output_item.added", map[string]any{"output_index": outputIndex, "item": map[string]any{"id": itemID, "type": "message", "status": "in_progress", "role": "assistant", "content": []any{}}}), + newServerSentEvent("response.content_part.added", map[string]any{"item_id": itemID, "output_index": outputIndex, "content_index": 0, "part": map[string]any{"type": "output_text", "text": "", "annotations": []any{}, "logprobs": []any{}}}), + newServerSentEvent("response.output_text.delta", map[string]any{"item_id": itemID, "output_index": outputIndex, "content_index": 0, "delta": text, "logprobs": []any{}}), + newServerSentEvent("response.output_text.done", map[string]any{"item_id": itemID, "output_index": outputIndex, "content_index": 0, "text": text, "logprobs": []any{}}), + newServerSentEvent("response.content_part.done", map[string]any{"item_id": itemID, "output_index": outputIndex, "content_index": 0, "part": content}), + newServerSentEvent("response.output_item.done", map[string]any{"output_index": outputIndex, "item": item}), + ) + output = append(output, item) + } + events = append(events, newServerSentEvent("response.completed", map[string]any{"response": map[string]any{"id": "resp_1", "status": "completed", "output": output}})) + encoded, err := encodeServerSentEvents(events) + if err != nil { + t.Fatal(err) + } + return encoded +} + +func TestCodexDesktopDoesNotAddForwardedHeaders(t *testing.T) { + forwardedHeaders := func(r *http.Request) []string { + var forwarded []string + for _, key := range []string{"X-Forwarded-For", "X-Forwarded-Host", "X-Forwarded-Proto"} { + forwarded = append(forwarded, r.Header.Values(key)...) + } + return forwarded + } + var ollamaForwarded, nativeForwarded []string + ollama := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ollamaForwarded = forwardedHeaders(r) + w.WriteHeader(http.StatusOK) + })) + defer ollama.Close() + + native := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nativeForwarded = forwardedHeaders(r) + w.WriteHeader(http.StatusOK) + })) + defer native.Close() + + catalogPath := writeCatalog(t, "glm-5.2:cloud") + handler := newTestCodexDesktop(t, ollama.URL, native.URL, catalogPath) + proxy := httptest.NewServer(handler) + defer proxy.Close() + + for _, model := range []string{"glm-5.2:cloud", "gpt-native"} { + req, err := http.NewRequest(http.MethodPost, proxy.URL+CodexDesktopPathPrefix+"/v1/responses", strings.NewReader(`{"model":"`+model+`"}`)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("ChatGPT-Account-ID", "account-123") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + } + if len(ollamaForwarded) != 0 { + t.Fatalf("Ollama received forwarding headers: %q", ollamaForwarded) + } + if len(nativeForwarded) != 0 { + t.Fatalf("native upstream received forwarding headers: %q", nativeForwarded) + } +} + +func TestCodexDesktopPassesThroughRedirectResponse(t *testing.T) { + native := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Location", "https://example.com/moved") + w.WriteHeader(http.StatusFound) + _, _ = io.WriteString(w, "moved") + })) + defer native.Close() + + ollama := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("native model should not reach Ollama") + })) + defer ollama.Close() + + catalogPath := writeCatalog(t, "glm-5.2:cloud") + handler := newTestCodexDesktop(t, ollama.URL, native.URL, catalogPath) + proxy := httptest.NewServer(handler) + defer proxy.Close() + + req, err := http.NewRequest(http.MethodPost, proxy.URL+CodexDesktopPathPrefix+"/v1/responses", strings.NewReader(`{"model":"gpt-native"}`)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("ChatGPT-Account-ID", "account-123") + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }} + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusFound || resp.Header.Get("Location") != "https://example.com/moved" || string(body) != "moved" { + t.Fatalf("redirect passthrough = %d %q %q", resp.StatusCode, resp.Header.Get("Location"), body) + } +} diff --git a/openai/responses.go b/openai/responses.go index c08dd3cb2..7c601b368 100644 --- a/openai/responses.go +++ b/openai/responses.go @@ -442,6 +442,10 @@ type ResponsesRequest struct { Reasoning ResponsesReasoning `json:"reasoning"` + // Think is an Ollama extension used when a model's native thinking control + // cannot be represented exactly by OpenAI's string-valued reasoning effort. + Think *api.ThinkValue `json:"think,omitempty"` + // optional, default is 1.0 Temperature *float64 `json:"temperature"` @@ -644,9 +648,17 @@ func FromResponsesRequest(r ResponsesRequest) (*api.ChatRequest, error) { options["num_predict"] = *r.MaxOutputTokens } - think, err := thinkFromReasoningEffort(r.Reasoning.Effort) - if err != nil { - return nil, err + think := r.Think + if think != nil { + if !think.IsValid() { + return nil, fmt.Errorf("invalid think value") + } + } else { + converted, err := thinkFromReasoningEffort(r.Reasoning.Effort) + if err != nil { + return nil, err + } + think = converted } // Convert tools from Responses API format to api.Tool format diff --git a/openai/responses_compact.go b/openai/responses_compact.go index f8c7e9de7..29f3b211e 100644 --- a/openai/responses_compact.go +++ b/openai/responses_compact.go @@ -341,9 +341,20 @@ func messageToResponsesItems(message api.Message) ([]json.RawMessage, error) { if message.ToolCallID == "" { return nil, errors.New("retained tool message is missing tool_call_id") } - values = append(values, map[string]any{ - "type": "function_call_output", "call_id": message.ToolCallID, "output": message.Content, - }) + if message.ToolName == "tool_search" { + var tools []json.RawMessage + if err := json.Unmarshal([]byte(message.Content), &tools); err != nil { + return nil, fmt.Errorf("retained tool search output is invalid: %w", err) + } + values = append(values, map[string]any{ + "type": "tool_search_output", "call_id": message.ToolCallID, + "execution": "client", "status": "completed", "tools": tools, + }) + } else { + values = append(values, map[string]any{ + "type": "function_call_output", "call_id": message.ToolCallID, "output": message.Content, + }) + } } else if message.Content != "" || len(message.ToolCalls) == 0 { values = append(values, map[string]any{ "type": "message", "role": message.Role, "content": message.Content, @@ -353,13 +364,20 @@ func messageToResponsesItems(message api.Message) ([]json.RawMessage, error) { if call.ID == "" || call.Function.Name == "" { return nil, errors.New("retained function call is missing call_id or name") } - arguments, err := json.Marshal(call.Function.Arguments) - if err != nil { - return nil, err + if call.Function.Name == "tool_search" { + values = append(values, map[string]any{ + "type": "tool_search_call", "call_id": call.ID, + "execution": "client", "status": "completed", "arguments": call.Function.Arguments, + }) + } else { + arguments, err := json.Marshal(call.Function.Arguments) + if err != nil { + return nil, err + } + values = append(values, map[string]any{ + "type": "function_call", "call_id": call.ID, "name": call.Function.Name, "arguments": string(arguments), + }) } - values = append(values, map[string]any{ - "type": "function_call", "call_id": call.ID, "name": call.Function.Name, "arguments": string(arguments), - }) } items := make([]json.RawMessage, 0, len(values)) @@ -418,8 +436,9 @@ func compactionMessage(item ResponsesInputItem) (api.Message, string, error) { return api.Message{}, "", fmt.Errorf("invalid function arguments: %w", err) } } + name := qualifyNamespaceToolName(value.Namespace, value.Name) return api.Message{Role: "assistant", ToolCalls: []api.ToolCall{{ - ID: value.CallID, Function: api.ToolCallFunction{Name: value.Name, Arguments: arguments}, + ID: value.CallID, Function: api.ToolCallFunction{Name: name, Arguments: arguments}, }}}, "function_call", nil case ResponsesFunctionCallOutput: content := value.Output @@ -432,6 +451,24 @@ func compactionMessage(item ResponsesInputItem) (api.Message, string, error) { } } return api.Message{Role: "tool", Content: content, Images: images, ToolCallID: value.CallID}, "function_call_output", nil + case ResponsesToolSearchCall: + return api.Message{Role: "assistant", ToolCalls: []api.ToolCall{{ + ID: value.CallID, Function: api.ToolCallFunction{Name: "tool_search", Arguments: value.Arguments}, + }}}, "function_call", nil + case ResponsesToolSearchOutput: + tools, err := json.Marshal(value.Tools) + if err != nil { + return api.Message{}, "", fmt.Errorf("invalid tool search output: %w", err) + } + return api.Message{ + Role: "tool", Content: string(tools), ToolName: "tool_search", ToolCallID: value.CallID, + }, "function_call_output", nil + case ResponsesWebSearchCall: + content := "Web search completed." + if value.Action != nil && strings.TrimSpace(value.Action.Query) != "" { + content = "Web search: " + value.Action.Query + } + return api.Message{Role: "assistant", Content: content}, "web_search_call", nil case ResponsesReasoningInput: var summary strings.Builder for _, part := range value.Summary { @@ -522,14 +559,18 @@ func collectCompactionToolMetadata(tools []ResponsesTool) []compactionToolMetada var add func(prefix string, tools []ResponsesTool) add = func(prefix string, tools []ResponsesTool) { for _, tool := range tools { - name := tool.Name - if prefix != "" && !strings.HasPrefix(name, prefix+".") { - name = prefix + "." + name + name := strings.TrimSpace(tool.Name) + if name == "" && (tool.Type == "tool_search" || tool.Type == "web_search") { + name = tool.Type } + name = qualifyNamespaceToolName(prefix, name) if tool.Type == "namespace" { add(name, tool.Tools) continue } + if name == "" { + continue + } description := "" if tool.Description != nil { description = *tool.Description @@ -600,8 +641,12 @@ func (p *ResponsesCompactionPlan) Complete(body []byte) (ResponsesCompactionResu return ResponsesCompactionResult{}, fmt.Errorf("expected one %s call, got %d", CreateSummaryToolName, len(calls)) } + arguments, ok := calls[0].Arguments.(string) + if !ok { + return ResponsesCompactionResult{}, fmt.Errorf("invalid %s arguments: expected a JSON string", CreateSummaryToolName) + } var selection summarySelection - if err := json.Unmarshal([]byte(calls[0].Arguments), &selection); err != nil { + if err := json.Unmarshal([]byte(arguments), &selection); err != nil { return ResponsesCompactionResult{}, fmt.Errorf("invalid %s arguments: %w", CreateSummaryToolName, err) } selection.Summary = strings.TrimSpace(selection.Summary) diff --git a/openai/responses_compact_test.go b/openai/responses_compact_test.go index eb08c35e5..df262acf5 100644 --- a/openai/responses_compact_test.go +++ b/openai/responses_compact_test.go @@ -1,6 +1,7 @@ package openai import ( + "bytes" "encoding/json" "strings" "testing" @@ -119,6 +120,42 @@ func TestPrepareTriggeredCompactionBuildsSummaryRequest(t *testing.T) { } } +func TestCompactionNamesBuiltInSearchToolMetadata(t *testing.T) { + body := []byte(`{ + "model":"test", + "stream":true, + "tools":[{"type":"tool_search"},{"type":"web_search"}], + "input":[ + {"type":"message","role":"user","content":"find current information"}, + {"type":"compaction_trigger"} + ] + }`) + plan, requested, err := PrepareTriggeredCompaction(body) + if err != nil || !requested { + t.Fatalf("prepare: requested=%v err=%v", requested, err) + } + + requestBody, err := plan.SummaryRequest("") + if err != nil { + t.Fatal(err) + } + var request struct { + Input []struct { + Content string `json:"content"` + } `json:"input"` + } + if err := json.Unmarshal(requestBody, &request); err != nil { + t.Fatal(err) + } + if len(request.Input) != 2 || !strings.Contains(request.Input[1].Content, `"name":"tool_search"`) || + !strings.Contains(request.Input[1].Content, `"name":"web_search"`) { + t.Fatalf("summary transcript is missing built-in tool names: %s", requestBody) + } + if strings.Contains(request.Input[1].Content, `"name":""`) { + t.Fatalf("summary transcript contains unnamed tool metadata: %s", requestBody) + } +} + func TestPrepareTriggeredCompactionRequiresStreaming(t *testing.T) { body := []byte(`{"model":"test","input":[{"type":"message","role":"user","content":"hi"},{"type":"compaction_trigger"}]}`) _, requested, err := PrepareTriggeredCompaction(body) @@ -159,6 +196,103 @@ func TestCompactionSelectionKeepsCompleteToolGroupInOriginalOrder(t *testing.T) } } +func TestCompactionPreservesNamespacedFunctionCallIdentity(t *testing.T) { + body := []byte(`{ + "model":"test", + "stream":true, + "tools":[{"type":"namespace","name":"mcp__codex_apps__github","tools":[{"type":"function","name":"_get_repo","parameters":{"type":"object"}}]}], + "input":[ + {"type":"function_call","call_id":"call_repo","namespace":"mcp__codex_apps__github","name":"_get_repo","arguments":"{\"repo\":\"ollama/ollama\"}"}, + {"type":"function_call_output","call_id":"call_repo","output":"found"}, + {"type":"message","role":"assistant","content":"done"}, + {"type":"compaction_trigger"} + ] + }`) + plan, requested, err := PrepareTriggeredCompaction(body) + if err != nil || !requested { + t.Fatalf("prepare: requested=%v err=%v", requested, err) + } + + requestBody, err := plan.SummaryRequest("") + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(requestBody, []byte(`mcp__codex_apps__github_get_repo`)) { + t.Fatalf("summary transcript lost the namespaced tool identity: %s", requestBody) + } + + result, err := plan.Complete(compactionResponseBody(t, map[string]any{ + "summary": "The repository was found.", "retain_item_ids": []string{"item_000002"}, + })) + if err != nil { + t.Fatal(err) + } + payload := decodeResultPayload(t, result) + if len(payload.Retained) != 2 { + t.Fatalf("expected complete namespaced tool group, got %+v", payload.Retained) + } + if got := payload.Retained[0].ToolCalls[0].Function.Name; got != "mcp__codex_apps__github_get_repo" { + t.Fatalf("retained tool name = %q", got) + } +} + +func TestCompactionPreservesToolSearchStateAndAcceptsWebSearchHistory(t *testing.T) { + body := []byte(`{ + "model":"test", + "stream":true, + "input":[ + {"type":"message","role":"user","content":"find the issue"}, + {"type":"tool_search_call","call_id":"call_search","execution":"client","status":"completed","arguments":{"query":"github"}}, + {"type":"tool_search_output","call_id":"call_search","execution":"client","status":"completed","tools":[{"type":"namespace","name":"mcp__codex_apps__github","tools":[{"type":"function","name":"_search"}]}]}, + {"type":"web_search_call","id":"ws_1","status":"completed","action":{"type":"search","query":"current issue"}}, + {"type":"message","role":"assistant","content":"I found it."}, + {"type":"compaction_trigger"} + ] + }`) + plan, requested, err := PrepareTriggeredCompaction(body) + if err != nil || !requested { + t.Fatalf("prepare: requested=%v err=%v", requested, err) + } + + result, err := plan.Complete(compactionResponseBody(t, map[string]any{ + "summary": "The issue was found.", "retain_item_ids": []string{"item_000003"}, + })) + if err != nil { + t.Fatal(err) + } + payload := decodeResultPayload(t, result) + if len(payload.Retained) != 2 || payload.Retained[0].ToolCalls[0].Function.Name != "tool_search" || payload.Retained[1].ToolName != "tool_search" { + t.Fatalf("tool search state was not retained as a pair: %+v", payload.Retained) + } + + expandedBody, err := json.Marshal(map[string]any{ + "model": "test", + "input": []any{result.Item, map[string]any{"type": "message", "role": "user", "content": "continue"}}, + }) + if err != nil { + t.Fatal(err) + } + rewritten, changed, err := ExpandResponsesCompactionInput(expandedBody) + if err != nil || !changed { + t.Fatalf("expand: changed=%v err=%v", changed, err) + } + var request struct { + Input []json.RawMessage `json:"input"` + } + if err := json.Unmarshal(rewritten, &request); err != nil { + t.Fatal(err) + } + wantTypes := []string{"function_call", "function_call_output", "tool_search_call", "tool_search_output", "message"} + if len(request.Input) != len(wantTypes) { + t.Fatalf("expanded input = %s", rewritten) + } + for i, want := range wantTypes { + if got := rawInputItemType(request.Input[i]); got != want { + t.Fatalf("expanded input[%d] type = %q, want %q: %s", i, got, want, rewritten) + } + } +} + func TestCompactionRejectsInvalidToolStateAndSelections(t *testing.T) { t.Run("orphan result", func(t *testing.T) { body := []byte(`{"model":"test","stream":true,"input":[{"type":"function_call_output","call_id":"missing","output":"x"},{"type":"compaction_trigger"}]}`) diff --git a/openai/responses_test.go b/openai/responses_test.go index 853b9f0b8..3da95af6c 100644 --- a/openai/responses_test.go +++ b/openai/responses_test.go @@ -1149,10 +1149,11 @@ func TestResponsesStreamConverterRestoresLegacyDottedFunctionCallNamespace(t *te func TestFromResponsesRequest_ReasoningEffort(t *testing.T) { tests := []struct { - name string - effort string - wantThink any - wantErr bool + name string + effort string + directThink *api.ThinkValue + wantThink any + wantErr bool }{ { name: "unset", @@ -1197,6 +1198,23 @@ func TestFromResponsesRequest_ReasoningEffort(t *testing.T) { effort: "none", wantThink: false, }, + { + name: "Ollama boolean override takes precedence", + effort: "medium", + directThink: &api.ThinkValue{Value: true}, + wantThink: true, + }, + { + name: "Ollama string override stays exact", + effort: "high", + directThink: &api.ThinkValue{Value: "max"}, + wantThink: "max", + }, + { + name: "invalid Ollama override", + directThink: &api.ThinkValue{Value: 3}, + wantErr: true, + }, { name: "invalid", effort: "extreme", @@ -1209,6 +1227,7 @@ func TestFromResponsesRequest_ReasoningEffort(t *testing.T) { req := ResponsesRequest{ Model: "deepseek-v4-flash", Input: ResponsesInput{Text: "hi"}, + Think: tt.directThink, } if tt.effort != "" { req.Reasoning.Effort = tt.effort diff --git a/server/codex_proxy.go b/server/codex_proxy.go new file mode 100644 index 000000000..3e6779ea5 --- /dev/null +++ b/server/codex_proxy.go @@ -0,0 +1,34 @@ +package server + +import ( + "log/slog" + "net/http" + "os" + "path/filepath" + + "github.com/ollama/ollama/envconfig" + "github.com/ollama/ollama/internal/proxy" +) + +const ( + codexDesktopChatGPTURL = "https://chatgpt.com/backend-api/codex" + codexDesktopOpenAIURL = "https://api.openai.com/v1" + codexDesktopLogFilename = "codex-proxy.log" +) + +func newCodexDesktopProxy() (http.Handler, error) { + home, err := os.UserHomeDir() + if err != nil { + slog.Warn("failed to find home directory for Codex proxy", "error", err) + home = os.TempDir() + } + + return proxy.NewCodexDesktop(proxy.CodexDesktopConfig{ + OllamaURL: envconfig.ConnectableHost().String(), + ChatGPTURL: codexDesktopChatGPTURL, + OpenAIURL: codexDesktopOpenAIURL, + RoutingCatalogPath: filepath.Join(home, ".codex", proxy.CodexDesktopRoutingCatalogFilename), + ActivityLogPath: filepath.Join(home, ".ollama", "logs", codexDesktopLogFilename), + Logger: slog.Default(), + }) +} diff --git a/server/codex_proxy_test.go b/server/codex_proxy_test.go new file mode 100644 index 000000000..fbe7236ee --- /dev/null +++ b/server/codex_proxy_test.go @@ -0,0 +1,41 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestCodexProxyHealthRoute(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + handler, err := (&Server{}).GenerateRoutes() + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodGet, "http://127.0.0.1/api/codex/_health", nil) + req.RemoteAddr = "127.0.0.1:1234" + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, req) + if recorder.Code != http.StatusOK || recorder.Body.String() != `{"ok":true}` { + t.Fatalf("health response = %d %q", recorder.Code, recorder.Body.String()) + } +} + +func TestCodexProxyWebSocketUpgradeRequestsHTTPFallback(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + handler, err := (&Server{}).GenerateRoutes() + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodGet, "http://127.0.0.1/api/codex/v1/responses", nil) + req.RemoteAddr = "127.0.0.1:1234" + req.Header.Set("Connection", "Upgrade") + req.Header.Set("Upgrade", "websocket") + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, req) + if recorder.Code != http.StatusUpgradeRequired { + t.Fatalf("WebSocket fallback response = %d %q, want 426", recorder.Code, recorder.Body.String()) + } +} diff --git a/server/model_recommendations.go b/server/model_recommendations.go index bca8e896e..fc02677ae 100644 --- a/server/model_recommendations.go +++ b/server/model_recommendations.go @@ -363,7 +363,10 @@ func withJitter(d time.Duration) time.Duration { func cloneModelRecommendations(in []api.ModelRecommendation) []api.ModelRecommendation { out := make([]api.ModelRecommendation, len(in)) - copy(out, in) + for i, rec := range in { + out[i] = rec + out[i].Thinking = rec.Thinking.Clone() + } return out } diff --git a/server/model_recommendations_test.go b/server/model_recommendations_test.go index 6e003fbb7..8fe5a70b3 100644 --- a/server/model_recommendations_test.go +++ b/server/model_recommendations_test.go @@ -42,6 +42,27 @@ func TestModelRecommendationsDefaultOrder(t *testing.T) { } } +func TestCloneModelRecommendationsCopiesThinkingMetadata(t *testing.T) { + original := []api.ModelRecommendation{{ + Model: "thinking-model", + Thinking: &api.ModelRecommendationThinking{ + Values: []any{false, true, "max"}, + Default: true, + }, + }} + + cloned := cloneModelRecommendations(original) + cloned[0].Thinking.Values[0] = "changed" + cloned[0].Thinking.Default = false + + if got := original[0].Thinking.Values[0]; got != false { + t.Fatalf("original thinking value = %#v, want false after clone mutation", got) + } + if got := original[0].Thinking.Default; got != true { + t.Fatalf("original thinking default = %#v, want true after clone mutation", got) + } +} + func TestModelRecommendationsCacheRefreshAppliesServerSideChanges(t *testing.T) { setupModelRecommendationsTestEnv(t, "") diff --git a/server/routes.go b/server/routes.go index 63a889761..9b73139a1 100644 --- a/server/routes.go +++ b/server/routes.go @@ -38,6 +38,7 @@ import ( "github.com/ollama/ollama/format" "github.com/ollama/ollama/fs/ggml" internalcloud "github.com/ollama/ollama/internal/cloud" + "github.com/ollama/ollama/internal/proxy" "github.com/ollama/ollama/llm" "github.com/ollama/ollama/logutil" "github.com/ollama/ollama/manifest" @@ -1843,6 +1844,11 @@ func allowedHostsMiddleware(addr net.Addr) gin.HandlerFunc { } func (s *Server) GenerateRoutes() (http.Handler, error) { + codexDesktopProxy, err := newCodexDesktopProxy() + if err != nil { + return nil, err + } + corsConfig := cors.DefaultConfig() corsConfig.AllowWildcard = true corsConfig.AllowBrowserExtensions = true @@ -1883,6 +1889,9 @@ func (s *Server) GenerateRoutes() (http.Handler, error) { r.HEAD("/api/version", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"version": version.Version}) }) r.GET("/api/version", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"version": version.Version}) }) r.GET("/api/status", s.StatusHandler) + // Codex uses this existing Ollama listener for both native and Ollama + // models. The proxy selects the upstream per request. + r.Any(proxy.CodexDesktopPathPrefix+"/*path", gin.WrapH(codexDesktopProxy)) // Local model cache management (new implementation is at end of function) r.POST("/api/pull", s.PullHandler)