Skip to content

Commit 89f71ad

Browse files
fix: allow ChatGPT OAuth for image generation
Allow image generation to use the signed-in ChatGPT OAuth path while keeping API-key based OpenAI/OpenRouter flows unchanged.
1 parent 909dd1b commit 89f71ad

12 files changed

Lines changed: 594 additions & 44 deletions

File tree

.changeset/chatgpt-image-oauth.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@open-codesign/providers": patch
3+
"@open-codesign/desktop": patch
4+
"@open-codesign/shared": patch
5+
"@open-codesign/i18n": patch
6+
---
7+
8+
Allow image generation to use the signed-in ChatGPT subscription OAuth path.
9+
10+
The image asset provider list now includes ChatGPT subscription alongside
11+
OpenAI API and OpenRouter. When selected, `generate_image_asset` calls the
12+
ChatGPT Codex Responses backend with the stored OAuth bearer token instead of
13+
requiring an OpenAI API key.

apps/desktop/src/main/codex-oauth-ipc.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ async function persistProviderMutation(
133133
secrets: cfg?.secrets ?? {},
134134
providers: nextProviders,
135135
...(cfg?.designSystem !== undefined ? { designSystem: cfg.designSystem } : {}),
136+
...(cfg?.imageGeneration !== undefined ? { imageGeneration: cfg.imageGeneration } : {}),
136137
});
137138
await writeConfig(next);
138139
setCachedConfig(next);
@@ -155,6 +156,7 @@ async function claimActiveProviderIfUnset(): Promise<void> {
155156
secrets: cfg.secrets,
156157
providers: cfg.providers,
157158
...(cfg.designSystem !== undefined ? { designSystem: cfg.designSystem } : {}),
159+
...(cfg.imageGeneration !== undefined ? { imageGeneration: cfg.imageGeneration } : {}),
158160
});
159161
await writeConfig(next);
160162
setCachedConfig(next);
@@ -268,6 +270,7 @@ async function runLogout(): Promise<CodexOAuthStatus> {
268270
secrets: cfg.secrets,
269271
providers: nextProviders,
270272
...(cfg.designSystem !== undefined ? { designSystem: cfg.designSystem } : {}),
273+
...(cfg.imageGeneration !== undefined ? { imageGeneration: cfg.imageGeneration } : {}),
271274
});
272275
await writeConfig(next);
273276
setCachedConfig(next);
@@ -325,6 +328,7 @@ export async function migrateStaleCodexEntryIfNeeded(): Promise<void> {
325328
secrets: cfg.secrets,
326329
providers: nextProviders,
327330
...(cfg.designSystem !== undefined ? { designSystem: cfg.designSystem } : {}),
331+
...(cfg.imageGeneration !== undefined ? { imageGeneration: cfg.imageGeneration } : {}),
328332
});
329333
await writeConfig(next);
330334
setCachedConfig(next);

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

Lines changed: 157 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import {
1919
const mocks = vi.hoisted(() => ({
2020
cachedConfig: null as Config | null,
2121
getApiKeyForProvider: vi.fn<(provider: string) => string>(),
22+
codexRead: vi.fn<() => Promise<unknown>>(),
23+
codexGetValidAccessToken: vi.fn<() => Promise<string>>(),
2224
setCachedConfig: vi.fn<(config: Config) => void>(),
2325
writeConfig: vi.fn<(config: Config) => Promise<void>>(),
2426
}));
@@ -38,6 +40,13 @@ vi.mock('./onboarding-ipc', () => ({
3840
},
3941
}));
4042

