Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/desktop/scripts/dev.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ if (process.getuid && process.getuid() === 0 && !env.NO_SANDBOX) {
env.NO_SANDBOX = '1';
}

if (process.env.REMOTE_DEBUGGING_PORT) {
env.ELECTRON_CLI_ARGS = JSON.stringify([
`--remote-debugging-port=${process.env.REMOTE_DEBUGGING_PORT}`,
'--remote-allow-origins=*',
]);
}

const child = spawn(process.execPath, [electronViteBin, 'dev', ...process.argv.slice(2)], {
env,
stdio: ['inherit', 'inherit', 'pipe'],
Expand Down
35 changes: 35 additions & 0 deletions apps/desktop/src/main/connection-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
import { buildAuthHeaders, buildAuthHeadersForWire } from './auth-headers';
import { getCodexTokenStore } from './codex-oauth-ipc';
import { ipcMain } from './electron-runtime';
import { resolveImageGenerationTestCredentials } from './image-generation-settings';
import { getApiKeyForProvider, getCachedConfig, hasApiKeyForProvider } from './onboarding-ipc';
import { isKeylessProviderAllowed } from './provider-settings';
import { withTlsBypass } from './tls-override';
Expand Down Expand Up @@ -806,6 +807,10 @@ export function registerConnectionIpc(): void {
handleConnectionV1TestProvider(raw),
);

// Tests the currently configured image-generation provider using its own
// (possibly separate) key + baseUrl. No payload — resolved from settings.
ipcMain.handle('connection:v1:test-image-provider', () => handleConnectionV1TestImageProvider());

// Fetch available models for a stored provider by ID — credentials resolved
// from the encrypted config so the renderer never touches plaintext keys.
ipcMain.handle('models:v1:list-for-provider', (_e, raw: unknown) =>
Expand Down Expand Up @@ -916,6 +921,36 @@ async function handleConnectionV1TestProvider(raw: unknown): Promise<ConnectionT
return runProviderTest(creds);
}

// Image generation providers live in their own config slice (cfg.imageGeneration)
// with independent baseUrl, credential mode, and (for custom mode) a separate
// stored key. The probe still hits GET /models on the image baseUrl, so once we
// resolve the credentials we hand off to the shared runProviderTest with a
// synthesized ActiveProviderCredentials. chatgpt-codex stays on its OAuth path.
async function handleConnectionV1TestImageProvider(): Promise<ConnectionTestResponse> {
const cfg = getCachedConfig();
const resolved = await resolveImageGenerationTestCredentials(cfg);
if (!resolved.ok) {
return {
ok: false,
code: 'IPC_BAD_INPUT',
message: resolved.message,
hint:
resolved.code === 'IMAGE_GEN_DISABLED'
? 'Configure an image generation provider in Settings → Image generation first.'
: 'Add an API key for the image generation provider in Settings → Image generation, or sign in to ChatGPT.',
};
}
const wire: WireApi =
resolved.provider === 'chatgpt-codex' ? 'openai-codex-responses' : 'openai-chat';
return runProviderTest({
provider: resolved.provider,
wire,
apiKey: resolved.apiKey,
baseUrl: resolved.baseUrl,
builtin: true,
});
}

type ResolvedProviderForListing = { providerId: string; entry: ProviderEntry };

function resolveProviderForListing(
Expand Down
87 changes: 87 additions & 0 deletions apps/desktop/src/main/image-generation-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
isGenerateImageAssetEnabled,
parseImageGenerationUpdate,
resolveImageGenerationConfig,
resolveImageGenerationTestCredentials,
updateImageGenerationSettings,
} from './image-generation-settings';

Expand Down Expand Up @@ -406,3 +407,89 @@ describe('image generation enablement', () => {
});
});
});

