fix: use openai-chatgpt-responses for Codex OAuth and hide stale OpenAI API slot after logout (#1113)

This commit is contained in:
paisley
2026-06-11 15:46:06 +08:00
committed by GitHub
parent 1fd3c50c3e
commit 66692f1777
12 changed files with 263 additions and 42 deletions
@@ -18,6 +18,7 @@ import {
syncProviderConfigToOpenClaw,
updateAgentModelProvider,
updateSingleAgentModelProvider,
getProviderApiKeyFromOpenClaw,
} from '../../utils/openclaw-auth';
import {
piAiModelsJsonModelEntry,
@@ -376,6 +377,26 @@ async function removeDeletedProviderFromOpenClaw(
for (const key of keys) {
await removeProviderFromOpenClaw(key);
}
// Codex OAuth uses runtime key openai-codex but may leave a bare models.providers.openai
// entry behind. Drop that slot when no API key credentials remain.
if (runtimeProviderKey === OPENAI_OAUTH_RUNTIME_PROVIDER) {
const openClawKey = await getProviderApiKeyFromOpenClaw('openai');
if (openClawKey) {
return;
}
const storeAccounts = await listProviderAccounts();
for (const account of storeAccounts) {
if (account.vendorId !== 'openai' || account.authMode === 'oauth_browser') {
continue;
}
const apiKey = await getApiKey(account.id);
if (apiKey) {
return;
}
}
await removeProviderFromOpenClaw('openai');
}
}
function parseModelRef(modelRef: string): { providerKey: string; modelId: string } | null {
+18 -11
View File
@@ -112,15 +112,19 @@ export class ProviderService {
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 openClawKey = await getProviderApiKeyFromOpenClaw('openai');
if (openClawKey) {
hasConfiguredOpenAiApiKey = true;
} else {
for (const account of storeByKey.get('openai') ?? []) {
if (account.authMode === 'oauth_browser') {
continue;
}
const apiKey = await getApiKey(account.id);
if (apiKey) {
hasConfiguredOpenAiApiKey = true;
break;
}
}
}
}
@@ -174,7 +178,7 @@ export class ProviderService {
}
}
if (activeProviders.has(OPENAI_CODEX_RUNTIME_PROVIDER_KEY)) {
if (activeProviders.has(OPENAI_CODEX_RUNTIME_PROVIDER_KEY) || !hasConfiguredOpenAiApiKey) {
const openaiStoreAccounts = storeByKey.get('openai') ?? [];
for (const account of openaiStoreAccounts) {
if (account.authMode !== 'api_key' && account.authMode !== undefined) {
@@ -184,7 +188,10 @@ export class ProviderService {
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})`,
`[provider-sync] Removing unconfigured OpenAI API key account "${account.id}"`
+ (activeProviders.has(OPENAI_CODEX_RUNTIME_PROVIDER_KEY)
? ` (OAuth uses ${OPENAI_CODEX_RUNTIME_PROVIDER_KEY})`
: ' (Codex OAuth removed)'),
);
await deleteProviderAccount(account.id);
}
+15 -1
View File
@@ -48,7 +48,7 @@ export const OLLAMA_PLACEHOLDER_API_KEY = 'ollama-local';
export const OPENCLAW_API_PROTOCOLS = [
'openai-completions',
'openai-responses',
'openai-codex-responses',
'openai-chatgpt-responses',
'anthropic-messages',
'google-generative-ai',
'github-copilot',
@@ -59,6 +59,20 @@ export const OPENCLAW_API_PROTOCOLS = [
export type OpenClawApiProtocol = (typeof OPENCLAW_API_PROTOCOLS)[number];
/** Legacy api values ClawX previously wrote that OpenClaw no longer accepts. */
export const LEGACY_OPENCLAW_API_PROTOCOL_MIGRATIONS = {
'openai-codex-responses': 'openai-chatgpt-responses',
} as const satisfies Record<string, OpenClawApiProtocol>;
export function normalizeOpenClawApiProtocol(api: unknown): OpenClawApiProtocol | undefined {
if (typeof api !== 'string') return undefined;
if ((OPENCLAW_API_PROTOCOLS as readonly string[]).includes(api)) {
return api as OpenClawApiProtocol;
}
const migrated = (LEGACY_OPENCLAW_API_PROTOCOL_MIGRATIONS as Record<string, OpenClawApiProtocol>)[api];
return migrated;
}
export class InvalidApiProtocolError extends Error {
constructor(public readonly api: unknown, public readonly providerKey?: string) {
super(
+33 -3
View File
@@ -32,8 +32,8 @@ import { withConfigLock } from './config-mutex';
import { PORTS } from './config';
import { getSetting } from './store';
import {
OPENCLAW_API_PROTOCOLS,
assertValidApiProtocol,
normalizeOpenClawApiProtocol,
} from '../shared/providers/types';
import { inferCustomModelInputModalities } from '../shared/providers/model-capabilities';
import {
@@ -1119,6 +1119,25 @@ export async function removeProviderFromOpenClaw(provider: string): Promise<void
*
* Returns the list of pruned provider keys for logging.
*/
function repairLegacyApiProtocolEntriesInConfig(config: Record<string, unknown>): string[] {
const migrated: string[] = [];
const models = (config.models || {}) as Record<string, unknown>;
const providers = (models.providers || {}) as Record<string, unknown>;
for (const [key, entry] of Object.entries(providers)) {
if (!isPlainRecord(entry)) continue;
const entryObj = entry as Record<string, unknown>;
const api = entryObj.api;
const normalized = normalizeOpenClawApiProtocol(api);
if (normalized && normalized !== api) {
entryObj.api = normalized;
migrated.push(key);
}
}
return migrated;
}
export async function pruneInvalidApiProviderEntries(): Promise<string[]> {
const removed: string[] = [];
await withConfigLock(async () => {
@@ -1127,9 +1146,14 @@ export async function pruneInvalidApiProviderEntries(): Promise<string[]> {
const providers = (models.providers || {}) as Record<string, unknown>;
let modified = false;
const migrated = repairLegacyApiProtocolEntriesInConfig(config);
if (migrated.length > 0) {
modified = true;
}
for (const [key, entry] of Object.entries(providers)) {
const api = isPlainRecord(entry) ? (entry as Record<string, unknown>).api : undefined;
if (typeof api !== 'string' || !(OPENCLAW_API_PROTOCOLS as readonly string[]).includes(api)) {
if (!normalizeOpenClawApiProtocol(api)) {
delete providers[key];
removed.push(key);
modified = true;
@@ -1493,7 +1517,7 @@ const OPENCLAW_PROVIDER_PINNED_AGENT_RUNTIME: Record<string, string> = {
/** 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,
api: 'openai-chatgpt-responses' as const,
};
function applyPinnedAgentRuntime(
@@ -3136,6 +3160,12 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
}
}
const migratedApiProtocols = repairLegacyApiProtocolEntriesInConfig(config);
if (migratedApiProtocols.length > 0) {
modified = true;
console.log(`[sanitize] Migrated legacy models.providers api protocol for: ${migratedApiProtocols.join(', ')}`);
}
const pinnedProviderRuntimes = applyOpenClawProviderAgentRuntimePinsToConfig(config);
if (pinnedProviderRuntimes.length > 0) {
modified = true;
+6 -7
View File
@@ -74,14 +74,13 @@ export function filterActiveProviderKeysForUi(
options?: { hasConfiguredOpenAiApiKey?: boolean },
): string[] {
const keys = Array.from(activeKeys).filter((key) => !HIDDEN_PROVIDER_KEYS_FOR_UI.has(key));
const active = new Set(keys);
if (!active.has('openai') || !active.has(OPENAI_CODEX_RUNTIME_PROVIDER_KEY)) {
return keys;
// Bare `openai` is the API-key slot. Hide it unless a real API key exists —
// including after Codex OAuth is removed and openclaw.json still lists
// models.providers.openai from an earlier OAuth setup.
if (!options?.hasConfiguredOpenAiApiKey) {
return keys.filter((key) => key !== 'openai');
}
if (options?.hasConfiguredOpenAiApiKey) {
return keys;
}
return keys.filter((key) => key !== 'openai');
return keys;
}
export function isOAuthProviderType(type: string): boolean {
@@ -39,7 +39,7 @@ docs:
A historical bug in [electron/shared/providers/registry.ts](electron/shared/providers/registry.ts) set the OpenRouter
`providerConfig.api` to the literal string `'openrouter'`, which is not in OpenClaw's allowed
`api` enum (`openai-completions | openai-responses | openai-codex-responses | anthropic-messages |
`api` enum (`openai-completions | openai-responses | openai-chatgpt-responses | anthropic-messages |
google-generative-ai | github-copilot | bedrock-converse-stream | ollama | azure-openai-responses`).
When the user selected OpenRouter as their default provider, ClawX wrote that invalid value into
+1 -1
View File
@@ -266,7 +266,7 @@ export type ProviderVendorCategory = 'official' | 'compatible' | 'local' | 'cust
export type ProviderProtocol =
| 'openai-completions'
| 'openai-responses'
| 'openai-codex-responses'
| 'openai-chatgpt-responses'
| 'anthropic-messages'
| 'google-generative-ai'
| 'github-copilot'
+1 -1
View File
@@ -27,7 +27,7 @@ export type ProviderType = (typeof PROVIDER_TYPES)[number];
export type ProviderProtocol =
| 'openai-completions'
| 'openai-responses'
| 'openai-codex-responses'
| 'openai-chatgpt-responses'
| 'anthropic-messages'
| 'google-generative-ai'
| 'github-copilot'
+51 -5
View File
@@ -1782,6 +1782,31 @@ describe('pruneInvalidApiProviderEntries', () => {
const after = await readOpenClawJson();
expect(after).toEqual(before);
});
it('migrates legacy openai-codex-responses api values instead of pruning them', async () => {
await writeOpenClawJson({
models: {
providers: {
'openai-codex': {
baseUrl: 'https://api.openai.com/v1',
api: 'openai-codex-responses',
},
openrouter: {
baseUrl: 'https://openrouter.ai/api/v1',
api: 'openrouter',
},
},
},
});
const { pruneInvalidApiProviderEntries } = await import('@electron/utils/openclaw-auth');
const removed = await pruneInvalidApiProviderEntries();
expect(removed).toEqual(['openrouter']);
const result = await readOpenClawJson();
const providers = (result.models as Record<string, unknown>).providers as Record<string, unknown>;
expect((providers['openai-codex'] as { api: string }).api).toBe('openai-chatgpt-responses');
});
});
describe('openai agentRuntime pin', () => {
@@ -1824,7 +1849,7 @@ describe('openai agentRuntime pin', () => {
await syncProviderConfigToOpenClaw('openai-codex', 'gpt-5.5', {
baseUrl: 'https://api.openai.com/v1',
api: 'openai-codex-responses',
api: 'openai-chatgpt-responses',
});
const result = await readOpenClawJson();
@@ -1833,7 +1858,7 @@ describe('openai agentRuntime pin', () => {
expect(codex).toBeDefined();
expect(codex.agentRuntime).toEqual({ id: 'pi' });
expect(codex.api).toBe('openai-codex-responses');
expect(codex.api).toBe('openai-chatgpt-responses');
});
it('preserves a user-provided agentRuntime override on the openai entry', async () => {
@@ -1967,7 +1992,7 @@ describe('setOpenClawDefaultModel for openai-codex OAuth', () => {
expect(defaults.primary).toBe('openai-codex/gpt-5.5');
expect(codex.agentRuntime).toEqual({ id: 'pi' });
expect(codex.api).toBe('openai-codex-responses');
expect(codex.api).toBe('openai-chatgpt-responses');
});
});
@@ -2010,7 +2035,7 @@ describe('ensureOpenClawProviderAgentRuntimePins', () => {
providers: {
'openai-codex': {
baseUrl: 'https://api.openai.com/v1',
api: 'openai-codex-responses',
api: 'openai-chatgpt-responses',
models: [{ id: 'gpt-5.5', name: 'gpt-5.5' }],
},
},
@@ -2038,7 +2063,7 @@ describe('ensureOpenClawProviderAgentRuntimePins', () => {
},
'openai-codex': {
baseUrl: 'https://api.openai.com/v1',
api: 'openai-codex-responses',
api: 'openai-chatgpt-responses',
models: [{ id: 'gpt-5.5', name: 'gpt-5.5' }],
},
},
@@ -2054,6 +2079,27 @@ describe('ensureOpenClawProviderAgentRuntimePins', () => {
expect((providers['openai-codex'] as Record<string, unknown>).agentRuntime).toEqual({ id: 'pi' });
});
it('migrates legacy openai-codex-responses api values during sanitizeOpenClawConfig', 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 { sanitizeOpenClawConfig } = await import('@electron/utils/openclaw-auth');
await sanitizeOpenClawConfig();
const result = await readOpenClawJson();
const providers = (result.models as Record<string, unknown>).providers as Record<string, unknown>;
expect((providers['openai-codex'] as { api: string }).api).toBe('openai-chatgpt-responses');
});
it('leaves entries untouched when the openai entry already has any agentRuntime.id', async () => {
const initial = {
models: {
+4
View File
@@ -42,4 +42,8 @@ describe('provider-keys', () => {
hasConfiguredOpenAiApiKey: true,
})).toEqual(['openai', 'openai-codex']);
});
it('drops bare openai after Codex OAuth is removed and no API key remains', () => {
expect(filterActiveProviderKeysForUi(['openai', 'minimax-portal'])).toEqual(['minimax-portal']);
});
});
+40
View File
@@ -21,6 +21,7 @@ const mocks = vi.hoisted(() => ({
syncProviderConfigToOpenClaw: vi.fn(),
updateAgentModelProvider: vi.fn(),
updateSingleAgentModelProvider: vi.fn(),
getProviderApiKeyFromOpenClaw: vi.fn(),
listAgentsSnapshot: vi.fn(),
}));
@@ -55,6 +56,7 @@ vi.mock('@electron/utils/openclaw-auth', () => ({
syncProviderConfigToOpenClaw: mocks.syncProviderConfigToOpenClaw,
updateAgentModelProvider: mocks.updateAgentModelProvider,
updateSingleAgentModelProvider: mocks.updateSingleAgentModelProvider,
getProviderApiKeyFromOpenClaw: mocks.getProviderApiKeyFromOpenClaw,
}));
vi.mock('@electron/utils/agent-config', () => ({
@@ -123,6 +125,8 @@ describe('provider-runtime-sync refresh strategy', () => {
mocks.removeProviderKeyFromOpenClaw.mockResolvedValue(undefined);
mocks.updateAgentModelProvider.mockResolvedValue(undefined);
mocks.updateSingleAgentModelProvider.mockResolvedValue(undefined);
mocks.getProviderApiKeyFromOpenClaw.mockResolvedValue(null);
mocks.listProviderAccounts.mockResolvedValue([]);
mocks.listAgentsSnapshot.mockResolvedValue({ agents: [] });
});
@@ -158,6 +162,42 @@ describe('provider-runtime-sync refresh strategy', () => {
expect(gateway.debouncedRestart).toHaveBeenCalledTimes(1);
});
it('also removes bare openai config when deleting Codex OAuth without an API key', async () => {
const gateway = createGateway('running');
const openaiOAuthProvider = createProvider({
id: 'openai-oauth-1',
type: 'openai',
model: 'gpt-5.5',
});
mocks.getProviderApiKeyFromOpenClaw.mockResolvedValue(null);
mocks.listProviderAccounts.mockResolvedValue([
{
id: 'openai-oauth-1',
vendorId: 'openai',
authMode: 'oauth_browser',
label: 'OpenAI Codex',
enabled: true,
isDefault: false,
createdAt: '2026-03-14T00:00:00.000Z',
updatedAt: '2026-03-14T00:00:00.000Z',
},
]);
mocks.getApiKey.mockResolvedValue(null);
await syncDeletedProviderToRuntime(
openaiOAuthProvider,
'openai-oauth-1',
gateway as GatewayManager,
'openai-codex',
);
expect(mocks.removeProviderFromOpenClaw).toHaveBeenCalledWith('openai-codex');
expect(mocks.removeProviderFromOpenClaw).toHaveBeenCalledWith('openai-oauth-1');
expect(mocks.removeProviderFromOpenClaw).toHaveBeenCalledWith('openai');
expect(gateway.debouncedRestart).toHaveBeenCalledTimes(1);
});
it('only clears the api-key profile when deleting a provider api key', async () => {
const openaiProvider = createProvider({
id: 'openai-personal',
@@ -205,7 +205,7 @@ describe('ProviderService.listAccounts (openclaw.json as sole source of truth)',
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' },
'openai-codex': { baseUrl: 'https://api.openai.com/v1', api: 'openai-chatgpt-responses' },
},
defaultModel: 'openai-codex/gpt-5.5',
});
@@ -278,6 +278,73 @@ describe('ProviderService.listAccounts (openclaw.json as sole source of truth)',
expect(result[0].authMode).toBe('oauth_browser');
});
it('hides bare openai after Codex OAuth is removed and no API key is configured', async () => {
mocks.listProviderAccounts.mockResolvedValue([
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']));
mocks.getOpenClawProvidersConfig.mockResolvedValue({
providers: {
openai: { baseUrl: 'https://api.openai.com/v1', api: 'openai-responses' },
},
defaultModel: 'minimax-portal/MiniMax-M3',
});
const result = await service.listAccounts();
expect(result).toHaveLength(0);
expect(mocks.deleteProviderAccount).toHaveBeenCalledWith('openai');
expect(mocks.saveProviderAccount).not.toHaveBeenCalled();
});
it('keeps openai visible when only OpenClaw auth-profiles has the API key', async () => {
mocks.listProviderAccounts.mockResolvedValue([]);
mocks.getApiKey.mockResolvedValue(null);
mocks.getProviderApiKeyFromOpenClaw.mockImplementation(async (provider: string) => (
provider === 'openai' ? 'sk-openclaw-imported' : null
));
mocks.getActiveOpenClawProviders.mockResolvedValue(new Set(['openai']));
mocks.getOpenClawProvidersConfig.mockResolvedValue({
providers: {
openai: { baseUrl: 'https://api.openai.com/v1', api: 'openai-responses' },
},
defaultModel: 'openai/gpt-5.5',
});
mocks.getProviderDefinition.mockImplementation((key: string) => {
if (key === 'openai') {
return {
id: 'openai',
name: 'OpenAI',
defaultAuthMode: 'api_key',
defaultModelId: 'gpt-5.5',
providerConfig: {
baseUrl: 'https://api.openai.com/v1',
api: 'openai-responses',
},
};
}
return undefined;
});
const result = await service.listAccounts();
expect(mocks.saveProviderAccount).toHaveBeenCalledTimes(1);
expect(result).toHaveLength(1);
expect(result[0]).toEqual(expect.objectContaining({
id: 'openai',
vendorId: 'openai',
authMode: 'api_key',
}));
expect(mocks.deleteProviderAccount).not.toHaveBeenCalled();
});
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'] }),
@@ -481,23 +548,16 @@ describe('ProviderService.listAccounts (openclaw.json as sole source of truth)',
const result = await service.listAccounts();
expect(mocks.saveProviderAccount).toHaveBeenCalledTimes(2);
expect(result).toHaveLength(2);
expect(result).toEqual(expect.arrayContaining([
expect.objectContaining({
id: 'openai',
vendorId: 'openai',
authMode: 'oauth_browser',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-5.2',
}),
expect(mocks.saveProviderAccount).toHaveBeenCalledTimes(1);
expect(result).toHaveLength(1);
expect(result).toEqual([
expect.objectContaining({
id: 'anthropic',
vendorId: 'anthropic',
authMode: 'api_key',
model: 'claude-opus-4-6',
}),
]));
]);
});
});