app: add Ollama to ChatGPT Desktop (#18236)

This commit is contained in:
Eva H 2026-09-04 17:04:48 -04:00 • committed by GitHub
parent 3f77cb6dfb
commit 87b9f9e95a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 13702 additions and 544 deletions

View file

@ -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].

View file

@ -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())
}

View file

@ -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);

View file

@ -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<NSString *> *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];

View file

@ -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")

View file

@ -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
})
}

View file

@ -0,0 +1,7 @@
//go:build windows
package main
import "github.com/ollama/ollama/app/webview"
func bindCodexDesktop(_ webview.WebView) {}

View file

@ -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")
}

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -223,6 +223,7 @@ func (w *Webview) Run(path string) unsafe.Pointer {
})
bindClaudeDesktop(wv)
bindCodexDesktop(wv)
wv.Bind("close", func() {
hideWindow(wv.Window())

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 320"><path d="m297.06 130.97c7.26-21.79 4.76-45.66-6.85-65.48-17.46-30.4-52.56-46.04-86.84-38.68-15.25-17.18-37.16-26.95-60.13-26.81-35.04-.08-66.13 22.48-76.91 55.82-22.51 4.61-41.94 18.7-53.31 38.67-17.59 30.32-13.58 68.54 9.92 94.54-7.26 21.79-4.76 45.66 6.85 65.48 17.46 30.4 52.56 46.04 86.84 38.68 15.24 17.18 37.16 26.95 60.13 26.8 35.06.09 66.16-22.49 76.94-55.86 22.51-4.61 41.94-18.7 53.31-38.67 17.57-30.32 13.55-68.51-9.94-94.51zm-120.28 168.11c-14.03.02-27.62-4.89-38.39-13.88.49-.26 1.34-.73 1.89-1.07l63.72-36.8c3.26-1.85 5.26-5.32 5.24-9.07v-89.83l26.93 15.55c.29.14.48.42.52.74v74.39c-.04 33.08-26.83 59.9-59.91 59.97zm-128.84-55.03c-7.03-12.14-9.56-26.37-7.15-40.18.47.28 1.3.79 1.89 1.13l63.72 36.8c3.23 1.89 7.23 1.89 10.47 0l77.79-44.92v31.1c.02.32-.13.63-.38.83l-64.41 37.19c-28.69 16.52-65.33 6.7-81.92-21.95zm-16.77-139.09c7-12.16 18.05-21.46 31.21-26.29 0 .55-.03 1.52-.03 2.2v73.61c-.02 3.74 1.98 7.21 5.23 9.06l77.79 44.91-26.93 15.55c-.27.18-.61.21-.91.08l-64.42-37.22c-28.63-16.58-38.45-53.21-21.95-81.89zm221.26 51.49-77.79-44.92 26.93-15.54c.27-.18.61-.21.91-.08l64.42 37.19c28.68 16.57 38.51 53.26 21.94 81.94-7.01 12.14-18.05 21.44-31.2 26.28v-75.81c.03-3.74-1.96-7.2-5.2-9.06zm26.8-40.34c-.47-.29-1.3-.79-1.89-1.13l-63.72-36.8c-3.23-1.89-7.23-1.89-10.47 0l-77.79 44.92v-31.1c-.02-.32.13-.63.38-.83l64.41-37.16c28.69-16.55 65.37-6.7 81.91 22 6.99 12.12 9.52 26.31 7.15 40.1zm-168.51 55.43-26.94-15.55c-.29-.14-.48-.42-.52-.74v-74.39c.02-33.12 26.89-59.96 60.01-59.94 14.01 0 27.57 4.92 38.34 13.88-.49.26-1.33.73-1.89 1.07l-63.72 36.8c-3.26 1.85-5.26 5.31-5.24 9.06l-.04 89.79zm14.63-31.54 34.65-20.01 34.65 20v40.01l-34.65 20-34.65-20z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

View file

@ -0,0 +1 @@
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2"><g transform="scale(32)"><clipPath id="codex-color-clip"><path d="M0 0h16v16H0z"/></clipPath><g clip-path="url(#codex-color-clip)"><path d="M13.003 0H2.997A3.012 3.012 0 000 2.997v10.006A3.012 3.012 0 002.997 16h10.006A3.012 3.012 0 0016 13.003V2.997A3.012 3.012 0 0013.003 0z" fill="#fff" fill-rule="nonzero"/><path d="M9.064 3.344a4.578 4.578 0 012.285-.312c1 .115 1.891.54 2.673 1.275.01.01.024.017.037.021a.104.104 0 00.043 0 4.556 4.556 0 013.046.275l.047.022.116.057a4.585 4.585 0 012.188 2.399c.209.51.313 1.041.315 1.595.015.412-.03.824-.134 1.223a.124.124 0 00.03.115c.594.607.988 1.33 1.183 2.17.289 1.425-.007 2.71-.887 3.854l-.136.166a4.548 4.548 0 01-2.201 1.388.12.12 0 00-.081.076c-.191.551-.383 1.023-.74 1.494-.9 1.187-2.222 1.846-3.711 1.838-1.187-.006-2.239-.44-3.157-1.302a.109.109 0 00-.105-.024c-.388.125-.78.143-1.204.138a4.438 4.438 0 01-1.945-.466 4.553 4.553 0 01-1.61-1.335c-.152-.202-.303-.392-.414-.617a5.797 5.797 0 01-.37-.961 4.575 4.575 0 01-.014-2.298.133.133 0 00.006-.056.083.083 0 00-.027-.048 4.467 4.467 0 01-1.034-1.651 3.898 3.898 0 01-.251-1.192 5.193 5.193 0 01.141-1.6c.337-1.112.982-1.985 1.933-2.618.212-.141.413-.251.601-.33a6.29 6.29 0 01.646-.227.1.1 0 00.065-.066 4.512 4.512 0 01.829-1.615 4.54 4.54 0 011.837-1.388zm3.482 10.565a.64.64 0 00-.601.636.64.64 0 00.601.636h3.636l.036.001a.64.64 0 00.637-.637.64.64 0 00-.637-.637l-.036.001h-3.636zM8.462 9.23a.64.64 0 00-.543-.304.64.64 0 00-.563.935l1.272 2.224-1.266 2.136a.638.638 0 001.095.649l1.454-2.455a.637.637 0 00.005-.64L8.462 9.23z" fill="url(#codex-color-gradient)" fill-rule="nonzero" transform="scale(.66667)"/></g></g><defs><linearGradient id="codex-color-gradient" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0 18 -18 0 12 3)"><stop offset="0" stop-color="#b1a7ff"/><stop offset=".5" stop-color="#7a9dff"/><stop offset="1" stop-color="#3941ff"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -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 (
<section aria-labelledby="apps-settings-heading" className="space-y-2">
<h2
id="apps-settings-heading"
className="px-1 text-xs font-medium uppercase tracking-wider text-neutral-400 dark:text-neutral-500"
>
Apps
</h2>
<div
aria-label={showSectionHeading ? undefined : "Claude settings"}
className="space-y-2"
>
{showSectionHeading && (
<h2
id="apps-settings-heading"
className="px-1 text-xs font-medium uppercase tracking-wider text-neutral-400 dark:text-neutral-500"
>
Apps
</h2>
)}
<div
aria-labelledby="claude-settings-heading"
className="overflow-visible rounded-xl bg-white p-4 dark:bg-neutral-800"
@ -689,6 +696,6 @@ export const ClaudeDesktopModelsSettings = forwardRef<
</div>
</div>
</div>
</section>
</div>
);
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,645 @@
import { Button } from "@/components/ui/button";
import type {
CodexDesktopModelStatus,
CodexDesktopModelsSettings as ModelsSettings,
CodexDesktopModelsSettingsResult,
CodexDesktopStatus,
} from "@/types/webview";
import {
ArrowPathIcon,
CheckIcon,
MagnifyingGlassIcon,
XMarkIcon,
} from "@heroicons/react/20/solid";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
export interface CodexDesktopModelsSettingsHandle {
resetToDefaults: () => Promise<boolean>;
}
interface CodexDesktopModelsSettingsProps {
initialSettings?: ModelsSettings;
accountKey?: string;
onDraftChange?: (hasChanges: boolean) => void;
}
const CHATGPT_OPEN_POLL_INTERVAL_MS = 250;
const CHATGPT_OPEN_TIMEOUT_MS = 30_000;
function wait(milliseconds: number): Promise<void> {
return new Promise((resolve) => globalThis.setTimeout(resolve, milliseconds));
}
async function waitForChatGPTToOpen(): Promise<
CodexDesktopStatus | null | undefined
> {
const getStatus = window.getCodexDesktopStatus;
if (!getStatus) return undefined;
const deadline = Date.now() + CHATGPT_OPEN_TIMEOUT_MS;
while (Date.now() < deadline) {
try {
const status = await getStatus();
if (status.running) return status;
} catch {
// ChatGPT may be between processes during a restart. Keep checking until
// it opens or the launch timeout expires.
}
await wait(CHATGPT_OPEN_POLL_INTERVAL_MS);
}
return null;
}
function selectionsEqual(
left: string[] | null | undefined,
right: string[] | null | undefined,
): boolean {
left ??= [];
right ??= [];
return (
left.length === right.length &&
left.every((model, index) => model === right[index])
);
}
function normalizeSettings(
settings: ModelsSettings & {
selected?: string[] | null;
available?: string[] | null;
models?: CodexDesktopModelStatus[] | null;
},
): ModelsSettings {
const available = settings.available ?? [];
return {
...settings,
usesDefaults: settings.usesDefaults ?? false,
selected: settings.selected ?? [],
available,
models:
settings.models ??
available.map((name) => ({
name,
displayName: name,
selected: settings.selected?.includes(name) ?? false,
availability: "available" as const,
})),
maxModels: settings.maxModels || 5,
};
}
function modelIsAvailable(model: CodexDesktopModelStatus): boolean {
return !model.availability || model.availability === "available";
}
function modelCanBeSelected(model: CodexDesktopModelStatus): boolean {
return model.recommended || modelIsAvailable(model);
}
function modelStatusLabel(model: CodexDesktopModelStatus): string | null {
switch (model.reason) {
case "cloud_off":
return "Cloud models are off";
case "sign_in_required":
return "Sign in required";
case "upgrade_required":
return model.requiredPlan
? `${model.requiredPlan[0]?.toUpperCase()}${model.requiredPlan.slice(1)} plan required`
: "Upgrade required";
case "verification_unavailable":
return "Access unavailable";
case "model_not_installed":
return "Not installed";
}
return null;
}
function ModelOptions({
models,
selected,
maxModels,
onToggle,
}: {
models: CodexDesktopModelStatus[];
selected: string[];
maxModels: number;
onToggle: (model: string) => void;
}) {
const [query, setQuery] = useState("");
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const searchRef = useRef<HTMLInputElement>(null);
const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);
const selectedSet = useMemo(() => new Set(selected), [selected]);
const normalizedQuery = query.trim().toLowerCase();
const filtered = models.filter((model) =>
`${model.displayName} ${model.name}`
.toLowerCase()
.includes(normalizedQuery),
);
useEffect(() => {
searchRef.current?.focus({ preventScroll: true });
}, []);
useEffect(() => {
setHighlightedIndex(-1);
}, [normalizedQuery]);
useEffect(() => {
if (highlightedIndex < 0) return;
optionRefs.current[highlightedIndex]?.scrollIntoView({ block: "nearest" });
}, [highlightedIndex]);
const optionIsDisabled = (model: CodexDesktopModelStatus) =>
!modelCanBeSelected(model) ||
(!selectedSet.has(model.name) && selected.length >= maxModels);
const moveHighlight = (direction: -1 | 1) => {
setHighlightedIndex((current) => {
if (filtered.length === 0) return -1;
const start = current < 0 ? (direction === 1 ? -1 : 0) : current;
for (let offset = 1; offset <= filtered.length; offset += 1) {
const candidate =
(start + direction * offset + filtered.length) % filtered.length;
if (!optionIsDisabled(filtered[candidate])) return candidate;
}
return -1;
});
};
return (
<>
<div className="flex items-center gap-2 border-b border-neutral-100 px-3 py-2 dark:border-neutral-700">
<MagnifyingGlassIcon className="h-4 w-4 shrink-0 text-neutral-400" />
<input
ref={searchRef}
type="text"
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "ArrowDown") {
event.preventDefault();
moveHighlight(1);
} else if (event.key === "ArrowUp") {
event.preventDefault();
moveHighlight(-1);
} else if (
event.key === "Enter" &&
highlightedIndex >= 0 &&
highlightedIndex < filtered.length
) {
event.preventDefault();
onToggle(filtered[highlightedIndex].name);
}
}}
placeholder="Find model..."
aria-label="Find ChatGPT model"
role="combobox"
aria-expanded="true"
aria-controls="chatgpt-model-options-listbox"
aria-activedescendant={
highlightedIndex >= 0
? `chatgpt-model-option-${highlightedIndex}`
: undefined
}
autoCorrect="off"
autoComplete="off"
className="min-w-0 flex-1 border-none bg-transparent py-0.5 outline-none"
/>
</div>
<div
id="chatgpt-model-options-listbox"
role="listbox"
aria-multiselectable="true"
className="min-h-0 overflow-y-auto py-1"
>
{filtered.map((model, index) => {
const checked = selectedSet.has(model.name);
const disabled = optionIsDisabled(model);
const statusLabel = modelStatusLabel(model);
return (
<button
key={model.name}
id={`chatgpt-model-option-${index}`}
ref={(element) => {
optionRefs.current[index] = element;
}}
type="button"
role="option"
aria-selected={checked}
disabled={disabled}
onClick={() => onToggle(model.name)}
onMouseEnter={() => setHighlightedIndex(index)}
className={`flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left hover:bg-neutral-100 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-neutral-700/60 dark:focus:bg-neutral-700/60 ${
highlightedIndex === index
? "bg-neutral-100 dark:bg-neutral-700/60"
: ""
}`}
>
<span className="h-4 w-4 shrink-0">
{checked && <CheckIcon className="h-4 w-4" />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate">{model.displayName}</span>
{statusLabel && (
<span className="mt-0.5 block truncate text-xs text-neutral-400">
{statusLabel}
</span>
)}
</span>
</button>
);
})}
{filtered.length === 0 && (
<p className="px-3 py-2 text-neutral-400">No models found</p>
)}
</div>
</>
);
}
export const CodexDesktopModelsSettings = forwardRef<
CodexDesktopModelsSettingsHandle,
CodexDesktopModelsSettingsProps
>(function CodexDesktopModelsSettings(
{ initialSettings, accountKey, onDraftChange },
ref,
) {
const normalizedInitialSettings = initialSettings
? normalizeSettings(initialSettings)
: null;
const [settings, setSettings] = useState<ModelsSettings | null>(
normalizedInitialSettings,
);
const [selected, setSelected] = useState<string[]>(
normalizedInitialSettings?.selected ?? [],
);
const [saved, setSaved] = useState<string[]>(
normalizedInitialSettings?.selected ?? [],
);
const [loading, setLoading] = useState(!initialSettings);
const [applying, setApplying] = useState(false);
const [resetting, setResetting] = useState(false);
const [launchAction, setLaunchAction] = useState<"start" | "restart">(
"start",
);
const [error, setError] = useState<string | null>(null);
const [warning, setWarning] = useState<string | null>(null);
const draftRef = useRef({ selected, saved });
const accountKeyRef = useRef(accountKey);
const statusRequestRef = useRef(0);
const operationInFlightRef = useRef(false);
draftRef.current = { selected, saved };
const applyResult = useCallback(
(result: CodexDesktopModelsSettingsResult, preserveDraft = false) => {
const nextSettings = normalizeSettings(result.settings);
const keepDraft =
preserveDraft &&
!selectionsEqual(draftRef.current.selected, draftRef.current.saved);
setSettings(nextSettings);
if (!keepDraft) {
setSelected(nextSettings.selected);
setSaved(nextSettings.selected);
}
setError(result.error ?? null);
setWarning(result.warning ?? null);
},
[],
);
const refresh = useCallback(async () => {
if (!window.getCodexDesktopModelsSettings) {
setError("ChatGPT model settings are unavailable in this Ollama build.");
setWarning(null);
setLoading(false);
return;
}
const request = ++statusRequestRef.current;
try {
const result = await window.getCodexDesktopModelsSettings();
if (
request === statusRequestRef.current &&
!operationInFlightRef.current
) {
applyResult(result, true);
}
} catch {
if (
request === statusRequestRef.current &&
!operationInFlightRef.current
) {
setError("Ollama could not load the ChatGPT model settings.");
setWarning(null);
}
} finally {
if (request === statusRequestRef.current) setLoading(false);
}
}, [applyResult]);
useEffect(() => {
if (!initialSettings) void refresh();
const onFocus = () => void refresh();
window.addEventListener("focus", onFocus);
return () => window.removeEventListener("focus", onFocus);
}, [initialSettings, refresh]);
useEffect(() => {
if (accountKeyRef.current === accountKey) return;
accountKeyRef.current = accountKey;
void refresh();
}, [accountKey, refresh]);
const hasChanges = !selectionsEqual(selected, saved);
useEffect(() => {
onDraftChange?.(hasChanges);
}, [hasChanges, onDraftChange]);
const maxModels = settings?.maxModels ?? 5;
const models = useMemo(() => {
const catalog = [...(settings?.models ?? [])];
const known = new Set(catalog.map((model) => model.name));
for (const name of selected) {
if (known.has(name)) continue;
known.add(name);
catalog.push({
name,
displayName: name,
selected: true,
availability: "unknown",
reason: "verification_unavailable",
});
}
return catalog;
}, [selected, settings?.models]);
const displayNames = useMemo(
() => new Map(models.map((model) => [model.name, model.displayName])),
[models],
);
const toggleModel = (model: string) => {
setError(null);
setWarning(null);
setSelected((current) => {
if (current.includes(model)) {
return current.filter((name) => name !== model);
}
if (current.length >= maxModels) return current;
return [...current, model];
});
};
const applyChanges = async () => {
if (!window.applyCodexDesktopModels) {
setError("ChatGPT model settings are available in the Ollama macOS app.");
return;
}
if (selected.length === 0) {
setError("Choose at least one model for ChatGPT.");
return;
}
if (operationInFlightRef.current) return;
setApplying(true);
setLaunchAction(settings?.running ? "restart" : "start");
setError(null);
setWarning(null);
operationInFlightRef.current = true;
++statusRequestRef.current;
try {
const modelsToApply =
!hasChanges && settings?.usesDefaults ? [] : selected;
let result = await window.applyCodexDesktopModels(modelsToApply, false);
if (result.restartConfirmationRequired) {
applyResult(result, true);
if (
!window.confirm(
result.settings.connected
? "Restart ChatGPT to update Ollama models? Any running task will stop."
: "Restart ChatGPT to add Ollama models? Any running task will stop.",
)
) {
return;
}
result = await window.applyCodexDesktopModels(modelsToApply, true);
}
++statusRequestRef.current;
if (result.error) {
setSettings(normalizeSettings(result.settings));
setError(result.error);
setWarning(result.warning ?? null);
return;
}
applyResult(result);
const openedStatus = await waitForChatGPTToOpen();
if (openedStatus === null) {
setError("ChatGPT is taking longer than expected to open. Try again.");
return;
}
if (openedStatus) {
setSettings((current) =>
current
? {
...current,
installed: openedStatus.installed,
connected: openedStatus.connected,
running: openedStatus.running,
}
: current,
);
}
} catch {
setError("Ollama could not apply the ChatGPT models.");
} finally {
++statusRequestRef.current;
operationInFlightRef.current = false;
setApplying(false);
}
};
const resetToDefaults = useCallback(async (): Promise<boolean> => {
if (operationInFlightRef.current) return false;
const resetModels = window.resetCodexDesktopModels;
if (!resetModels) {
setError("Ollama could not reset the ChatGPT models.");
return false;
}
setResetting(true);
setError(null);
setWarning(null);
operationInFlightRef.current = true;
++statusRequestRef.current;
try {
const result = await resetModels();
++statusRequestRef.current;
if (result.error) {
setSettings(normalizeSettings(result.settings));
setError(result.error);
setWarning(result.warning ?? null);
return false;
}
applyResult(result);
return true;
} catch {
setError("Ollama could not reset the ChatGPT models.");
return false;
} finally {
++statusRequestRef.current;
operationInFlightRef.current = false;
setResetting(false);
}
}, [applyResult]);
useImperativeHandle(ref, () => ({ resetToDefaults }), [resetToDefaults]);
if (!settings?.supported && !loading && !error) return null;
const busy = applying || resetting;
return (
<div
aria-labelledby="chatgpt-model-settings-heading"
className="overflow-visible rounded-xl bg-white p-4 dark:bg-neutral-800"
>
<div className="flex items-start space-x-3">
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center">
<img
src="/launch-icons/codex.svg"
alt=""
className="h-5 w-5 dark:hidden"
/>
<img
src="/launch-icons/codex-dark.svg"
alt=""
className="hidden h-5 w-5 dark:block"
/>
</span>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-4">
<div>
<h2
id="chatgpt-model-settings-heading"
className="text-sm font-medium text-neutral-900 dark:text-white"
>
ChatGPT
</h2>
<p className="mt-1 text-base/6 text-zinc-500 sm:text-sm/6 dark:text-zinc-400">
Choose up to {maxModels} Ollama models to use in ChatGPT.
</p>
</div>
<div className="shrink-0">
<Button
type="button"
color="white"
onClick={() => void applyChanges()}
disabled={loading || busy || selected.length === 0}
>
{applying && (
<ArrowPathIcon data-slot="icon" className="animate-spin" />
)}
{applying
? launchAction === "restart"
? "Restarting…"
: "Starting…"
: settings?.running
? hasChanges
? "Save & restart ChatGPT"
: "Restart ChatGPT"
: hasChanges
? "Save & start ChatGPT"
: "Start ChatGPT"}
</Button>
</div>
</div>
<div className="mt-4 w-full max-w-xl">
<Popover className="relative w-full">
<div
data-testid="chatgpt-model-picker"
className="relative flex min-h-10 w-full flex-wrap items-center gap-1.5 rounded-lg bg-neutral-50 px-2 py-1.5 ring-1 ring-inset ring-neutral-200 hover:bg-neutral-100 dark:bg-neutral-700 dark:ring-neutral-600 dark:hover:bg-neutral-600"
>
<PopoverButton
aria-label="Add ChatGPT model"
disabled={loading || busy}
className="absolute inset-0 rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:cursor-not-allowed"
>
<span className="sr-only">Choose ChatGPT models</span>
</PopoverButton>
<div className="pointer-events-none relative z-10 flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
{selected.map((model) => (
<span
key={model}
className="pointer-events-none inline-flex max-w-full items-stretch overflow-hidden rounded-md bg-neutral-200/70 text-sm text-neutral-700 dark:bg-neutral-600 dark:text-neutral-100"
>
<span className="min-w-0 py-1 pl-2 pr-1">
<span className="block truncate">
{displayNames.get(model) ?? model}
</span>
</span>
<button
type="button"
aria-label={`Remove ${model}`}
disabled={busy}
onClick={(event) => {
event.stopPropagation();
toggleModel(model);
}}
className="pointer-events-auto inline-flex shrink-0 items-center px-1.5 hover:bg-neutral-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:opacity-50 dark:hover:bg-neutral-500"
>
<XMarkIcon className="h-3.5 w-3.5" />
</button>
</span>
))}
{selected.length === 0 && (
<span className="px-1 py-1 text-sm text-neutral-400">
{loading ? "Loading models…" : "Select models"}
</span>
)}
</div>
</div>
<PopoverPanel
anchor={{ to: "bottom start", gap: 8, padding: 8 }}
data-testid="chatgpt-model-options"
className="z-50 flex w-[var(--button-width)] max-w-[calc(100vw-1rem)] flex-col overflow-hidden rounded-2xl border border-neutral-100 bg-white text-[15px] text-neutral-800 shadow-xl shadow-black/5 [--anchor-max-height:19rem] dark:border-neutral-600/40 dark:bg-neutral-800 dark:text-white"
>
<ModelOptions
models={models}
selected={selected}
maxModels={maxModels}
onToggle={toggleModel}
/>
</PopoverPanel>
</Popover>
</div>
{error && (
<p
role="alert"
className="mt-3 w-full max-w-xl text-xs leading-5 text-red-600 dark:text-red-400"
>
{error}
</p>
)}
{warning && (
<p
role="status"
className="mt-3 w-full max-w-xl text-xs leading-5 text-zinc-500 dark:text-zinc-400"
>
{warning}
</p>
)}
</div>
</div>
</div>
);
});