43+
vi.mock('./codex-oauth-ipc', () => ({
44+
getCodexTokenStore: () => ({
45+
read: mocks.codexRead,
46+
getValidAccessToken: mocks.codexGetValidAccessToken,
47+
}),
48+
}));
49+
4150
vi.mock('./keychain', () => ({
4251
buildSecretRef: (value: string) => ({ ciphertext: value, mask: '***' }),
4352
decryptSecret: (value: string) => value,
@@ -92,41 +101,53 @@ function expectThrowCode(fn: () => unknown, code: string): void {
92101
throw new Error(`Expected function to throw ${code}`);
93102
}
94103

104+
async function expectRejectCode(promise: Promise<unknown>, code: string): Promise<void> {
105+
try {
106+
await promise;
107+
} catch (err) {
108+
expect(err).toMatchObject({ code });
109+
return;
110+
}
111+
throw new Error(`Expected promise to reject ${code}`);
112+
}
113+
95114
describe('image generation enablement', () => {
96115
afterEach(() => {
97116
mocks.cachedConfig = null;
98117
getApiKeyForProviderMock.mockReset();
118+
mocks.codexRead.mockReset();
119+
mocks.codexGetValidAccessToken.mockReset();
99120
mocks.setCachedConfig.mockReset();
100121
mocks.writeConfig.mockReset();
101122
});
102123

103-
it('disables generate_image_asset when image generation is turned off', () => {
124+
it('disables generate_image_asset when image generation is turned off', async () => {
104125
const cfg = makeConfig(false);
105-
expect(isGenerateImageAssetEnabled(cfg)).toBe(false);
106-
expect(resolveImageGenerationConfig(cfg)).toBeNull();
126+
await expect(isGenerateImageAssetEnabled(cfg)).resolves.toBe(false);
127+
await expect(resolveImageGenerationConfig(cfg)).resolves.toBeNull();
107128
});
108129

109-
it('enables generate_image_asset when image generation is on and key is available', () => {
130+
it('enables generate_image_asset when image generation is on and key is available', async () => {
110131
getApiKeyForProviderMock.mockReturnValue('sk-openai');
111132
const cfg = makeConfig(true);
112-
expect(isGenerateImageAssetEnabled(cfg)).toBe(true);
113-
expect(resolveImageGenerationConfig(cfg)).toMatchObject({
133+
await expect(isGenerateImageAssetEnabled(cfg)).resolves.toBe(true);
134+
await expect(resolveImageGenerationConfig(cfg)).resolves.toMatchObject({
114135
provider: 'openai',
115136
model: 'gpt-image-2',
116137
apiKey: 'sk-openai',
117138
});
118139
});
119140

120-
it('throws when image generation is on but inherited key is unavailable', () => {
141+
it('throws when image generation is on but inherited key is unavailable', async () => {
121142
getApiKeyForProviderMock.mockImplementation(() => {
122143
throw new CodesignError('missing key', ERROR_CODES.PROVIDER_KEY_MISSING);
123144
});
124145
const cfg = makeConfig(true);
125-
expect(() => isGenerateImageAssetEnabled(cfg)).toThrow(/missing key/);
126-
expect(() => resolveImageGenerationConfig(cfg)).toThrow(/missing key/);
146+
await expect(isGenerateImageAssetEnabled(cfg)).rejects.toThrow(/missing key/);
147+
await expect(resolveImageGenerationConfig(cfg)).rejects.toThrow(/missing key/);
127148
});
128149

129-
it('throws PROVIDER_KEY_MISSING when custom credential mode has no custom key', () => {
150+
it('throws PROVIDER_KEY_MISSING when custom credential mode has no custom key', async () => {
130151
const cfg = makeConfig(true);
131152
const parsed = hydrateConfig({
132153
version: 3,
@@ -146,38 +167,155 @@ describe('image generation enablement', () => {
146167
},
147168
});
148169

149-
expectThrowCode(() => resolveImageGenerationConfig(parsed), ERROR_CODES.PROVIDER_KEY_MISSING);
170+
await expectRejectCode(resolveImageGenerationConfig(parsed), ERROR_CODES.PROVIDER_KEY_MISSING);
150171
});
151172

152-
it('reports inheritedKeyAvailable=false in the view when the provider key is missing', () => {
173+
it('reports inheritedKeyAvailable=false in the view when the provider key is missing', async () => {
153174
getApiKeyForProviderMock.mockImplementation(() => {
154175
throw new CodesignError('missing key', ERROR_CODES.PROVIDER_KEY_MISSING);
155176
});
156177
const cfg = makeConfig(true);
157-
const view = imageSettingsToView(cfg.imageGeneration);
178+
const view = await imageSettingsToView(cfg.imageGeneration);
158179
expect(view.enabled).toBe(true);
159180
expect(view.credentialMode).toBe('inherit');
160181
expect(view.inheritedKeyAvailable).toBe(false);
161182
expect(view.hasCustomKey).toBe(false);
162183
});
163184

164-
it('reports inheritedKeyAvailable=true in the view when the provider key exists', () => {
185+
it('reports inheritedKeyAvailable=true in the view when the provider key exists', async () => {
165186
getApiKeyForProviderMock.mockReturnValue('sk-openai');
166187
const cfg = makeConfig(true);
167-
const view = imageSettingsToView(cfg.imageGeneration);
188+
const view = await imageSettingsToView(cfg.imageGeneration);
168189
expect(view.inheritedKeyAvailable).toBe(true);
169190
});
170191

171-
it('throws credential corruption instead of reporting it as a missing inherited key', () => {
192+
it('throws credential corruption instead of reporting it as a missing inherited key', async () => {
172193
getApiKeyForProviderMock.mockImplementation(() => {
173194
throw new CodesignError('decrypt failed', ERROR_CODES.KEYCHAIN_UNAVAILABLE);
174195
});
175196
const cfg = makeConfig(true);
176-
expectThrowCode(
177-
() => imageSettingsToView(cfg.imageGeneration),
197+
await expectRejectCode(
198+
imageSettingsToView(cfg.imageGeneration),
178199
ERROR_CODES.KEYCHAIN_UNAVAILABLE,
179200
);
180-
expectThrowCode(() => imageGenerationKeyAvailable(cfg), ERROR_CODES.KEYCHAIN_UNAVAILABLE);
201+
await expectRejectCode(imageGenerationKeyAvailable(cfg), ERROR_CODES.KEYCHAIN_UNAVAILABLE);
202+
});
203+
204+
it('resolves ChatGPT subscription image generation through the OAuth token store', async () => {
205+
mocks.codexGetValidAccessToken.mockResolvedValue('oauth-token');
206+
const cfg = hydrateConfig({
207+
version: 3,
208+
activeProvider: 'chatgpt-codex',
209+
activeModel: 'gpt-5.5',
210+
providers: {
211+
'chatgpt-codex': {
212+
id: 'chatgpt-codex',
213+
name: 'ChatGPT subscription',
214+
builtin: false,
215+
wire: 'openai-codex-responses',
216+
baseUrl: 'https://chatgpt.com/backend-api',
217+
defaultModel: 'gpt-5.5',
218+
requiresApiKey: false,
219+
},
220+
},
221+
secrets: {},
222+
imageGeneration: {
223+
schemaVersion: IMAGE_GENERATION_SCHEMA_VERSION,
224+
enabled: true,
225+
provider: 'chatgpt-codex',
226+
credentialMode: 'inherit',
227+
model: 'gpt-5.5',
228+
quality: 'high',
229+
size: '1536x1024',
230+
outputFormat: 'png',
231+
},
232+
});
233+
234+
await expect(resolveImageGenerationConfig(cfg)).resolves.toMatchObject({
235+
provider: 'chatgpt-codex',
236+
model: 'gpt-5.5',
237+
apiKey: 'oauth-token',
238+
baseUrl: 'https://chatgpt.com/backend-api',
239+
});
240+
expect(getApiKeyForProviderMock).not.toHaveBeenCalled();
241+
});
242+
243+
it('reports ChatGPT subscription inherited credential availability from OAuth status', async () => {
244+
mocks.codexRead.mockResolvedValue({
245+
schemaVersion: 1,
246+
accessToken: 'token',
247+
refreshToken: 'refresh',
248+
idToken: 'id',
249+
expiresAt: Date.now() + 1000,
250+
accountId: 'acct',
251+
email: 'person@example.com',
252+
updatedAt: Date.now(),
253+
});
254+
const view = await imageSettingsToView({
255+
schemaVersion: IMAGE_GENERATION_SCHEMA_VERSION,
256+
enabled: true,
257+
provider: 'chatgpt-codex',
258+
credentialMode: 'inherit',
259+
model: 'gpt-5.5',
260+
quality: 'high',
261+
size: '1536x1024',
262+
outputFormat: 'png',
263+
});
264+
265+
expect(view.inheritedKeyAvailable).toBe(true);
266+
});
267+
268+
it('reports ChatGPT subscription key availability from imageGenerationKeyAvailable', async () => {
269+
const cfg = hydrateConfig({
270+
version: 3,
271+
activeProvider: 'openai',
272+
activeModel: 'gpt-5.4',
273+
providers: {
274+
openai: {
275+
id: 'openai',
276+
name: 'OpenAI',
277+
builtin: true,
278+
wire: 'openai-chat',
279+
baseUrl: 'https://api.openai.com/v1',
280+
defaultModel: 'gpt-5.4',
281+
},
282+
'chatgpt-codex': {
283+
id: 'chatgpt-codex',
284+
name: 'ChatGPT subscription',
285+
builtin: false,
286+
wire: 'openai-codex-responses',
287+
baseUrl: 'https://chatgpt.com/backend-api',
288+
defaultModel: 'gpt-5.5',
289+
requiresApiKey: false,
290+
},
291+
},
292+
secrets: {},
293+
imageGeneration: {
294+
schemaVersion: IMAGE_GENERATION_SCHEMA_VERSION,
295+
enabled: true,
296+
provider: 'chatgpt-codex',
297+
credentialMode: 'inherit',
298+
model: 'gpt-5.5',
299+
quality: 'high',
300+
size: '1536x1024',
301+
outputFormat: 'png',
302+
},
303+
});
304+
305+
mocks.codexRead.mockResolvedValue({
306+
schemaVersion: 1,
307+
accessToken: 'token',
308+
refreshToken: 'refresh',
309+
idToken: 'id',
310+
expiresAt: Date.now() + 1000,
311+
accountId: 'acct',
312+
email: 'person@example.com',
313+
updatedAt: Date.now(),
314+
});
315+
await expect(imageGenerationKeyAvailable(cfg)).resolves.toBe(true);
316+
317+
mocks.codexRead.mockResolvedValue(null);
318+
await expect(imageGenerationKeyAvailable(cfg)).resolves.toBe(false);
181319
});
182320

183321
it('clears provider-scoped custom keys when the image provider changes', async () => {

0 commit comments

Comments
 (0)