fix: OpenAI OAuth login and provider list cleanup (#1043)
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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<string, ProviderAccount[]>();
|
||||
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<string>();
|
||||
|
||||
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<boolean> {
|
||||
const account = await this.getAccount(accountId);
|
||||
const runtimeProviderKey = account
|
||||
? getOpenClawProviderKeyForType(account.vendorId, account.id)
|
||||
? resolveOpenClawProviderKey(account)
|
||||
: accountId;
|
||||
if (await getProviderApiKeyFromOpenClaw(runtimeProviderKey)) {
|
||||
return true;
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -115,16 +115,21 @@ function decodeJwtPayload(token: string): Record<string, unknown> | 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<string, unknown>;
|
||||
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<string, unknown>).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<OpenAICodexAuthorizationFlow> {
|
||||
@@ -181,8 +186,9 @@ function startLocalOAuthServer(state: string): Promise<OpenAICodexLocalServer |
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
// 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,
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
@@ -1417,21 +1432,21 @@ export async function ensureAnthropicMessagesModelMaxTokens(): Promise<string[]>
|
||||
* 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<string, string> = {
|
||||
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(
|
||||
|
||||
@@ -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<string>(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<string>,
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
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<string, unknown>).providers as Record<string, unknown>;
|
||||
const codex = providers['openai-codex'] as Record<string, unknown>;
|
||||
const defaults = ((result.agents as Record<string, unknown>).defaults as Record<string, unknown>).model as Record<string, unknown>;
|
||||
|
||||
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<string, unknown>).providers as Record<string, unknown>;
|
||||
const codex = providers['openai-codex'] as Record<string, unknown>;
|
||||
expect(codex.agentRuntime).toEqual({ id: 'pi' });
|
||||
});
|
||||
|
||||
it('leaves entries untouched when the openai entry already has any agentRuntime.id', async () => {
|
||||
const initial = {
|
||||
models: {
|
||||
|
||||
45
tests/unit/provider-keys.test.ts
Normal file
45
tests/unit/provider-keys.test.ts
Normal file
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof import('@electron/utils/provider-keys')>();
|
||||
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'] }),
|
||||
|
||||
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user