Skip to content

Commit 9d84d3e

Browse files
tiegebentleyclaude
andcommitted
feat(desktop): add test-connection button for image generation providers
Resolves credentials for all three paths (inherited, custom encrypted, ChatGPT OAuth) and probes GET /models on the image baseUrl. Includes UI button with loading/success/error toasts, 5 unit tests, and i18n strings for en/es/zh-CN. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bf1e711 commit 9d84d3e

8 files changed

Lines changed: 255 additions & 4 deletions

File tree

apps/desktop/src/main/connection-ipc.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
import { buildAuthHeaders, buildAuthHeadersForWire } from './auth-headers';
1717
import { getCodexTokenStore } from './codex-oauth-ipc';
1818
import { ipcMain } from './electron-runtime';
19+
import { resolveImageGenerationTestCredentials } from './image-generation-settings';
1920
import { getApiKeyForProvider, getCachedConfig, hasApiKeyForProvider } from './onboarding-ipc';
2021
import { isKeylessProviderAllowed } from './provider-settings';
2122
import { withTlsBypass } from './tls-override';
@@ -806,6 +807,10 @@ export function registerConnectionIpc(): void {
806807
handleConnectionV1TestProvider(raw),
807808
);
808809

810+
// Tests the currently configured image-generation provider using its own
811+
// (possibly separate) key + baseUrl. No payload — resolved from settings.
812+
ipcMain.handle('connection:v1:test-image-provider', () => handleConnectionV1TestImageProvider());
813+
809814
// Fetch available models for a stored provider by ID — credentials resolved
810815
// from the encrypted config so the renderer never touches plaintext keys.
811816
ipcMain.handle('models:v1:list-for-provider', (_e, raw: unknown) =>
@@ -916,6 +921,36 @@ async function handleConnectionV1TestProvider(raw: unknown): Promise<ConnectionT
916921
return runProviderTest(creds);
917922
}
918923

924+
// Image generation providers live in their own config slice (cfg.imageGeneration)
925+
// with independent baseUrl, credential mode, and (for custom mode) a separate
926+
// stored key. The probe still hits GET /models on the image baseUrl, so once we
927+
// resolve the credentials we hand off to the shared runProviderTest with a
928+
// synthesized ActiveProviderCredentials. chatgpt-codex stays on its OAuth path.
929+
async function handleConnectionV1TestImageProvider(): Promise<ConnectionTestResponse> {
930+
const cfg = getCachedConfig();
931+
const resolved = await resolveImageGenerationTestCredentials(cfg);
932+
if (!resolved.ok) {
933+
return {
934+
ok: false,
935+
code: 'IPC_BAD_INPUT',
936+
message: resolved.message,
937+
hint:
938+
resolved.code === 'IMAGE_GEN_DISABLED'
939+
? 'Configure an image generation provider in Settings → Image generation first.'
940+
: 'Add an API key for the image generation provider in Settings → Image generation, or sign in to ChatGPT.',
941+
};
942+
}
943+
const wire: WireApi =
944+
resolved.provider === 'chatgpt-codex' ? 'openai-codex-responses' : 'openai-chat';
945+
return runProviderTest({
946+
provider: resolved.provider,
947+
wire,
948+
apiKey: resolved.apiKey,
949+
baseUrl: resolved.baseUrl,
950+
builtin: true,
951+
});
952+
}
953+
919954
type ResolvedProviderForListing = { providerId: string; entry: ProviderEntry };
920955

921956
function resolveProviderForListing(

apps/desktop/src/main/image-generation-settings.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
isGenerateImageAssetEnabled,
1414
parseImageGenerationUpdate,
1515
resolveImageGenerationConfig,
16+
resolveImageGenerationTestCredentials,
1617
updateImageGenerationSettings,
1718
} from './image-generation-settings';
1819

