mirror of
https://github.com/ollama/ollama.git
synced 2026-10-20 18:41:17 +00:00
app: add Ollama to ChatGPT Desktop (#18236)
This commit is contained in:
parent
3f77cb6dfb
commit
87b9f9e95a
56 changed files with 13702 additions and 544 deletions
31
api/types.go
31
api/types.go
|
|
@ -816,12 +816,31 @@ type ModelRecommendationMappings map[string]ModelRecommendationMapping
|
|||
|
||||
// ModelRecommendation is a single recommendation entry in [ModelRecommendationsResponse].
|
||||
type ModelRecommendation struct {
|
||||
Model string `json:"model"`
|
||||
Description string `json:"description"`
|
||||
ContextLength int `json:"context_length,omitempty"`
|
||||
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
|
||||
VRAMBytes int64 `json:"vram_bytes,omitempty"`
|
||||
RequiredPlan string `json:"required_plan,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Description string `json:"description"`
|
||||
ContextLength int `json:"context_length,omitempty"`
|
||||
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
|
||||
VRAMBytes int64 `json:"vram_bytes,omitempty"`
|
||||
RequiredPlan string `json:"required_plan,omitempty"`
|
||||
Thinking *ModelRecommendationThinking `json:"thinking,omitempty"`
|
||||
}
|
||||
|
||||
// ModelRecommendationThinking advertises the exact values accepted by
|
||||
// Ollama's think field and the model's default. Values may be booleans for
|
||||
// binary thinking controls or strings for adjustable effort levels.
|
||||
type ModelRecommendationThinking struct {
|
||||
Values []any `json:"values,omitempty"`
|
||||
Default any `json:"default,omitempty"`
|
||||
}
|
||||
|
||||
// Clone returns an independent copy.
|
||||
func (t *ModelRecommendationThinking) Clone() *ModelRecommendationThinking {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
clone := *t
|
||||
clone.Values = append([]any(nil), t.Values...)
|
||||
return &clone
|
||||
}
|
||||
|
||||
// ProcessResponse is the response from [Client.Process].
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
75
app/cmd/app/codex_app_bindings_darwin.go
Normal file
75
app/cmd/app/codex_app_bindings_darwin.go
Normal 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
|
||||
})
|
||||
}
|
||||
7
app/cmd/app/codex_app_bindings_windows.go
Normal file
7
app/cmd/app/codex_app_bindings_windows.go
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import "github.com/ollama/ollama/app/webview"
|
||||
|
||||
func bindCodexDesktop(_ webview.WebView) {}
|
||||
979
app/cmd/app/codex_app_darwin.go
Normal file
979
app/cmd/app/codex_app_darwin.go
Normal 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")
|
||||
}
|
||||
1379
app/cmd/app/codex_app_darwin_test.go
Normal file
1379
app/cmd/app/codex_app_darwin_test.go
Normal file
File diff suppressed because it is too large
Load diff
212
app/cmd/app/codex_desktop_installer_darwin.go
Normal file
212
app/cmd/app/codex_desktop_installer_darwin.go
Normal 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
|
||||
}
|
||||
120
app/cmd/app/codex_desktop_installer_darwin_test.go
Normal file
120
app/cmd/app/codex_desktop_installer_darwin_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -223,6 +223,7 @@ func (w *Webview) Run(path string) unsafe.Pointer {
|
|||
})
|
||||
|
||||
bindClaudeDesktop(wv)
|
||||
bindCodexDesktop(wv)
|
||||
|
||||
wv.Bind("close", func() {
|
||||
hideWindow(wv.Window())
|
||||
|
|
|
|||
1
app/darwin/Ollama.app/Contents/Resources/chatgpt.svg
Normal file
1
app/darwin/Ollama.app/Contents/Resources/chatgpt.svg
Normal 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 |
1
app/ui/app/public/launch-icons/codex-color.svg
Normal file
1
app/ui/app/public/launch-icons/codex-color.svg
Normal 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 |
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
1094
app/ui/app/src/components/CodexDesktopModelsSettings.test.tsx
Normal file
1094
app/ui/app/src/components/CodexDesktopModelsSettings.test.tsx
Normal file
File diff suppressed because it is too large
Load diff
645
app/ui/app/src/components/CodexDesktopModelsSettings.tsx
Normal file
645
app/ui/app/src/components/CodexDesktopModelsSettings.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
558
app/ui/app/src/components/CodexDesktopRow.test.tsx
Normal file
558
app/ui/app/src/components/CodexDesktopRow.test.tsx
Normal 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 & 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());
|
||||
}
|
||||
});
|
||||
});
|
||||
387
app/ui/app/src/components/CodexDesktopRow.tsx
Normal file
387
app/ui/app/src/components/CodexDesktopRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 & connect");
|
||||
expect(html).toContain("Download & connect");
|
||||
expect(html).not.toContain("Active");
|
||||
expect(html).toContain("bg-transparent");
|
||||
expect(html).toContain('aria-label="Copy OpenCode command"');
|
||||
expect(html).toContain('aria-label="Copy Terminal command"');
|
||||
expect(html).not.toContain(">Copy command</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 & 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", () => {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
72
app/ui/app/src/types/webview.d.ts
vendored
72
app/ui/app/src/types/webview.d.ts
vendored
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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
245
cmd/launch/codex_app_profile.go
Normal file
245
cmd/launch/codex_app_profile.go
Normal 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
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
645
internal/proxy/codex_desktop.go
Normal file
645
internal/proxy/codex_desktop.go
Normal 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})
|
||||
}
|
||||
623
internal/proxy/codex_desktop_autoreview.go
Normal file
623
internal/proxy/codex_desktop_autoreview.go
Normal 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
|
||||
}
|
||||
206
internal/proxy/codex_desktop_errors.go
Normal file
206
internal/proxy/codex_desktop_errors.go
Normal 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()
|
||||
}
|
||||
232
internal/proxy/codex_desktop_errors_test.go
Normal file
232
internal/proxy/codex_desktop_errors_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
333
internal/proxy/codex_desktop_normalize.go
Normal file
333
internal/proxy/codex_desktop_normalize.go
Normal 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
|
||||
}
|
||||
1586
internal/proxy/codex_desktop_test.go
Normal file
1586
internal/proxy/codex_desktop_test.go
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"}]}`)
|
||||
|
|
|
|||
|
|
@ -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
34
server/codex_proxy.go
Normal 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(),
|
||||
})
|
||||
}
|
||||
41
server/codex_proxy_test.go
Normal file
41
server/codex_proxy_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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, "")
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue