Skip to content

Commit 1cdf006

Browse files
fix(desktop): support imported codex keyless providers
1 parent bed4458 commit 1cdf006

11 files changed

Lines changed: 177 additions & 50 deletions

File tree

apps/desktop/src/main/imports/codex-config.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,25 @@ wire_api = "responses"
4343
const out = await parseCodexConfig(toml);
4444
expect(out.providers[0]?.wire).toBe('openai-responses');
4545
expect(out.providers[0]?.queryParams?.['api-version']).toBe('2025-04-01-preview');
46+
expect(out.providers[0]?.defaultModel).toBe('gpt-4o');
47+
});
48+
49+
it('uses provider-local model when a non-active provider declares one', async () => {
50+
const toml = `
51+
model = "deepseek-chat"
52+
model_provider = "deepseek"
53+
54+
[model_providers.deepseek]
55+
base_url = "https://api.deepseek.com/v1"
56+
57+
[model_providers.local_proxy]
58+
base_url = "https://proxy.example.test/v1"
59+
model = "qwen3-coder"
60+
`;
61+
const out = await parseCodexConfig(toml);
62+
expect(out.providers.find((p) => p.id === 'codex-local_proxy')?.defaultModel).toBe(
63+
'qwen3-coder',
64+
);
4665
});
4766

