mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:40:44 +00: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."))
|
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,
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user