fix(gateway): skip stale model provider api entries

This commit is contained in:
Ayaan Zaidi
2026-04-27 09:04:50 +05:30
parent 6a7980e984
commit 147f4f50f5
3 changed files with 198 additions and 6 deletions

View File

@@ -18,6 +18,11 @@ vi.mock("../config/config.js", () => ({
snapshot.issues.every((issue) => issue.path.startsWith("plugins.entries.")) snapshot.issues.every((issue) => issue.path.startsWith("plugins.entries."))
); );
}), }),
validateConfigObjectWithPlugins: vi.fn((config: OpenClawConfig) => ({
ok: true,
config,
warnings: [],
})),
writeConfigFile: vi.fn(), writeConfigFile: vi.fn(),
})); }));
@@ -176,6 +181,78 @@ describe("gateway startup config recovery", () => {
expect(recoveryNotice.enqueueConfigRecoveryNotice).not.toHaveBeenCalled(); 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 () => { it("strips a valid JSON suffix when last-known-good recovery is unavailable", async () => {
const invalidSnapshot = buildSnapshot({ const invalidSnapshot = buildSnapshot({
valid: false, valid: false,

View File

@@ -10,9 +10,11 @@ import {
recoverConfigFromLastKnownGood, recoverConfigFromLastKnownGood,
recoverConfigFromJsonRootSuffix, recoverConfigFromJsonRootSuffix,
shouldAttemptLastKnownGoodRecovery, shouldAttemptLastKnownGoodRecovery,
validateConfigObjectWithPlugins,
writeConfigFile, writeConfigFile,
} from "../config/config.js"; } from "../config/config.js";
import { formatConfigIssueLines } from "../config/issue-format.js"; import { formatConfigIssueLines } from "../config/issue-format.js";
import { asResolvedSourceConfig, materializeRuntimeConfig } from "../config/materialize.js";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import { isTruthyEnvValue } from "../infra/env.js"; import { isTruthyEnvValue } from "../infra/env.js";
import { import {
@@ -56,20 +58,122 @@ type GatewayStartupConfigOverrides = {
export type GatewayStartupConfigSnapshotLoadResult = { export type GatewayStartupConfigSnapshotLoadResult = {
snapshot: ConfigFileSnapshot; snapshot: ConfigFileSnapshot;
wroteConfig: boolean; 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<string>,
): 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<string>();
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: { export async function loadGatewayStartupConfigSnapshot(params: {
minimalTestGateway: boolean; minimalTestGateway: boolean;
log: GatewayStartupLog; log: GatewayStartupLog;
}): Promise<GatewayStartupConfigSnapshotLoadResult> { }): Promise<GatewayStartupConfigSnapshotLoadResult> {
let configSnapshot = await readConfigFileSnapshot(); let configSnapshot = await readConfigFileSnapshot();
let wroteConfig = false; let wroteConfig = false;
let degradedStartupConfig = false;
if (configSnapshot.legacyIssues.length > 0 && isNixMode) { if (configSnapshot.legacyIssues.length > 0 && isNixMode) {
throw new Error( throw new Error(
"Legacy config entries detected while running in Nix mode. Update your Nix config to the latest schema and restart.", "Legacy config entries detected while running in Nix mode. Update your Nix config to the latest schema and restart.",
); );
} }
if (configSnapshot.exists) { if (configSnapshot.exists) {
if (!configSnapshot.valid) {
const providerApiPrunedSnapshot = resolveGatewayStartupConfigWithoutInvalidModelProviders({
snapshot: configSnapshot,
log: params.log,
});
if (providerApiPrunedSnapshot) {
degradedStartupConfig = true;
configSnapshot = providerApiPrunedSnapshot;
}
}
if (!configSnapshot.valid) { if (!configSnapshot.valid) {
const canRecoverFromLastKnownGood = shouldAttemptLastKnownGoodRecovery(configSnapshot); const canRecoverFromLastKnownGood = shouldAttemptLastKnownGoodRecovery(configSnapshot);
const recovered = canRecoverFromLastKnownGood const recovered = canRecoverFromLastKnownGood
@@ -109,11 +213,16 @@ export async function loadGatewayStartupConfigSnapshot(params: {
assertValidGatewayStartupConfigSnapshot(configSnapshot, { includeDoctorHint: true }); assertValidGatewayStartupConfigSnapshot(configSnapshot, { includeDoctorHint: true });
} }
const autoEnable = params.minimalTestGateway const autoEnable =
? { config: configSnapshot.config, changes: [] as string[] } params.minimalTestGateway || degradedStartupConfig
: applyPluginAutoEnable({ config: configSnapshot.config, env: process.env }); ? { config: configSnapshot.config, changes: [] as string[] }
: applyPluginAutoEnable({ config: configSnapshot.config, env: process.env });
if (autoEnable.changes.length === 0) { if (autoEnable.changes.length === 0) {
return { snapshot: configSnapshot, wroteConfig }; return {
snapshot: configSnapshot,
wroteConfig,
...(degradedStartupConfig ? { degradedProviderApi: true } : {}),
};
} }
try { try {
@@ -128,7 +237,11 @@ export async function loadGatewayStartupConfigSnapshot(params: {
params.log.warn(`gateway: failed to persist plugin auto-enable changes: ${String(err)}`); 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: { export function createRuntimeSecretsActivator(params: {
@@ -226,6 +339,7 @@ export async function prepareGatewayStartupConfig(params: {
authOverride?: GatewayAuthConfig; authOverride?: GatewayAuthConfig;
tailscaleOverride?: GatewayTailscaleConfig; tailscaleOverride?: GatewayTailscaleConfig;
activateRuntimeSecrets: ActivateRuntimeSecrets; activateRuntimeSecrets: ActivateRuntimeSecrets;
persistStartupAuth?: boolean;
}): Promise<Awaited<ReturnType<typeof ensureGatewayStartupAuth>>> { }): Promise<Awaited<ReturnType<typeof ensureGatewayStartupAuth>>> {
assertValidGatewayStartupConfigSnapshot(params.configSnapshot); assertValidGatewayStartupConfigSnapshot(params.configSnapshot);
@@ -262,7 +376,7 @@ export async function prepareGatewayStartupConfig(params: {
env: process.env, env: process.env,
authOverride: preflightAuthOverride, authOverride: preflightAuthOverride,
tailscaleOverride: params.tailscaleOverride, tailscaleOverride: params.tailscaleOverride,
persist: true, persist: params.persistStartupAuth ?? true,
baseHash: params.configSnapshot.hash, baseHash: params.configSnapshot.hash,
}); });
const runtimeStartupConfig = applyGatewayAuthOverridesForStartupPreflight(authBootstrap.cfg, { const runtimeStartupConfig = applyGatewayAuthOverridesForStartupPreflight(authBootstrap.cfg, {

View File

@@ -296,6 +296,7 @@ export async function startGatewayServer(
authOverride: opts.auth, authOverride: opts.auth,
tailscaleOverride: opts.tailscale, tailscaleOverride: opts.tailscale,
activateRuntimeSecrets, activateRuntimeSecrets,
persistStartupAuth: startupConfigLoad.degradedProviderApi !== true,
}), }),
); );
cfgAtStart = authBootstrap.cfg; cfgAtStart = authBootstrap.cfg;