4867
it('skips provider blocks missing base_url with a warning', async () => {

apps/desktop/src/main/imports/codex-config.ts

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,14 @@ type CodexProviderBlock = {
2323
name?: string;
2424
base_url?: string;
2525
env_key?: string;
26+
model?: string;
2627
wire_api?: string;
2728
http_headers?: Record<string, string>;
2829
query_params?: Record<string, string>;
2930
};
3031

32+
const FALLBACK_IMPORTED_MODEL = 'gpt-4o';
33+
3134
/**
3235
* Parse a Codex `config.toml` string and translate each `[model_providers.X]`
3336
* block into a v3 `ProviderEntry`. Unknown keys are silently ignored (parse
@@ -62,6 +65,14 @@ export async function parseCodexConfig(toml: string): Promise<CodexImport> {
6265

6366
const root = parsed as Record<string, unknown>;
6467
const modelProviders = root['model_providers'];
68+
const activeProviderRaw = root['model_provider'];
69+
const activeModelRaw = root['model'];
70+
const activeProviderId =
71+
typeof activeProviderRaw === 'string' && activeProviderRaw.length > 0
72+
? `codex-${activeProviderRaw}`
73+
: null;
74+
const activeModel =
75+
typeof activeModelRaw === 'string' && activeModelRaw.length > 0 ? activeModelRaw : null;
6576
const providers: ProviderEntry[] = [];
6677
const envKeyMap: Record<string, string> = {};
6778

@@ -82,13 +93,17 @@ export async function parseCodexConfig(toml: string): Promise<CodexImport> {
8293
: block.wire_api === 'chat'
8394
? 'openai-chat'
8495
: detectWireFromBaseUrl(block.base_url);
96+
const blockModel = typeof block.model === 'string' ? block.model.trim() : '';
97+
const activeModelForProvider =
98+
activeProviderId === `codex-${id}` && activeModel !== null ? activeModel : null;
99+
const defaultModel = (activeModelForProvider ?? blockModel) || FALLBACK_IMPORTED_MODEL;
85100
const entry: ProviderEntry = {
86101
id: `codex-${id}`,
87102
name: 'Codex (imported)',
88103
builtin: false,
89104
wire,
90105
baseUrl: block.base_url,
91-
defaultModel: '', // caller fills in via active model if this provider wins
106+
defaultModel,
92107
};
93108
if (typeof block.env_key === 'string' && block.env_key.length > 0) {
94109
entry.envKey = block.env_key;
@@ -113,18 +128,8 @@ export async function parseCodexConfig(toml: string): Promise<CodexImport> {
113128
}
114129
}
115130

116-
const activeProviderRaw = root['model_provider'];
117-
const activeModelRaw = root['model'];
118-
const activeProviderId =
119-
typeof activeProviderRaw === 'string' && activeProviderRaw.length > 0
120-
? `codex-${activeProviderRaw}`
121-
: null;
122-
const activeModel =
123-
typeof activeModelRaw === 'string' && activeModelRaw.length > 0 ? activeModelRaw : null;
124-
125131
// Backfill defaultModel for the active provider so the UI has something to
126-
// offer by default. Non-active providers keep an empty defaultModel and
127-
// the user picks one on first activation.
132+
// offer by default even if the provider block did not declare a model.
128133
if (activeProviderId !== null && activeModel !== null) {
129134
const entry = providers.find((p) => p.id === activeProviderId);
130135
if (entry !== undefined) entry.defaultModel = activeModel;

apps/desktop/src/main/index.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ import {
4343
} from './onboarding-ipc';
4444
import { readPersisted as readPreferences, registerPreferencesIpc } from './preferences-ipc';
4545
import { preparePromptContext } from './prompt-context';
46-
import { resolveActiveModel } from './provider-settings';
46+
import { isKeylessProviderAllowed, resolveActiveModel } from './provider-settings';
4747
import { safeInitSnapshotsDb } from './snapshots-db';
4848
import { registerSnapshotsIpc, registerSnapshotsUnavailableIpc } from './snapshots-ipc';
4949

@@ -437,6 +437,7 @@ function registerIpcHandlers(): void {
437437
} catch {
438438
apiKey = '';
439439
}
440+
const allowKeyless = isKeylessProviderAllowed(active.model.provider);
440441
// Once we've snapped to the canonical active provider, the renderer-supplied
441442
// baseUrl can no longer be trusted — it may belong to a different (stale)
442443
// provider and would route the active provider's API key to the wrong host.
@@ -468,7 +469,7 @@ function registerIpcHandlers(): void {
468469
modelId: active.model.modelId,
469470
};
470471
coreLogger.info('[generate] step=validate_provider', stepCtx);
471-
if (apiKey.length === 0) {
472+
if (apiKey.length === 0 && !allowKeyless) {
472473
coreLogger.error('[generate] step=validate_provider.fail', {
473474
provider: active.model.provider,
474475
reason: 'missing_api_key',
@@ -518,6 +519,7 @@ function registerIpcHandlers(): void {
518519
...(baseUrl !== undefined ? { baseUrl } : {}),
519520
wire: active.wire,
520521
...(active.httpHeaders !== undefined ? { httpHeaders: active.httpHeaders } : {}),
522+
...(allowKeyless ? { allowKeyless: true } : {}),
521523
signal: controller.signal,
522524
logger: coreLogger,
523525
},
@@ -575,6 +577,7 @@ function registerIpcHandlers(): void {
575577
} catch {
576578
apiKey = '';
577579
}
580+
const allowKeyless = isKeylessProviderAllowed(active.model.provider);
578581
// See codesign:v1:generate above — renderer baseUrl is ignored post-snap.
579582
const baseUrl = active.baseUrl ?? undefined;
580583
if (active.overridden) {
@@ -617,6 +620,7 @@ function registerIpcHandlers(): void {
617620
...(baseUrl !== undefined ? { baseUrl } : {}),
618621
wire: active.wire,
619622
...(active.httpHeaders !== undefined ? { httpHeaders: active.httpHeaders } : {}),
623+
...(allowKeyless ? { allowKeyless: true } : {}),
620624
signal: controller.signal,
621625
},
622626
id,
@@ -672,6 +676,7 @@ function registerIpcHandlers(): void {
672676
} catch {
673677
apiKey = '';
674678
}
679+
const allowKeyless = isKeylessProviderAllowed(active.model.provider);
675680
const baseUrl = active.baseUrl ?? undefined;
676681
const promptContext = await preparePromptContext({
677682
attachments: payload.attachments,
@@ -706,6 +711,7 @@ function registerIpcHandlers(): void {
706711
...(baseUrl !== undefined ? { baseUrl } : {}),
707712
wire: active.wire,
708713
...(active.httpHeaders !== undefined ? { httpHeaders: active.httpHeaders } : {}),
714+
...(allowKeyless ? { allowKeyless: true } : {}),
709715
});
710716
logIpc.info('applyComment.ok', {
711717
ms: Date.now() - t0,
@@ -740,7 +746,13 @@ function registerIpcHandlers(): void {
740746
provider: cfg.activeProvider,
741747
modelId: cfg.activeModel,
742748
});
743-
const apiKey = getApiKeyForProvider(active.model.provider);
749+
let apiKey: string;
750+
try {
751+
apiKey = getApiKeyForProvider(active.model.provider);
752+
} catch {
753+
apiKey = '';
754+
}
755+
const allowKeyless = isKeylessProviderAllowed(active.model.provider);
744756
const baseUrl = active.baseUrl ?? undefined;
745757
return generateTitle({
746758
prompt,
@@ -749,6 +761,7 @@ function registerIpcHandlers(): void {
749761
...(baseUrl !== undefined ? { baseUrl } : {}),
750762
wire: active.wire,
751763
...(active.httpHeaders !== undefined ? { httpHeaders: active.httpHeaders } : {}),
764+
...(allowKeyless ? { allowKeyless: true } : {}),
752765
});
753766
});
754767

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

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
assertProviderHasStoredSecret,
2626
computeDeleteProviderResult,
2727
getAddProviderDefaults,
28+
isKeylessProviderAllowed,
2829
toProviderRows,
2930
} from './provider-settings';
3031
import { buildAppPaths } from './storage-settings';
@@ -94,7 +95,7 @@ function toState(cfg: Config | null): OnboardingState {
9495
}
9596
const active = cfg.activeProvider;
9697
const ref = cfg.secrets[active];
97-
if (ref === undefined) {
98+
if (ref === undefined && !isKeylessProviderAllowed(active)) {
9899
return {
99100
hasKey: false,
100101
provider: active,
@@ -617,14 +618,7 @@ async function runImportCodex(imported: CodexImport): Promise<OnboardingState> {
617618
const envValue = process.env[entry.envKey];
618619
if (envValue !== undefined && envValue.length > 0) {
619620
nextSecrets[entry.id] = { ciphertext: encryptSecret(envValue) };
620-
} else if (nextSecrets[entry.id] === undefined) {
621-
// env var not exported — store empty key so the provider is
622-
// usable for keyless endpoints (IP-whitelisted proxies).
623-
nextSecrets[entry.id] = { ciphertext: encryptSecret('') };
624621
}
625-
} else if (nextSecrets[entry.id] === undefined) {
626-
// No env_key at all — likely a keyless proxy.
627-
nextSecrets[entry.id] = { ciphertext: encryptSecret('') };
628622
}
629623
}
630624
const fallbackActive = imported.providers[0];

apps/desktop/src/main/provider-settings.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ function makeCfg(input: {
1313
modelPrimary: string;
1414
secrets?: Record<string, { ciphertext: string }>;
1515
baseUrls?: Record<string, string>;
16+
providers?: Record<string, import('@open-codesign/shared').ProviderEntry>;
1617
}): Config {
1718
const providers: Record<string, import('@open-codesign/shared').ProviderEntry> = {
1819
anthropic: {
@@ -39,6 +40,7 @@ function makeCfg(input: {
3940
baseUrl: input.baseUrls?.['openrouter'] ?? 'https://openrouter.ai/api/v1',
4041
defaultModel: 'anthropic/claude-sonnet-4.6',
4142
},
43+
...(input.providers ?? {}),
4244
};
4345
return hydrateConfig({
4446
version: 3,
@@ -126,6 +128,25 @@ describe('assertProviderHasStoredSecret', () => {
126128

127129
expect(() => assertProviderHasStoredSecret(cfg, 'anthropic')).toThrow(CodesignError);
128130
});
131+
132+
it('allows imported Codex providers without a stored API key', () => {
133+
const cfg = makeCfg({
134+
provider: 'codex-proxy',
135+
modelPrimary: 'gpt-5.3-codex',
136+
providers: {
137+
'codex-proxy': {
138+
id: 'codex-proxy',
139+
name: 'Codex (imported)',
140+
builtin: false,
141+
wire: 'openai-responses',
142+
baseUrl: 'https://proxy.example.com/v1',
143+
defaultModel: 'gpt-5.3-codex',
144+
},
145+
},
146+
});
147+
148+
expect(() => assertProviderHasStoredSecret(cfg, 'codex-proxy')).not.toThrow();
149+
});
129150
});
130151

131152
describe('computeDeleteProviderResult', () => {
@@ -272,4 +293,29 @@ describe('resolveActiveModel', () => {
272293
resolveActiveModel(cfg, { provider: 'anthropic', modelId: 'claude-sonnet-4-6' }),
273294
).toThrowError(CodesignError);
274295
});
296+
297+
it('allows active imported Codex providers without a stored secret', () => {
298+
const cfg = makeCfg({
299+
provider: 'codex-proxy',
300+
modelPrimary: 'gpt-5.3-codex',
301+
providers: {
302+
'codex-proxy': {
303+
id: 'codex-proxy',
304+
name: 'Codex (imported)',
305+
builtin: false,
306+
wire: 'openai-responses',
307+
baseUrl: 'https://proxy.example.com/v1',
308+
defaultModel: 'gpt-5.3-codex',
309+
},
310+
},
311+
});
312+
313+
const result = resolveActiveModel(cfg, {
314+
provider: 'codex-proxy',
315+
modelId: 'gpt-5.3-codex',
316+
});
317+
318+
expect(result.model).toEqual({ provider: 'codex-proxy', modelId: 'gpt-5.3-codex' });
319+
expect(result.baseUrl).toBe('https://proxy.example.com/v1');
320+
});
275321
});

apps/desktop/src/main/provider-settings.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,14 @@ export function getAddProviderDefaults(
5353

5454
export function assertProviderHasStoredSecret(cfg: Config, provider: string): void {
5555
if (cfg.secrets[provider] !== undefined) return;
56+
if (provider.startsWith('codex-')) return;
5657
throw new CodesignError(`No API key stored for provider "${provider}".`, 'PROVIDER_KEY_MISSING');
5758
}
5859

60+
export function isKeylessProviderAllowed(provider: string): boolean {
61+
return provider.startsWith('codex-');
62+
}
63+
5964
function resolveEntryFor(cfg: Config, id: string): ProviderEntry | null {
6065
const stored = cfg.providers[id];
6166
if (stored !== undefined) return stored;
@@ -133,7 +138,9 @@ export interface DeleteProviderResult {
133138
* what the next active provider and model values should be.
134139
*/
135140
export function computeDeleteProviderResult(cfg: Config, toDelete: string): DeleteProviderResult {
136-
const remaining = Object.keys(cfg.secrets).filter((p) => p !== toDelete);
141+
const remaining = Object.keys(cfg.providers).filter(
142+
(p) => p !== toDelete && (cfg.secrets[p] !== undefined || isKeylessProviderAllowed(p)),
143+
);
137144

138145
if (remaining.length === 0) {
139146
return { nextActive: null, modelPrimary: '' };
@@ -175,7 +182,7 @@ export function resolveActiveModel(
175182
hint: { provider: string; modelId: string },
176183
): ActiveModelResolution {
177184
const activeId = cfg.activeProvider;
178-
if (cfg.secrets[activeId] === undefined) {
185+
if (cfg.secrets[activeId] === undefined && !isKeylessProviderAllowed(activeId)) {
179186
throw new CodesignError(
180187
`No API key stored for active provider "${activeId}". Re-run onboarding to add one.`,
181188
'PROVIDER_KEY_MISSING',

apps/desktop/src/renderer/src/store.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import type {
1313
ModelRef,
1414
OnboardingState,
1515
SelectedElement,
16-
SupportedOnboardingProvider,
1716
} from '@open-codesign/shared';
1817
import { create } from 'zustand';
1918
import type { StoreApi } from 'zustand';
@@ -396,7 +395,7 @@ function newId(): string {
396395
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
397396
}
398397

399-
function modelRef(provider: SupportedOnboardingProvider, modelId: string): ModelRef {
398+
function modelRef(provider: string, modelId: string): ModelRef {
400399
return { provider, modelId };
401400
}
402401

@@ -605,7 +604,7 @@ function triggerAutoRenameIfFirst(get: GetState, isFirstPrompt: boolean, prompt:
605604

606605
interface ReadyConfig extends OnboardingState {
607606
hasKey: true;
608-
provider: SupportedOnboardingProvider;
607+
provider: string;
609608
modelPrimary: string;
610609
}
611610

0 commit comments

Comments
 (0)