diff --git a/src/gateway/server-startup-config.recovery.test.ts b/src/gateway/server-startup-config.recovery.test.ts index 8d0ea271a0f..7a6e32ead90 100644 --- a/src/gateway/server-startup-config.recovery.test.ts +++ b/src/gateway/server-startup-config.recovery.test.ts @@ -18,6 +18,11 @@ vi.mock("../config/config.js", () => ({ snapshot.issues.every((issue) => issue.path.startsWith("plugins.entries.")) ); }), + validateConfigObjectWithPlugins: vi.fn((config: OpenClawConfig) => ({ + ok: true, + config, + warnings: [], + })), writeConfigFile: vi.fn(), })); @@ -176,6 +181,78 @@ describe("gateway startup config recovery", () => { expect(recoveryNotice.enqueueConfigRecoveryNotice).not.toHaveBeenCalled(); }); + it("skips providers with stale model api enum values during startup", async () => { + const config = { + gateway: { mode: "local" }, + models: { + providers: { + openrouter: { + baseUrl: "https://openrouter.ai/api/v1", + api: "openai", + models: [ + { + id: "openai/gpt-4o-mini", + name: "OpenRouter GPT-4o Mini", + api: "openai", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + }, + ], + }, + anthropic: { + baseUrl: "https://api.anthropic.com", + api: "anthropic-messages", + models: [], + }, + }, + }, + } as unknown as OpenClawConfig; + const invalidSnapshot = buildTestConfigSnapshot({ + path: configPath, + exists: true, + raw: `${JSON.stringify(config)}\n`, + parsed: config, + valid: false, + config, + issues: [ + { + path: "models.providers.openrouter.api", + message: + 'Invalid option: expected one of "openai-completions"|"openai-responses"|"openai-codex-responses"|"anthropic-messages"|"google-generative-ai"|"github-copilot"|"bedrock-converse-stream"|"ollama"|"azure-openai-responses"', + }, + { + path: "models.providers.openrouter.models.0.api", + message: + 'Invalid option: expected one of "openai-completions"|"openai-responses"|"openai-codex-responses"|"anthropic-messages"|"google-generative-ai"|"github-copilot"|"bedrock-converse-stream"|"ollama"|"azure-openai-responses"', + }, + ], + legacyIssues: [], + }); + vi.mocked(configIo.readConfigFileSnapshot).mockResolvedValueOnce(invalidSnapshot); + const log = { info: vi.fn(), warn: vi.fn() }; + + const result = await loadGatewayStartupConfigSnapshot({ + minimalTestGateway: false, + log, + }); + + expect(result.wroteConfig).toBe(false); + expect(result.degradedProviderApi).toBe(true); + expect(result.snapshot.valid).toBe(true); + expect(result.snapshot.sourceConfig.models?.providers?.openrouter).toBeUndefined(); + expect(result.snapshot.sourceConfig.models?.providers?.anthropic).toEqual( + config.models?.providers?.anthropic, + ); + expect(configIo.recoverConfigFromLastKnownGood).not.toHaveBeenCalled(); + expect(configIo.writeConfigFile).not.toHaveBeenCalled(); + expect(log.warn).toHaveBeenCalledWith( + 'gateway: skipped model provider openrouter; configured provider api is invalid. Run "openclaw doctor --fix" to repair the config.', + ); + }); + it("strips a valid JSON suffix when last-known-good recovery is unavailable", async () => { const invalidSnapshot = buildSnapshot({ valid: false, diff --git a/src/gateway/server-startup-config.ts b/src/gateway/server-startup-config.ts index 3e7574e9090..4fd3c5dc544 100644 --- a/src/gateway/server-startup-config.ts +++ b/src/gateway/server-startup-config.ts @@ -10,9 +10,11 @@ import { recoverConfigFromLastKnownGood, recoverConfigFromJsonRootSuffix, shouldAttemptLastKnownGoodRecovery, + validateConfigObjectWithPlugins, writeConfigFile, } from "../config/config.js"; import { formatConfigIssueLines } from "../config/issue-format.js"; +import { asResolvedSourceConfig, materializeRuntimeConfig } from "../config/materialize.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { @@ -56,20 +58,122 @@ type GatewayStartupConfigOverrides = { export type GatewayStartupConfigSnapshotLoadResult = { snapshot: ConfigFileSnapshot; wroteConfig: boolean; + degradedProviderApi?: boolean; }; +const MODEL_PROVIDER_API_PATH_RE = /^models\.providers\.([^.]+)\.api$/; +const MODEL_PROVIDER_MODEL_API_PATH_RE = /^models\.providers\.([^.]+)\.models\.\d+\.api$/; + +function resolveInvalidModelProviderApiIssueProviderId(issue: { + path: string; + message: string; +}): string | null { + if (!issue.message.startsWith("Invalid option:")) { + return null; + } + const providerMatch = + issue.path.match(MODEL_PROVIDER_API_PATH_RE) ?? + issue.path.match(MODEL_PROVIDER_MODEL_API_PATH_RE); + return providerMatch?.[1] ?? null; +} + +function cloneConfigWithoutModelProviders( + config: OpenClawConfig, + providerIds: ReadonlySet, +): OpenClawConfig { + const providers = config.models?.providers; + if (!providers) { + return config; + } + let changed = false; + const nextProviders = { ...providers }; + for (const providerId of providerIds) { + if (!Object.hasOwn(nextProviders, providerId)) { + continue; + } + delete nextProviders[providerId]; + changed = true; + } + if (!changed) { + return config; + } + return { + ...config, + models: { + ...config.models, + providers: nextProviders, + }, + }; +} + +function resolveGatewayStartupConfigWithoutInvalidModelProviders(params: { + snapshot: ConfigFileSnapshot; + log: GatewayStartupLog; +}): ConfigFileSnapshot | null { + if (params.snapshot.valid || params.snapshot.legacyIssues.length > 0) { + return null; + } + const providerIds = new Set(); + for (const issue of params.snapshot.issues) { + const providerId = resolveInvalidModelProviderApiIssueProviderId(issue); + if (!providerId) { + return null; + } + providerIds.add(providerId); + } + if (providerIds.size === 0) { + return null; + } + + const prunedSourceConfig = cloneConfigWithoutModelProviders( + params.snapshot.sourceConfig, + providerIds, + ); + const validated = validateConfigObjectWithPlugins(prunedSourceConfig); + if (!validated.ok) { + return null; + } + const runtimeConfig = materializeRuntimeConfig(validated.config, "load"); + for (const providerId of providerIds) { + params.log.warn( + `gateway: skipped model provider ${providerId}; configured provider api is invalid. Run "openclaw doctor --fix" to repair the config.`, + ); + } + return { + ...params.snapshot, + sourceConfig: asResolvedSourceConfig(validated.config), + resolved: asResolvedSourceConfig(validated.config), + valid: true, + runtimeConfig, + config: runtimeConfig, + issues: [], + warnings: validated.warnings, + }; +} + export async function loadGatewayStartupConfigSnapshot(params: { minimalTestGateway: boolean; log: GatewayStartupLog; }): Promise { let configSnapshot = await readConfigFileSnapshot(); let wroteConfig = false; + let degradedStartupConfig = false; if (configSnapshot.legacyIssues.length > 0 && isNixMode) { throw new Error( "Legacy config entries detected while running in Nix mode. Update your Nix config to the latest schema and restart.", ); } if (configSnapshot.exists) { + if (!configSnapshot.valid) { + const providerApiPrunedSnapshot = resolveGatewayStartupConfigWithoutInvalidModelProviders({ + snapshot: configSnapshot, + log: params.log, + }); + if (providerApiPrunedSnapshot) { + degradedStartupConfig = true; + configSnapshot = providerApiPrunedSnapshot; + } + } if (!configSnapshot.valid) { const canRecoverFromLastKnownGood = shouldAttemptLastKnownGoodRecovery(configSnapshot); const recovered = canRecoverFromLastKnownGood @@ -109,11 +213,16 @@ export async function loadGatewayStartupConfigSnapshot(params: { assertValidGatewayStartupConfigSnapshot(configSnapshot, { includeDoctorHint: true }); } - const autoEnable = params.minimalTestGateway - ? { config: configSnapshot.config, changes: [] as string[] } - : applyPluginAutoEnable({ config: configSnapshot.config, env: process.env }); + const autoEnable = + params.minimalTestGateway || degradedStartupConfig + ? { config: configSnapshot.config, changes: [] as string[] } + : applyPluginAutoEnable({ config: configSnapshot.config, env: process.env }); if (autoEnable.changes.length === 0) { - return { snapshot: configSnapshot, wroteConfig }; + return { + snapshot: configSnapshot, + wroteConfig, + ...(degradedStartupConfig ? { degradedProviderApi: true } : {}), + }; } try { @@ -128,7 +237,11 @@ export async function loadGatewayStartupConfigSnapshot(params: { params.log.warn(`gateway: failed to persist plugin auto-enable changes: ${String(err)}`); } - return { snapshot: configSnapshot, wroteConfig }; + return { + snapshot: configSnapshot, + wroteConfig, + ...(degradedStartupConfig ? { degradedProviderApi: true } : {}), + }; } export function createRuntimeSecretsActivator(params: { @@ -226,6 +339,7 @@ export async function prepareGatewayStartupConfig(params: { authOverride?: GatewayAuthConfig; tailscaleOverride?: GatewayTailscaleConfig; activateRuntimeSecrets: ActivateRuntimeSecrets; + persistStartupAuth?: boolean; }): Promise>> { assertValidGatewayStartupConfigSnapshot(params.configSnapshot); @@ -262,7 +376,7 @@ export async function prepareGatewayStartupConfig(params: { env: process.env, authOverride: preflightAuthOverride, tailscaleOverride: params.tailscaleOverride, - persist: true, + persist: params.persistStartupAuth ?? true, baseHash: params.configSnapshot.hash, }); const runtimeStartupConfig = applyGatewayAuthOverridesForStartupPreflight(authBootstrap.cfg, { diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index d8837ca76af..525c957c39e 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -296,6 +296,7 @@ export async function startGatewayServer( authOverride: opts.auth, tailscaleOverride: opts.tailscale, activateRuntimeSecrets, + persistStartupAuth: startupConfigLoad.degradedProviderApi !== true, }), ); cfgAtStart = authBootstrap.cfg;