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 72e9baf76..000000000
Binary files a/app/ui/app/public/launch-icons/codex-app.png and /dev/null differ
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 (
+
");
+ 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}
+
+ )}
+
void toggleConnection()}
+ className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-500 disabled:cursor-wait disabled:opacity-50 ${displayedConnected ? "bg-neutral-950 dark:bg-white" : "bg-neutral-300 dark:bg-neutral-700"}`}
+ >
+
+
+
+
+ );
+}
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 && (
-
+
)}
{/* 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)