diff --git a/electron/services/providers/provider-runtime-sync.ts b/electron/services/providers/provider-runtime-sync.ts index fbe3639..364ee5f 100644 --- a/electron/services/providers/provider-runtime-sync.ts +++ b/electron/services/providers/provider-runtime-sync.ts @@ -608,14 +608,10 @@ export async function syncDefaultProviderToRuntime( logger.warn('[provider-runtime] Failed to prune invalid provider entries before switch:', err); } - // Self-heal: pin the embedded agent runtime for any legacy provider entry - // that needs it (currently only `openai`, which would otherwise be auto- - // routed by OpenClaw to the unbundled `codex` harness when configured with - // the official api.openai.com baseUrl). Earlier ClawX builds wrote the - // provider entry without `agentRuntime`, so upgrading users would keep - // hitting `Requested agent harness "codex" is not registered.` until they - // re-saved the provider manually. Running this before every default- - // provider switch repairs the on-disk config on the next save. + // Self-heal: pin the embedded agent runtime for legacy OpenAI provider entries + // (`openai`, `openai-codex`) that would otherwise be auto-routed to the + // unbundled `codex` harness. Running this before every default-provider switch + // repairs on-disk config written by earlier ClawX builds. try { const pinned = await ensureOpenClawProviderAgentRuntimePins(); if (pinned.length > 0) { diff --git a/electron/services/providers/provider-service.ts b/electron/services/providers/provider-service.ts index f1980d3..cc7aeff 100644 --- a/electron/services/providers/provider-service.ts +++ b/electron/services/providers/provider-service.ts @@ -33,7 +33,12 @@ import { getOpenClawProvidersConfig, getProviderApiKeyFromOpenClaw, } from '../../utils/openclaw-auth'; -import { getAliasSourceTypes, getOpenClawProviderKeyForType } from '../../utils/provider-keys'; +import { + filterActiveProviderKeysForUi, + getAliasSourceTypes, + OPENAI_CODEX_RUNTIME_PROVIDER_KEY, + resolveOpenClawProviderKey, +} from '../../utils/provider-keys'; import type { ProviderWithKeyInfo } from '../../shared/providers/types'; import { logger } from '../../utils/logger'; @@ -96,7 +101,7 @@ export class ProviderService { // Index store accounts by their openclaw runtime key for fast lookup. const storeByKey = new Map(); for (const account of allStoreAccounts) { - const ock = getOpenClawProviderKeyForType(account.vendorId, account.id); + const ock = resolveOpenClawProviderKey(account); const group = storeByKey.get(ock) ?? []; group.push(account); storeByKey.set(ock, group); @@ -105,8 +110,27 @@ export class ProviderService { const result: ProviderAccount[] = []; const processedKeys = new Set(); + let hasConfiguredOpenAiApiKey = false; + if (activeProviders.has('openai')) { + for (const account of storeByKey.get('openai') ?? []) { + if (account.authMode === 'oauth_browser') { + continue; + } + const apiKey = await getApiKey(account.id); + const openClawKey = await getProviderApiKeyFromOpenClaw('openai'); + if (apiKey || openClawKey) { + hasConfiguredOpenAiApiKey = true; + break; + } + } + } + + const activeKeysForUi = filterActiveProviderKeysForUi(activeProviders, { + hasConfiguredOpenAiApiKey, + }); + // For each active provider in openclaw.json, produce exactly ONE account. - for (const key of activeProviders) { + for (const key of activeKeysForUi) { if (processedKeys.has(key)) continue; processedKeys.add(key); @@ -150,6 +174,23 @@ export class ProviderService { } } + if (activeProviders.has(OPENAI_CODEX_RUNTIME_PROVIDER_KEY)) { + const openaiStoreAccounts = storeByKey.get('openai') ?? []; + for (const account of openaiStoreAccounts) { + if (account.authMode !== 'api_key' && account.authMode !== undefined) { + continue; + } + const apiKey = await getApiKey(account.id); + const openClawKey = await getProviderApiKeyFromOpenClaw('openai'); + if (!apiKey && !openClawKey) { + logger.info( + `[provider-sync] Removing unconfigured OpenAI API key account "${account.id}" (OAuth uses ${OPENAI_CODEX_RUNTIME_PROVIDER_KEY})`, + ); + await deleteProviderAccount(account.id); + } + } + } + return result; } @@ -374,7 +415,7 @@ export class ProviderService { const accounts = await this.listAccounts(); const results: Array<{ accountId: string; hasKey: boolean; keyMasked: string | null }> = []; for (const account of accounts) { - const runtimeProviderKey = getOpenClawProviderKeyForType(account.vendorId, account.id); + const runtimeProviderKey = resolveOpenClawProviderKey(account); const apiKey = (await getProviderApiKeyFromOpenClaw(runtimeProviderKey)) ?? (await getApiKey(account.id)) ?? (runtimeProviderKey !== account.id ? await getApiKey(runtimeProviderKey) : null); @@ -396,7 +437,7 @@ export class ProviderService { async hasAccountApiKey(accountId: string): Promise { const account = await this.getAccount(accountId); const runtimeProviderKey = account - ? getOpenClawProviderKeyForType(account.vendorId, account.id) + ? resolveOpenClawProviderKey(account) : accountId; if (await getProviderApiKeyFromOpenClaw(runtimeProviderKey)) { return true; diff --git a/electron/utils/browser-oauth.ts b/electron/utils/browser-oauth.ts index 1686c35..de2087e 100644 --- a/electron/utils/browser-oauth.ts +++ b/electron/utils/browser-oauth.ts @@ -4,7 +4,11 @@ import { logger } from './logger'; import { loginOpenAICodexOAuth, type OpenAICodexOAuthCredentials } from './openai-codex-oauth'; import { getProviderService } from '../services/providers/provider-service'; import { getSecretStore } from '../services/secrets/secret-store'; -import { saveOAuthTokenToOpenClaw } from './openclaw-auth'; +import { + ensureOpenClawProviderAgentRuntimePins, + saveOAuthTokenToOpenClaw, + setOpenClawDefaultModel, +} from './openclaw-auth'; // Google was removed: OpenClaw's `google-gemini-cli` OAuth integration is an // unofficial third-party flow that requires the `gemini` CLI binary to be on @@ -188,6 +192,26 @@ class BrowserOAuthManager extends EventEmitter { projectId: oauthTokenSubject, }); + const modelId = normalizedExistingModel || defaultModel; + const modelRef = `${runtimeProviderId}/${modelId}`; + const fallbackModelRefs = (nextAccount.fallbackModels ?? []) + .map((fallback) => fallback.trim()) + .filter(Boolean) + .map((fallback) => ( + fallback.startsWith(`${runtimeProviderId}/`) + ? fallback + : `${runtimeProviderId}/${fallback}` + )); + + try { + await setOpenClawDefaultModel(runtimeProviderId, modelRef, fallbackModelRefs); + await ensureOpenClawProviderAgentRuntimePins(); + logger.info(`[BrowserOAuth] Registered ${runtimeProviderId} in openclaw.json (default model: ${modelRef})`); + } catch (err) { + logger.warn('[BrowserOAuth] Failed to register OpenAI OAuth provider in openclaw.json:', err); + throw err; + } + this.emit('oauth:success', { provider: providerType, accountId: nextAccount.id }); if (this.mainWindow && !this.mainWindow.isDestroyed()) { this.mainWindow.webContents.send('oauth:success', { diff --git a/electron/utils/openai-codex-oauth.ts b/electron/utils/openai-codex-oauth.ts index 119e233..615b41a 100644 --- a/electron/utils/openai-codex-oauth.ts +++ b/electron/utils/openai-codex-oauth.ts @@ -115,16 +115,21 @@ function decodeJwtPayload(token: string): Record | null { function getAccountIdFromAccessToken(accessToken: string): string | null { const payload = decodeJwtPayload(accessToken); const authClaims = payload?.[JWT_CLAIM_PATH]; - if (!authClaims || typeof authClaims !== 'object') { - return null; + if (authClaims && typeof authClaims === 'object') { + const claims = authClaims as Record; + for (const key of ['chatgpt_account_id', 'account_id', 'user_id', 'sub']) { + const value = claims[key]; + if (typeof value === 'string' && value.trim()) { + return value.trim(); + } + } } - const accountId = (authClaims as Record).chatgpt_account_id; - if (typeof accountId !== 'string' || !accountId.trim()) { - return null; + if (typeof payload?.sub === 'string' && payload.sub.trim()) { + return payload.sub.trim(); } - return accountId; + return null; } async function createAuthorizationFlow(): Promise { @@ -181,8 +186,9 @@ function startLocalOAuthServer(state: string): Promise { + // Bind dual-stack loopback so both `localhost` and `127.0.0.1` redirects work. server - .listen(1455, 'localhost', () => { + .listen(1455, () => { resolve({ close: () => server.close(), waitForCode: async () => { @@ -288,10 +294,7 @@ export async function loginOpenAICodexOAuth(options: { } const token = await exchangeAuthorizationCode(code, verifier); - const accountId = getAccountIdFromAccessToken(token.access); - if (!accountId) { - throw new Error('Failed to extract OpenAI accountId from token'); - } + const accountId = getAccountIdFromAccessToken(token.access) ?? 'default'; return { access: token.access, diff --git a/electron/utils/openclaw-auth.ts b/electron/utils/openclaw-auth.ts index c497a71..6d9d693 100644 --- a/electron/utils/openclaw-auth.ts +++ b/electron/utils/openclaw-auth.ts @@ -1161,6 +1161,21 @@ export async function setOpenClawDefaultModel( mergeExistingModels: true, }); console.log(`Configured models.providers.${provider} with baseUrl=${providerCfg.baseUrl}, model=${modelId}`); + } else if (provider === 'openai-codex') { + // OAuth Codex is not in the UI registry but still needs an explicit provider + // entry with a pinned embedded runtime (see OPENCLAW_PROVIDER_PINNED_AGENT_RUNTIME). + upsertOpenClawProviderEntry(config, provider, { + baseUrl: OPENAI_CODEX_OAUTH_PROVIDER_CONFIG.baseUrl, + api: OPENAI_CODEX_OAUTH_PROVIDER_CONFIG.api, + modelIds: [modelId, ...fallbackModelIds], + mergeExistingModels: true, + }); + if (isOpenClawOAuthPluginProviderKey(provider)) { + ensureOAuthPluginEnabled(config, provider); + } + console.log( + `Configured models.providers.${provider} for OAuth (api=${OPENAI_CODEX_OAUTH_PROVIDER_CONFIG.api})`, + ); } else { // Built-in provider: remove any stale models.providers entry const models = (config.models || {}) as Record; @@ -1417,21 +1432,21 @@ export async function ensureAnthropicMessagesModelMaxTokens(): Promise * dispatch the chat to an externally-bundled harness plugin that may not be * installed. * - * Currently only the API-key OpenAI provider needs this: OpenClaw 2026.5+ - * routes any `provider="openai"` entry on the official `api.openai.com` - * baseUrl through the `codex` agent harness (which expects a separate - * `codex-app-server` plugin to be installed). The bundled OpenClaw - * distribution we ship does not register a harness with id `"codex"`, so - * without this pin every API-key OpenAI chat fails with - * `Requested agent harness "codex" is not registered.` — see - * `node_modules/openclaw/dist/policy-AKMwD9k5.js` and - * `node_modules/openclaw/dist/selection-61FIEezO.js`. - * - * The OAuth flow writes to `models.providers.openai-codex` (a different key) - * and intentionally wants the codex routing, so it is not pinned here. + * OpenClaw 2026.5+ auto-routes OpenAI providers (`openai`, `openai-codex`) to the + * external `codex` agent harness, which expects a separate codex plugin install. + * The bundled OpenClaw distribution ClawX ships does not register that harness, + * so without pinning both keys chat fails with + * `Requested agent harness "codex" is not registered.` */ const OPENCLAW_PROVIDER_PINNED_AGENT_RUNTIME: Record = { openai: 'pi', + 'openai-codex': 'pi', +}; + +/** Runtime models.providers entry for OpenAI Codex OAuth accounts. */ +export const OPENAI_CODEX_OAUTH_PROVIDER_CONFIG = { + baseUrl: 'https://api.openai.com/v1', + api: 'openai-codex-responses' as const, }; function applyPinnedAgentRuntime( diff --git a/electron/utils/provider-keys.ts b/electron/utils/provider-keys.ts index ffa6fac..83e9966 100644 --- a/electron/utils/provider-keys.ts +++ b/electron/utils/provider-keys.ts @@ -3,9 +3,11 @@ const MULTI_INSTANCE_PROVIDER_TYPES = new Set(['custom', 'ollama']); export const OPENCLAW_PROVIDER_KEY_MINIMAX = 'minimax-portal'; export const OPENCLAW_PROVIDER_KEY_MOONSHOT = 'moonshot'; export const OPENCLAW_PROVIDER_KEY_MOONSHOT_GLOBAL = 'moonshot-global'; +export const OPENAI_CODEX_RUNTIME_PROVIDER_KEY = 'openai-codex'; export const OAUTH_PROVIDER_TYPES = ['minimax-portal', 'minimax-portal-cn'] as const; export const OPENCLAW_OAUTH_PLUGIN_PROVIDER_KEYS = [ OPENCLAW_PROVIDER_KEY_MINIMAX, + OPENAI_CODEX_RUNTIME_PROVIDER_KEY, ] as const; const OAUTH_PROVIDER_TYPE_SET = new Set(OAUTH_PROVIDER_TYPES); @@ -33,6 +35,21 @@ export function getOpenClawProviderKeyForType(type: string, providerId: string): return PROVIDER_KEY_ALIASES[type] ?? type; } +/** + * Resolve the OpenClaw runtime provider key for a saved account. + * Browser OAuth for OpenAI is stored under vendorId `openai` but runs as `openai-codex`. + */ +export function resolveOpenClawProviderKey(account: { + vendorId: string; + id: string; + authMode?: string; +}): string { + if (account.authMode === 'oauth_browser' && account.vendorId === 'openai') { + return OPENAI_CODEX_RUNTIME_PROVIDER_KEY; + } + return getOpenClawProviderKeyForType(account.vendorId, account.id); +} + /** * Get all vendorId values that map to the given openclaw.json key via alias. * e.g. getAliasSourceTypes('minimax-portal') → ['minimax-portal-cn'] @@ -43,6 +60,26 @@ export function getAliasSourceTypes(openClawKey: string): string[] { .map(([source]) => source); } +/** + * OpenAI Codex OAuth uses runtime key `openai-codex` while API keys use `openai`. + * When only OAuth is configured, hide the redundant bare `openai` active slot so the + * UI does not show a second "OpenAI • API key (missing)" row. + */ +export function filterActiveProviderKeysForUi( + activeKeys: Iterable, + options?: { hasConfiguredOpenAiApiKey?: boolean }, +): string[] { + const keys = Array.from(activeKeys); + const active = new Set(keys); + if (!active.has('openai') || !active.has(OPENAI_CODEX_RUNTIME_PROVIDER_KEY)) { + return keys; + } + if (options?.hasConfiguredOpenAiApiKey) { + return keys; + } + return keys.filter((key) => key !== 'openai'); +} + export function isOAuthProviderType(type: string): boolean { return OAUTH_PROVIDER_TYPE_SET.has(type); } diff --git a/src/lib/providers.ts b/src/lib/providers.ts index b9da364..e87576b 100644 --- a/src/lib/providers.ts +++ b/src/lib/providers.ts @@ -157,7 +157,6 @@ export const PROVIDER_TYPE_INFO: ProviderTypeInfo[] = [ requiresApiKey: true, isOAuth: true, supportsApiKey: true, - hideOAuthUi: true, defaultModelId: 'gpt-5.5', showModelId: true, modelIdPlaceholder: 'gpt-5.5', diff --git a/tests/e2e/provider-lifecycle.spec.ts b/tests/e2e/provider-lifecycle.spec.ts index 1f84365..5869ccc 100644 --- a/tests/e2e/provider-lifecycle.spec.ts +++ b/tests/e2e/provider-lifecycle.spec.ts @@ -63,6 +63,24 @@ test.describe('ClawX provider lifecycle', () => { } }); + test('shows OpenAI OAuth and API key auth mode toggle in add-provider dialog', async ({ page }) => { + await completeSetup(page); + + await page.getByTestId('sidebar-nav-models').click(); + await expect(page.getByTestId('providers-settings')).toBeVisible(); + + await page.getByTestId('providers-add-button').click(); + await expect(page.getByTestId('add-provider-dialog')).toBeVisible(); + + await page.getByTestId('add-provider-type-openai').click(); + await expect(page.getByRole('button', { name: 'OAuth Login' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'API Key' })).toBeVisible(); + + await page.getByRole('button', { name: 'OAuth Login' }).click(); + await expect(page.getByRole('button', { name: 'Login with Browser' })).toBeVisible(); + await expect(page.getByTestId('add-provider-api-key-input')).toHaveCount(0); + }); + test('trims whitespace before validating and saving a custom provider key', async ({ electronApp, page }) => { await completeSetup(page); diff --git a/tests/unit/openclaw-auth.test.ts b/tests/unit/openclaw-auth.test.ts index 17f63b2..a4f454f 100644 --- a/tests/unit/openclaw-auth.test.ts +++ b/tests/unit/openclaw-auth.test.ts @@ -1280,6 +1280,10 @@ describe('assertValidApiProtocol guard at write sites', () => { await rm(testUserData, { recursive: true, force: true }); }); + afterEach(() => { + vi.doUnmock('@electron/utils/provider-registry'); + }); + it('setOpenClawDefaultModel throws InvalidApiProtocolError and leaves openclaw.json untouched when registry api is invalid', async () => { const initialConfig = { agents: { @@ -1631,7 +1635,7 @@ describe('openai agentRuntime pin', () => { expect(openai.baseUrl).toBe('https://api.openai.com/v1'); }); - it('does not pin agentRuntime for the OAuth openai-codex provider entry', async () => { + it('pins agentRuntime to the embedded "pi" runtime for the OAuth openai-codex provider entry', async () => { await writeOpenClawJson({ models: { providers: {} }, }); @@ -1648,7 +1652,8 @@ describe('openai agentRuntime pin', () => { const codex = providers['openai-codex'] as Record; expect(codex).toBeDefined(); - expect(codex.agentRuntime).toBeUndefined(); + expect(codex.agentRuntime).toEqual({ id: 'pi' }); + expect(codex.api).toBe('openai-codex-responses'); }); it('preserves a user-provided agentRuntime override on the openai entry', async () => { @@ -1682,6 +1687,34 @@ describe('openai agentRuntime pin', () => { }); }); +describe('setOpenClawDefaultModel for openai-codex OAuth', () => { + beforeEach(async () => { + vi.doUnmock('@electron/utils/provider-registry'); + vi.resetModules(); + vi.restoreAllMocks(); + await rm(testHome, { recursive: true, force: true }); + await rm(testUserData, { recursive: true, force: true }); + }); + + it('writes models.providers.openai-codex with a pinned pi runtime', async () => { + await writeOpenClawJson({ + models: { providers: {} }, + }); + + const { setOpenClawDefaultModel } = await import('@electron/utils/openclaw-auth'); + await setOpenClawDefaultModel('openai-codex', 'openai-codex/gpt-5.5'); + + const result = await readOpenClawJson(); + const providers = (result.models as Record).providers as Record; + const codex = providers['openai-codex'] as Record; + const defaults = ((result.agents as Record).defaults as Record).model as Record; + + expect(defaults.primary).toBe('openai-codex/gpt-5.5'); + expect(codex.agentRuntime).toEqual({ id: 'pi' }); + expect(codex.api).toBe('openai-codex-responses'); + }); +}); + describe('ensureOpenClawProviderAgentRuntimePins', () => { beforeEach(async () => { vi.resetModules(); @@ -1715,6 +1748,29 @@ describe('ensureOpenClawProviderAgentRuntimePins', () => { expect(openai.api).toBe('openai-responses'); }); + it('pins agentRuntime:{id:"pi"} on legacy openai-codex OAuth entries that lack it', async () => { + await writeOpenClawJson({ + models: { + providers: { + 'openai-codex': { + baseUrl: 'https://api.openai.com/v1', + api: 'openai-codex-responses', + models: [{ id: 'gpt-5.5', name: 'gpt-5.5' }], + }, + }, + }, + }); + + const { ensureOpenClawProviderAgentRuntimePins } = await import('@electron/utils/openclaw-auth'); + const pinned = await ensureOpenClawProviderAgentRuntimePins(); + + expect(pinned).toEqual(['openai-codex']); + const result = await readOpenClawJson(); + const providers = (result.models as Record).providers as Record; + const codex = providers['openai-codex'] as Record; + expect(codex.agentRuntime).toEqual({ id: 'pi' }); + }); + it('leaves entries untouched when the openai entry already has any agentRuntime.id', async () => { const initial = { models: { diff --git a/tests/unit/provider-keys.test.ts b/tests/unit/provider-keys.test.ts new file mode 100644 index 0000000..1e93781 --- /dev/null +++ b/tests/unit/provider-keys.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { + OPENAI_CODEX_RUNTIME_PROVIDER_KEY, + filterActiveProviderKeysForUi, + getOpenClawProviderKeyForType, + isOpenClawOAuthPluginProviderKey, + resolveOpenClawProviderKey, +} from '@electron/utils/provider-keys'; + +describe('provider-keys', () => { + it('maps OpenAI browser OAuth accounts to the openai-codex runtime key', () => { + expect(resolveOpenClawProviderKey({ + vendorId: 'openai', + id: 'openai-personal', + authMode: 'oauth_browser', + })).toBe(OPENAI_CODEX_RUNTIME_PROVIDER_KEY); + + expect(resolveOpenClawProviderKey({ + vendorId: 'openai', + id: 'openai-personal', + authMode: 'api_key', + })).toBe('openai'); + }); + + it('keeps custom multi-instance hashing behavior', () => { + expect(getOpenClawProviderKeyForType('custom', 'my-local')).toBe('custom-mylocal'); + }); + + it('treats openai-codex as an OAuth plugin provider key', () => { + expect(isOpenClawOAuthPluginProviderKey('openai-codex')).toBe(true); + }); + + it('drops bare openai from the UI list when only openai-codex OAuth is active', () => { + expect(filterActiveProviderKeysForUi(['openai', 'openai-codex', 'anthropic'])).toEqual([ + 'openai-codex', + 'anthropic', + ]); + }); + + it('keeps openai in the UI list when an API key is configured alongside Codex OAuth', () => { + expect(filterActiveProviderKeysForUi(['openai', 'openai-codex'], { + hasConfiguredOpenAiApiKey: true, + })).toEqual(['openai', 'openai-codex']); + }); +}); diff --git a/tests/unit/provider-service-stale-cleanup.test.ts b/tests/unit/provider-service-stale-cleanup.test.ts index 03a8e2f..4192fdd 100644 --- a/tests/unit/provider-service-stale-cleanup.test.ts +++ b/tests/unit/provider-service-stale-cleanup.test.ts @@ -38,10 +38,20 @@ vi.mock('@electron/utils/openclaw-auth', () => ({ getProviderApiKeyFromOpenClaw: mocks.getProviderApiKeyFromOpenClaw, })); -vi.mock('@electron/utils/provider-keys', () => ({ - getOpenClawProviderKeyForType: mocks.getOpenClawProviderKeyForType, - getAliasSourceTypes: mocks.getAliasSourceTypes, -})); +vi.mock('@electron/utils/provider-keys', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getOpenClawProviderKeyForType: mocks.getOpenClawProviderKeyForType, + resolveOpenClawProviderKey: (account: { vendorId: string; id: string; authMode?: string }) => { + if (account.authMode === 'oauth_browser' && account.vendorId === 'openai') { + return 'openai-codex'; + } + return mocks.getOpenClawProviderKeyForType(account.vendorId, account.id); + }, + getAliasSourceTypes: mocks.getAliasSourceTypes, + }; +}); vi.mock('@electron/utils/secure-storage', () => ({ deleteApiKey: vi.fn(), @@ -174,6 +184,62 @@ describe('ProviderService.listAccounts (openclaw.json as sole source of truth)', expect(result[0].label).toBe('My Moonshot'); }); + it('hides the bare openai slot when only openai-codex OAuth is configured', async () => { + mocks.listProviderAccounts.mockResolvedValue([ + makeAccount({ + id: 'openai-oauth-1', + vendorId: 'openai' as ProviderAccount['vendorId'], + authMode: 'oauth_browser', + label: 'OpenAI Codex', + }), + makeAccount({ + id: 'openai', + vendorId: 'openai' as ProviderAccount['vendorId'], + authMode: 'api_key', + label: 'OpenAI', + }), + ]); + mocks.getApiKey.mockResolvedValue(null); + mocks.getProviderApiKeyFromOpenClaw.mockResolvedValue(null); + mocks.getActiveOpenClawProviders.mockResolvedValue(new Set(['openai', 'openai-codex'])); + mocks.getOpenClawProvidersConfig.mockResolvedValue({ + providers: { + openai: { baseUrl: 'https://api.openai.com/v1', api: 'openai-responses' }, + 'openai-codex': { baseUrl: 'https://api.openai.com/v1', api: 'openai-codex-responses' }, + }, + defaultModel: 'openai-codex/gpt-5.5', + }); + + const result = await service.listAccounts(); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('openai-oauth-1'); + expect(mocks.deleteProviderAccount).toHaveBeenCalledWith('openai'); + }); + + it('matches OpenAI browser OAuth accounts to the openai-codex runtime key', async () => { + mocks.listProviderAccounts.mockResolvedValue([ + makeAccount({ + id: 'openai-oauth-1', + vendorId: 'openai' as ProviderAccount['vendorId'], + authMode: 'oauth_browser', + label: 'OpenAI Codex', + }), + ]); + mocks.getActiveOpenClawProviders.mockResolvedValue(new Set(['openai-codex'])); + mocks.getOpenClawProvidersConfig.mockResolvedValue({ + providers: {}, + defaultModel: 'openai-codex/gpt-5.5', + }); + + const result = await service.listAccounts(); + + expect(mocks.saveProviderAccount).not.toHaveBeenCalled(); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('openai-oauth-1'); + expect(result[0].authMode).toBe('oauth_browser'); + }); + it('matches UUID-based store account to openclaw key via getOpenClawProviderKeyForType', async () => { mocks.listProviderAccounts.mockResolvedValue([ makeAccount({ id: 'openrouter-uuid-1234', vendorId: 'openrouter' as ProviderAccount['vendorId'] }), diff --git a/tests/unit/providers.test.ts b/tests/unit/providers.test.ts index c9bd276..1275e2c 100644 --- a/tests/unit/providers.test.ts +++ b/tests/unit/providers.test.ts @@ -150,7 +150,13 @@ describe('provider metadata', () => { const minimax = PROVIDER_TYPE_INFO.find((provider) => provider.id === 'minimax-portal'); const minimaxCn = PROVIDER_TYPE_INFO.find((provider) => provider.id === 'minimax-portal-cn'); - expect(openai).toMatchObject({ showModelId: true, defaultModelId: 'gpt-5.5' }); + expect(openai).toMatchObject({ + showModelId: true, + defaultModelId: 'gpt-5.5', + isOAuth: true, + supportsApiKey: true, + }); + expect(openai?.hideOAuthUi).toBeUndefined(); expect(google).toMatchObject({ showModelId: true, defaultModelId: 'gemini-3.1-pro-preview' }); expect(minimax).toMatchObject({ showModelId: true, defaultModelId: 'MiniMax-M2.7' }); expect(minimaxCn).toMatchObject({ showModelId: true, defaultModelId: 'MiniMax-M2.7' });