describe('resolveImageGenerationTestCredentials', () => {
afterEach(() => {
mocks.cachedConfig = null;
getApiKeyForProviderMock.mockReset();
mocks.codexGetValidAccessToken.mockReset();
});

it('returns IMAGE_GEN_DISABLED when no imageGeneration config exists', async () => {
const result = await resolveImageGenerationTestCredentials(null);
expect(result).toMatchObject({ ok: false, code: 'IMAGE_GEN_DISABLED' });
});

it('resolves inherited credentials even when image generation is disabled', async () => {
getApiKeyForProviderMock.mockReturnValue('sk-openai');
const cfg = makeConfig(false);
const result = await resolveImageGenerationTestCredentials(cfg);
expect(result).toMatchObject({
ok: true,
provider: 'openai',
apiKey: 'sk-openai',
baseUrl: 'https://api.openai.com/v1',
});
});

it('returns PROVIDER_KEY_MISSING when inherited credential cannot be read', async () => {
getApiKeyForProviderMock.mockImplementation(() => {
throw new CodesignError('missing key', ERROR_CODES.PROVIDER_KEY_MISSING);
});
const cfg = makeConfig(true);
const result = await resolveImageGenerationTestCredentials(cfg);
expect(result).toMatchObject({ ok: false, code: 'PROVIDER_KEY_MISSING' });
});

it('decrypts custom-mode key when one is stored', async () => {
const baseCfg = makeConfig(true);
const cfg = hydrateConfig({
version: 3,
activeProvider: baseCfg.activeProvider,
activeModel: baseCfg.activeModel,
providers: baseCfg.providers,
secrets: baseCfg.secrets,
imageGeneration: {
schemaVersion: IMAGE_GENERATION_SCHEMA_VERSION,
enabled: true,
provider: 'openai',
credentialMode: 'custom',
model: 'gpt-image-2',
quality: 'high',
size: '1536x1024',
outputFormat: 'png',
apiKey: { ciphertext: 'sk-custom-image', mask: 'sk-…age' },
},
});
const result = await resolveImageGenerationTestCredentials(cfg);
expect(result).toMatchObject({ ok: true, provider: 'openai', apiKey: 'sk-custom-image' });
});

it('uses ChatGPT OAuth access token for chatgpt-codex provider', async () => {
mocks.codexGetValidAccessToken.mockResolvedValue('codex-token-abc');
const baseCfg = makeConfig(true);
const cfg = hydrateConfig({
version: 3,
activeProvider: baseCfg.activeProvider,
activeModel: baseCfg.activeModel,
providers: baseCfg.providers,
secrets: baseCfg.secrets,
imageGeneration: {
schemaVersion: IMAGE_GENERATION_SCHEMA_VERSION,
enabled: true,
provider: 'chatgpt-codex',
credentialMode: 'inherit',
model: 'gpt-5.5',
quality: 'high',
size: '1536x1024',
outputFormat: 'png',
},
});
const result = await resolveImageGenerationTestCredentials(cfg);
expect(result).toMatchObject({
ok: true,
provider: 'chatgpt-codex',
apiKey: 'codex-token-abc',
});
});
});
65 changes: 65 additions & 0 deletions apps/desktop/src/main/image-generation-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,71 @@ export async function resolveImageGenerationConfig(
};
}

export type ResolvedImageGenerationTestCredentials =
| {
ok: true;
provider: ImageGenerationProvider;
apiKey: string;
baseUrl: string;
}
| { ok: false; code: 'IMAGE_GEN_DISABLED' | 'PROVIDER_KEY_MISSING'; message: string };

export async function resolveImageGenerationTestCredentials(
cfg: Config | null,
): Promise<ResolvedImageGenerationTestCredentials> {
if (cfg === null || cfg.imageGeneration === undefined) {
return {
ok: false,
code: 'IMAGE_GEN_DISABLED',
message: 'Image generation is not configured.',
};
}
const parsed = ImageGenerationSettingsSchema.parse(cfg.imageGeneration);
const baseUrl = parsed.baseUrl ?? defaultImageBaseUrl(parsed.provider);

if (parsed.provider === CHATGPT_CODEX_PROVIDER_ID) {
let apiKey: string;
try {
apiKey = await getCodexTokenStore().getValidAccessToken();
} catch (err) {
return {
ok: false,
code: 'PROVIDER_KEY_MISSING',
message: err instanceof Error ? err.message : String(err),
};
}
return { ok: true, provider: parsed.provider, apiKey, baseUrl };
}

if (parsed.credentialMode === 'custom') {
if (parsed.apiKey === undefined) {
return {
ok: false,
code: 'PROVIDER_KEY_MISSING',
message: `No custom image API key stored for "${parsed.provider}".`,
};
}
return {
ok: true,
provider: parsed.provider,
apiKey: decryptSecret(parsed.apiKey.ciphertext),
baseUrl,
};
}

let apiKey: string;
try {
apiKey = getApiKeyForProvider(parsed.provider);
} catch (err) {
return {
ok: false,
code: 'PROVIDER_KEY_MISSING',
message: err instanceof Error ? err.message : String(err),
};
}
return { ok: true, provider: parsed.provider, apiKey, baseUrl };
}

export async function isGenerateImageAssetEnabled(cfg: Config): Promise<boolean> {
return (await resolveImageGenerationConfig(cfg)) !== null;
}
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,10 @@ const api = {
ipcRenderer.invoke('connection:v1:test-provider', providerId) as Promise<
ConnectionTestResult | ConnectionTestError
>,
testImageProvider: () =>
ipcRenderer.invoke('connection:v1:test-image-provider') as Promise<
ConnectionTestResult | ConnectionTestError
>,
},
models: {
list: (input: { provider: SupportedOnboardingProvider; apiKey: string; baseUrl: string }) =>
Expand Down
Loading
Loading