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."))
);
}),
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,

View File

@@ -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<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: {
minimalTestGateway: boolean;
log: GatewayStartupLog;
}): Promise<GatewayStartupConfigSnapshotLoadResult> {
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<Awaited<ReturnType<typeof ensureGatewayStartupAuth>>> {
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, {

View File

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