View file

@ -0,0 +1,558 @@
import type { IntegrationStatus } from "@/api";
import type { CodexDesktopStatus } from "@/types/webview";
import { renderToStaticMarkup } from "react-dom/server";
import { act, create } from "react-test-renderer";
import { afterEach, describe, expect, it, vi } from "vitest";
import { CodexDesktopRow } from "./CodexDesktopRow";
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
const integration: IntegrationStatus = {
id: "chatgpt",
name: "ChatGPT",
description: "Use Ollama models in ChatGPT",
installed: true,
command: "ollama launch chatgpt",
};
function status(
overrides: Partial<CodexDesktopStatus> = {},
): CodexDesktopStatus {
return {
supported: true,
installed: true,
connected: false,
running: false,
...overrides,
};
}
describe("CodexDesktopRow", () => {
it("renders a disconnected ChatGPT toggle", () => {
const html = renderToStaticMarkup(
<CodexDesktopRow integration={integration} initialStatus={status()} />,
);
expect(html).toContain(">ChatGPT (Desktop)</p>");
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(
<CodexDesktopRow
integration={integration}
initialStatus={status({
connected: true,
model: "qwen3:8b",
models: ["qwen3:8b", "glm-5.3-flash:cloud", "kimi-k2.7-code:cloud"],
})}
/>,
);
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(
<CodexDesktopRow
integration={integration}
initialStatus={status({
connected: true,
model: "qwen3:8b",
requests: 1,
})}
/>,
);
expect(html).toContain("1 Ollama request this session");
});
it("offers installation when ChatGPT is not installed", () => {
const html = renderToStaticMarkup(
<CodexDesktopRow
integration={{ ...integration, installed: false }}
initialStatus={status({ installed: false })}
/>,
);
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 &amp; 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(
<CodexDesktopRow
integration={{ ...integration, installed: false }}
initialStatus={notInstalled}
/>,
);
});
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(
<CodexDesktopRow
integration={integration}
initialStatus={status()}
/>,
);
});
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(
<CodexDesktopRow
integration={{ ...integration, installed: false }}
initialStatus={status({ installed: false })}
/>,
);
});
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(
<CodexDesktopRow
integration={{ ...integration, installed: false }}
initialStatus={status({ installed: false })}
/>,
);
});
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(
<CodexDesktopRow
integration={{ ...integration, installed: false }}
initialStatus={status({ installed: false })}
/>,
);
});
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(
<CodexDesktopRow
integration={integration}
initialStatus={status({ running: true })}
/>,
);
});
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(
<CodexDesktopRow
integration={integration}
initialStatus={status({ running: true })}
/>,
);
});
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(
<CodexDesktopRow
integration={integration}
initialStatus={status({ running: true })}
/>,
);
});
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(
<CodexDesktopRow
integration={{ ...integration, installed: false }}
initialStatus={status({ installed: false, connected: true })}
/>,
);
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(
<CodexDesktopRow
integration={{ ...integration, installed: false }}
initialStatus={status({ installed: false, connected: true })}
/>,
);
});
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());
}
});
});

