mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-29 13:06:47 +02:00
fix(gateway): skip stale model provider api entries
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -296,6 +296,7 @@ export async function startGatewayServer(
|
||||
authOverride: opts.auth,
|
||||
tailscaleOverride: opts.tailscale,
|
||||
activateRuntimeSecrets,
|
||||
persistStartupAuth: startupConfigLoad.degradedProviderApi !== true,
|
||||
}),
|
||||
);
|
||||
cfgAtStart = authBootstrap.cfg;
|
||||
|
||||
Reference in New Issue
Block a user