@@ -406,3 +407,89 @@ describe('image generation enablement', () => {
406407
});
407408
});
408409
});
410+
411+
describe('resolveImageGenerationTestCredentials', () => {
412+
afterEach(() => {
413+
mocks.cachedConfig = null;
414+
getApiKeyForProviderMock.mockReset();
415+
mocks.codexGetValidAccessToken.mockReset();
416+
});
417+
418+
it('returns IMAGE_GEN_DISABLED when no imageGeneration config exists', async () => {
419+
const result = await resolveImageGenerationTestCredentials(null);
420+
expect(result).toMatchObject({ ok: false, code: 'IMAGE_GEN_DISABLED' });
421+
});
422+
423+
it('resolves inherited credentials even when image generation is disabled', async () => {
424+
getApiKeyForProviderMock.mockReturnValue('sk-openai');
425+
const cfg = makeConfig(false);
426+
const result = await resolveImageGenerationTestCredentials(cfg);
427+
expect(result).toMatchObject({
428+
ok: true,
429+
provider: 'openai',
430+
apiKey: 'sk-openai',
431+
baseUrl: 'https://api.openai.com/v1',
432+
});
433+
});
434+
435+
it('returns PROVIDER_KEY_MISSING when inherited credential cannot be read', async () => {
436+
getApiKeyForProviderMock.mockImplementation(() => {
437+
throw new CodesignError('missing key', ERROR_CODES.PROVIDER_KEY_MISSING);
438+
});
439+
const cfg = makeConfig(true);
440+
const result = await resolveImageGenerationTestCredentials(cfg);
441+
expect(result).toMatchObject({ ok: false, code: 'PROVIDER_KEY_MISSING' });
442+
});
443+
444+
it('decrypts custom-mode key when one is stored', async () => {
445+
const baseCfg = makeConfig(true);
446+
const cfg = hydrateConfig({
447+
version: 3,
448+
activeProvider: baseCfg.activeProvider,
449+
activeModel: baseCfg.activeModel,
450+
providers: baseCfg.providers,
451+
secrets: baseCfg.secrets,
452+
imageGeneration: {
453+
schemaVersion: IMAGE_GENERATION_SCHEMA_VERSION,
454+
enabled: true,
455+
provider: 'openai',
456+
credentialMode: 'custom',
457+
model: 'gpt-image-2',
458+
quality: 'high',
459+
size: '1536x1024',
460+
outputFormat: 'png',
461+
apiKey: { ciphertext: 'sk-custom-image', mask: 'sk-…age' },
462+
},
463+
});
464+
const result = await resolveImageGenerationTestCredentials(cfg);
465+
expect(result).toMatchObject({ ok: true, provider: 'openai', apiKey: 'sk-custom-image' });
466+
});
467+
468+
it('uses ChatGPT OAuth access token for chatgpt-codex provider', async () => {
469+
mocks.codexGetValidAccessToken.mockResolvedValue('codex-token-abc');
470+
const baseCfg = makeConfig(true);
471+
const cfg = hydrateConfig({
472+
version: 3,
473+
activeProvider: baseCfg.activeProvider,
474+
activeModel: baseCfg.activeModel,
475+
providers: baseCfg.providers,
476+
secrets: baseCfg.secrets,
477+
imageGeneration: {
478+
schemaVersion: IMAGE_GENERATION_SCHEMA_VERSION,
479+
enabled: true,
480+
provider: 'chatgpt-codex',
481+
credentialMode: 'inherit',
482+
model: 'gpt-5.5',
483+
quality: 'high',
484+
size: '1536x1024',
485+
outputFormat: 'png',
486+
},
487+
});
488+
const result = await resolveImageGenerationTestCredentials(cfg);
489+
expect(result).toMatchObject({
490+
ok: true,
491+
provider: 'chatgpt-codex',
492+
apiKey: 'codex-token-abc',
493+
});
494+
});
495+
});

apps/desktop/src/main/image-generation-settings.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,71 @@ export async function resolveImageGenerationConfig(
246246
};
247247
}
248248