View file

@ -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 (
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-transparent">
{icon ? (
<>
<img
src={icon.src}
alt=""
className={`${icon.className ?? "h-7 w-7"} rounded-sm object-contain ${icon.darkSrc ? "dark:hidden" : ""}`}
/>
{icon.darkSrc && (
<img
src={icon.darkSrc}
alt=""
className={`${icon.className ?? "h-7 w-7"} hidden rounded-sm object-contain dark:block`}
/>
)}
</>
) : (
<CommandLineIcon className="h-6 w-6 stroke-[1.5] text-neutral-700 dark:text-neutral-300" />
)}
</div>
);
}
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<CodexDesktopStatus | null>(
initialStatus ?? null,
);
const [phase, setPhase] = useState<CodexConnectPhase>("idle");
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(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 (
<div className="flex min-h-18 items-center justify-between gap-4 bg-white px-4 py-3 dark:bg-neutral-900">
<div className="flex min-w-0 items-center gap-3">
<CodexIcon integration={integration} />
<div className="min-w-0">
<p className="text-sm font-medium text-neutral-950 dark:text-neutral-100">
ChatGPT (Desktop)
</p>
<p
role={error ? "alert" : notice ? "status" : undefined}
className="truncate text-xs leading-5 text-neutral-500 dark:text-neutral-400"
>
{description}
</p>
</div>
</div>
<div className="ml-auto flex shrink-0 items-center gap-2.5">
{statusLabel && (
<span
role="status"
aria-live="polite"
className="inline-flex items-center gap-1.5 whitespace-nowrap text-xs text-neutral-500 dark:text-neutral-400"
>
{isConnecting && (
<ArrowPathIcon className="h-3.5 w-3.5 animate-spin" />
)}
{statusLabel}
</span>
)}
<button
type="button"
role="switch"
aria-checked={displayedConnected}
aria-busy={pending || undefined}
aria-label={
connected
? "Remove Ollama models from ChatGPT"
: isConnecting
? "Connecting ChatGPT"
: "Add Ollama models to ChatGPT"
}
title={
connected
? "Remove Ollama models"
: installed
? "Add Ollama models"
: "Install ChatGPT and add Ollama models"
}
disabled={pending}
onClick={() => 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"}`}
>
<span
aria-hidden="true"
className={`inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${pending ? "animate-pulse" : ""} ${displayedConnected ? "translate-x-4.5 dark:bg-neutral-900" : "translate-x-0.5"}`}
/>
</button>
</div>
</div>
);
}

View file

@ -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<void>;
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</th>");
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 &amp; connect");
expect(html).toContain("Download &amp; 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</button>");
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(
<ConnectAppsScreen
initialIntegrations={[
{
id: "claude-desktop",
name: "Claude",
description: "Use Ollama models in Claude Desktop",
installed: true,
},
{
id: "codex",
name: "Codex CLI",
description: "OpenAI's coding agent",
command: "ollama launch codex",
},
]}
initialCodexStatus={{
supported: true,
installed: true,
connected: false,
running: false,
}}
/>,
);
expect(html.indexOf("Use Ollama models in Claude Desktop")).toBeLessThan(
html.indexOf(">ChatGPT (Desktop)</p>"),
);
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(
<ConnectAppsScreen
@ -630,7 +685,7 @@ describe("Onboarding", () => {
},
{
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 &amp; connect");
expect(html).not.toContain("Inactive");
expect(html).not.toContain("Not installed");
expect(html).not.toContain('disabled=""');
const claudeButton = html.match(
/<button[^>]*aria-label="Connect Claude"[^>]*>/,
)?.[0];
expect(claudeButton).toBeDefined();
expect(claudeButton).not.toContain('disabled=""');
});
it("uses branded icons for the remaining launcher integrations", () => {

View file

@ -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<string | null>(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({
<div className="mx-auto w-full max-w-4xl text-left">
{integrationStatuses ? (
<div className="space-y-7 pb-4 pt-2">
{claudeIntegration && (
{(claudeIntegration || codexIntegration) && (
<section aria-labelledby="desktop-heading">
<h2
id="desktop-heading"
@ -1056,8 +1069,14 @@ export function ConnectAppsScreen({
>
Desktop
</h2>
<div className="mt-2 bg-white dark:bg-neutral-900">
<div className="mt-2 space-y-2 bg-white dark:bg-neutral-900">
{claudeRow}
{codexIntegration && (
<CodexDesktopRow
integration={codexIntegration}
initialStatus={initialCodexStatus}
/>
)}
</div>
</section>
)}
@ -1078,11 +1097,13 @@ export function ConnectAppsScreen({
</section>
)}
{!claudeIntegration && launchIntegrations.length === 0 && (
<p className="py-12 text-center text-sm text-neutral-400 dark:text-neutral-500">
No apps found.
</p>
)}
{!claudeIntegration &&
!codexIntegration &&
launchIntegrations.length === 0 && (
<p className="py-12 text-center text-sm text-neutral-400 dark:text-neutral-500">
No apps found.
</p>
)}
</div>
) : statusError ? (
<p role="alert" className="mt-8 text-sm text-red-600">

View file

@ -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 <section aria-label="ChatGPT settings" />;
},
),
}));
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(<Settings />);
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();

View file

@ -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<boolean>((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();
});
});

View file

@ -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<unknown>;
updateCloud: (enabled: boolean) => Promise<unknown>;
updateShowAppsInMenu: (visible: boolean) => Promise<unknown>;
resetChatGPTModels: () => Promise<boolean>;
resetClaudeMappings: () => Promise<boolean>;
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<string | null>(null);
const [hasClaudeDraftChanges, setHasClaudeDraftChanges] = useState(false);
const [hasCodexDraftChanges, setHasCodexDraftChanges] = useState(false);
const claudeModelsSettingsRef =
useRef<ClaudeDesktopModelsSettingsHandle>(null);
const codexModelsSettingsRef =
useRef<CodexDesktopModelsSettingsHandle>(null);
const savedConfirmationTimeoutRef = useRef<number | null>(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
</Button>
@ -756,13 +781,30 @@ export default function Settings() {
</div>
{!isWindows && (
<ClaudeDesktopModelsSettings
ref={claudeModelsSettingsRef}
includeCloudModels={
isAuthenticated && cloudStatusKnown && !cloudDisabled
}
onDraftChange={setHasClaudeDraftChanges}
/>
<section
aria-labelledby="apps-settings-heading"
className="space-y-2"
>
<h2
id="apps-settings-heading"
className="px-1 text-xs font-medium uppercase tracking-wider text-neutral-400 dark:text-neutral-500"
>
Apps
</h2>
<ClaudeDesktopModelsSettings
ref={claudeModelsSettingsRef}
includeCloudModels={
isAuthenticated && cloudStatusKnown && !cloudDisabled
}
onDraftChange={setHasClaudeDraftChanges}
showSectionHeading={false}
/>
<CodexDesktopModelsSettings
ref={codexModelsSettingsRef}
accountKey={`${user?.id ?? "signed-out"}:${user?.plan ?? ""}:${cloudDisabled ? "cloud-off" : "cloud-on"}`}
onDraftChange={setHasCodexDraftChanges}
/>
</section>
)}
{/* Agent Mode */}

View file

@ -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,
};
}

View file

@ -15,6 +15,9 @@ export const INTEGRATION_ICONS: Record<string, IntegrationIcon> = {
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",
},

View file

@ -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<ImageData | null>;
@ -85,6 +138,19 @@ declare global {
) => Promise<ClaudeDesktopActionResult>;
prepareClaudeDesktopConnection?: () => Promise<ClaudeDesktopActionResult>;
openClaudeDesktop?: () => Promise<string>;
getCodexDesktopStatus?: () => Promise<CodexDesktopStatus>;
getCodexDesktopRequestCount?: () => Promise<number>;
setCodexDesktopConnected?: (
enabled: boolean,
restartConfirmed: boolean,
) => Promise<CodexDesktopActionResult>;
installCodexDesktop?: () => Promise<CodexDesktopInstallResult>;
getCodexDesktopModelsSettings?: () => Promise<CodexDesktopModelsSettingsResult>;
applyCodexDesktopModels?: (
models: string[],
restartConfirmed: boolean,
) => Promise<CodexDesktopModelsSettingsResult>;
resetCodexDesktopModels?: () => Promise<CodexDesktopModelsSettingsResult>;
installClaudeDesktop?: () => Promise<ClaudeDesktopInstallResult>;
getShowAppsInMenu?: () => Promise<boolean>;
setShowAppsInMenu?: (visible: boolean) => Promise<void>;
@ -129,6 +195,12 @@ export type {
ClaudeDesktopMappingStatus,
ClaudeDesktopModelStatus,
ClaudeDesktopStatus,
CodexDesktopActionResult,
CodexDesktopInstallResult,
CodexDesktopModelsSettings,
CodexDesktopModelStatus,
CodexDesktopModelsSettingsResult,
CodexDesktopStatus,
ContextMenuItem,
ContextMenuResult,
ImageData,

View file

@ -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",

View file

@ -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"}

View file

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

File diff suppressed because it is too large Load diff

View file

@ -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")
}

File diff suppressed because it is too large Load diff

View file

@ -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"},

View file

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

View file

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

View file

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

View file

@ -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()

View file

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

View file

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

View file

@ -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})
}

View file

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

View file

@ -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()
}

View file

@ -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)
}
}

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -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"}]}`)

View file

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

34
server/codex_proxy.go Normal file
View file

@ -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(),
})
}

View file

@ -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())
}
}

View file

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

View file

@ -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, "")

View file

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