249+
export type ResolvedImageGenerationTestCredentials =
250+
| {
251+
ok: true;
252+
provider: ImageGenerationProvider;
253+
apiKey: string;
254+
baseUrl: string;
255+
}
256+
| { ok: false; code: 'IMAGE_GEN_DISABLED' | 'PROVIDER_KEY_MISSING'; message: string };
257+
258+
export async function resolveImageGenerationTestCredentials(
259+
cfg: Config | null,
260+
): Promise<ResolvedImageGenerationTestCredentials> {
261+
if (cfg === null || cfg.imageGeneration === undefined) {
262+
return {
263+
ok: false,
264+
code: 'IMAGE_GEN_DISABLED',
265+
message: 'Image generation is not configured.',
266+
};
267+
}
268+
const parsed = ImageGenerationSettingsSchema.parse(cfg.imageGeneration);
269+
const baseUrl = parsed.baseUrl ?? defaultImageBaseUrl(parsed.provider);
270+
271+
if (parsed.provider === CHATGPT_CODEX_PROVIDER_ID) {
272+
let apiKey: string;
273+
try {
274+
apiKey = await getCodexTokenStore().getValidAccessToken();
275+
} catch (err) {
276+
return {
277+
ok: false,
278+
code: 'PROVIDER_KEY_MISSING',
279+
message: err instanceof Error ? err.message : String(err),
280+
};
281+
}
282+
return { ok: true, provider: parsed.provider, apiKey, baseUrl };
283+
}
284+
285+
if (parsed.credentialMode === 'custom') {
286+
if (parsed.apiKey === undefined) {
287+
return {
288+
ok: false,
289+
code: 'PROVIDER_KEY_MISSING',
290+
message: `No custom image API key stored for "${parsed.provider}".`,
291+
};
292+
}
293+
return {
294+
ok: true,
295+
provider: parsed.provider,
296+
apiKey: decryptSecret(parsed.apiKey.ciphertext),
297+
baseUrl,
298+
};
299+
}
300+
301+
let apiKey: string;
302+
try {
303+
apiKey = getApiKeyForProvider(parsed.provider);
304+
} catch (err) {
305+
return {
306+
ok: false,
307+
code: 'PROVIDER_KEY_MISSING',
308+
message: err instanceof Error ? err.message : String(err),
309+
};
310+
}
311+
return { ok: true, provider: parsed.provider, apiKey, baseUrl };
312+
}
313+
249314
export async function isGenerateImageAssetEnabled(cfg: Config): Promise<boolean> {
250315
return (await resolveImageGenerationConfig(cfg)) !== null;
251316
}

apps/desktop/src/preload/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,10 @@ const api = {
618618
ipcRenderer.invoke('connection:v1:test-provider', providerId) as Promise<
619619
ConnectionTestResult | ConnectionTestError
620620
>,
621+
testImageProvider: () =>
622+
ipcRenderer.invoke('connection:v1:test-image-provider') as Promise<
623+
ConnectionTestResult | ConnectionTestError
624+
>,
621625
},
622626
models: {
623627
list: (input: { provider: SupportedOnboardingProvider; apiKey: string; baseUrl: string }) =>

apps/desktop/src/renderer/src/components/settings/ImageGenerationTab.tsx

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ function defaultImageBaseUrlFor(provider: ImageGenerationSettingsView['provider'
2020
function ImageGenerationPanel() {
2121
const t = useT();
2222
const pushToast = useCodesignStore((s) => s.pushToast);
23+
const reportableErrorToast = useCodesignStore((s) => s.reportableErrorToast);
2324
const [settings, setSettings] = useState<ImageGenerationSettingsView | null>(null);
2425
const [saving, setSaving] = useState(false);
26+
const [testing, setTesting] = useState(false);
2527
const [apiKey, setApiKey] = useState('');
2628
const [model, setModel] = useState('');
2729
const [baseUrl, setBaseUrl] = useState('');
@@ -46,6 +48,42 @@ function ImageGenerationPanel() {
4648
});
4749
}, [pushToast, t]);
4850

51+
async function handleTestConnection() {
52+
if (!window.codesign?.connection) return;
53+
setTesting(true);
54+
try {
55+
const res = await window.codesign.connection.testImageProvider();
56+
if (res.ok) {
57+
pushToast({
58+
variant: 'success',
59+
title: t('settings.imageGen.toast.connectionOk', {
60+
defaultValue: 'Image provider connection OK',
61+
}),
62+
});
63+
} else {
64+
reportableErrorToast({
65+
code: 'CONNECTION_TEST_FAILED',
66+
scope: 'settings',
67+
title: t('settings.imageGen.toast.connectionFailed', {
68+
defaultValue: 'Image provider connection failed',
69+
}),
70+
description: res.hint || res.message,
71+
});
72+
}
73+
} catch (err) {
74+
reportableErrorToast({
75+
code: 'CONNECTION_TEST_FAILED',
76+
scope: 'settings',
77+
title: t('settings.imageGen.toast.connectionFailed', {
78+
defaultValue: 'Image provider connection failed',
79+
}),
80+
description: err instanceof Error ? err.message : t('settings.common.unknownError'),
81+
});
82+
} finally {
83+
setTesting(false);
84+
}
85+
}
86+
4987
async function save(patch: Partial<ImageGenerationSettingsView> & { apiKey?: string }) {
5088
if (!window.codesign?.imageGeneration) return;
5189
setSaving(true);
@@ -252,7 +290,17 @@ function ImageGenerationPanel() {
252290
</Row>
253291
</div>
254292

255-
<div className="flex justify-end pt-[var(--space-1)] border-t border-[var(--color-border-muted)]">
293+
<div className="flex justify-end gap-[var(--space-2)] pt-[var(--space-1)] border-t border-[var(--color-border-muted)]">
294+
<button
295+
type="button"
296+
disabled={saving || testing || !keyAvailable}
297+
onClick={() => void handleTestConnection()}
298+
className="h-8 px-3 rounded-[var(--radius-md)] border border-[var(--color-border)] text-[var(--text-sm)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
299+
>
300+
{testing
301+
? t('settings.imageGen.testing', { defaultValue: 'Testing…' })
302+
: t('settings.imageGen.testConnection', { defaultValue: 'Test connection' })}
303+
</button>
256304
<button
257305
type="button"
258306
disabled={

packages/i18n/src/locales/en.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -342,10 +342,14 @@
342342
"needsChatgptLogin": "Needs ChatGPT sign-in",
343343
"disabled": "Disabled"
344344
},
345+
"testConnection": "Test connection",
346+
"testing": "Testing…",
345347
"toast": {
346348
"loadFailed": "Failed to load image generation settings",
347349
"saved": "Image generation settings saved",
348-
"saveFailed": "Failed to save image generation settings"
350+
"saveFailed": "Failed to save image generation settings",
351+
"connectionOk": "Image provider connection OK",
352+
"connectionFailed": "Image provider connection failed"
349353
}
350354
},
351355
"memory": {

packages/i18n/src/locales/es.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,10 +313,14 @@
313313
"needsChatgptLogin": "Necesita iniciar sesión en ChatGPT",
314314
"disabled": "Deshabilitado"
315315
},
316+
"testConnection": "Probar conexión",
317+
"testing": "Probando…",
316318
"toast": {
317319
"loadFailed": "Error al cargar la configuración de generación de imágenes",
318320
"saved": "Configuración de generación de imágenes guardada",
319-
"saveFailed": "Error al guardar la configuración de generación de imágenes"
321+
"saveFailed": "Error al guardar la configuración de generación de imágenes",
322+
"connectionOk": "Conexión con el proveedor de imágenes OK",
323+
"connectionFailed": "Falló la conexión con el proveedor de imágenes"
320324
}
321325
},
322326
"shell": {

packages/i18n/src/locales/zh-CN.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -342,10 +342,14 @@
342342
"needsChatgptLogin": "需要登录 ChatGPT",
343343
"disabled": "未启用"
344344
},
345+
"testConnection": "测试连接",
346+
"testing": "测试中…",
345347
"toast": {
346348
"loadFailed": "加载图像生成设置失败",
347349
"saved": "图像生成设置已保存",
348-
"saveFailed": "保存图像生成设置失败"
350+
"saveFailed": "保存图像生成设置失败",
351+
"connectionOk": "图像服务连接正常",
352+
"connectionFailed": "图像服务连接失败"
349353
}
350354
},
351355
"memory": {

0 commit comments

Comments
 (0)