fix: harden release model discovery

This commit is contained in:
Peter Steinberger
2026-05-25 08:35:55 +01:00
parent 754a1c8cea
commit 4e0c18238d
12 changed files with 232 additions and 20 deletions

View File

@@ -1986,7 +1986,7 @@ jobs:
- suite_id: native-live-src-gateway-profiles-anthropic-opus
suite_group: native-live-src-gateway-profiles-anthropic
label: Native live gateway profiles Anthropic Opus
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic OPENCLAW_LIVE_GATEWAY_MODELS=anthropic/claude-opus-4-7 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
command: OPENCLAW_LIVE_GATEWAY_THINKING=low OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic OPENCLAW_LIVE_GATEWAY_MODELS=anthropic/claude-opus-4-7 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 30
profile_env_only: false
advisory: true
@@ -1994,7 +1994,7 @@ jobs:
- suite_id: native-live-src-gateway-profiles-anthropic-sonnet-haiku
suite_group: native-live-src-gateway-profiles-anthropic
label: Native live gateway profiles Anthropic Sonnet/Haiku
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic OPENCLAW_LIVE_GATEWAY_MODELS=anthropic/claude-sonnet-4-6,anthropic/claude-haiku-4-5 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
command: OPENCLAW_LIVE_GATEWAY_THINKING=low OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic OPENCLAW_LIVE_GATEWAY_MODELS=anthropic/claude-sonnet-4-6,anthropic/claude-haiku-4-5 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 30
profile_env_only: false
advisory: true
@@ -2013,7 +2013,7 @@ jobs:
profiles: stable full
- suite_id: native-live-src-gateway-profiles-openai
label: Native live gateway profiles OpenAI
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=openai OPENCLAW_LIVE_GATEWAY_MODELS=openai/gpt-5.5 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
command: OPENCLAW_LIVE_GATEWAY_THINKING=low OPENCLAW_LIVE_GATEWAY_PROVIDERS=openai OPENCLAW_LIVE_GATEWAY_MODELS=openai/gpt-5.5 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 60
profile_env_only: false
profiles: beta minimum stable full
@@ -2294,7 +2294,7 @@ jobs:
profiles: beta minimum stable full
- suite_id: live-gateway-anthropic-docker
label: Docker live gateway Anthropic
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic OPENCLAW_LIVE_GATEWAY_MODELS=anthropic/claude-sonnet-4-6 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
command: OPENCLAW_LIVE_GATEWAY_THINKING=low OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic OPENCLAW_LIVE_GATEWAY_MODELS=anthropic/claude-sonnet-4-6 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
timeout_minutes: 40
profile_env_only: false
profiles: stable full

View File

@@ -45,7 +45,6 @@ const config = {
},
agents: {
defaults: {
agentRuntime: { id: "codex" },
model: { primary: "codex/gpt-5.5", fallbacks: [] },
models: {
"codex/gpt-5.5": {
@@ -61,8 +60,12 @@ const config = {
{
id: "main",
default: true,
agentRuntime: { id: "codex" },
model: { primary: "codex/gpt-5.5", fallbacks: [] },
models: {
"codex/gpt-5.5": {
agentRuntime: { id: "codex" },
},
},
workspace: workspaceDir,
},
],

View File

@@ -66,7 +66,10 @@ function configure() {
defaults: {
...cfg.agents?.defaults,
model: { primary: modelRef, fallbacks: [] },
agentRuntime: { id: "codex" },
models: {
...cfg.agents?.defaults?.models,
[modelRef]: { agentRuntime: { id: "codex" } },
},
workspace: path.join(state, "workspace"),
skipBootstrap: true,
timeoutSeconds: 420,

View File

@@ -30,6 +30,25 @@ function createImplicitOpenRouterProvider(): ProviderConfig {
};
}
function createImplicitOpenAiProvider(overrides: Partial<ProviderConfig> = {}): ProviderConfig {
return {
baseUrl: "https://api.openai.com/v1",
api: "openai-responses",
models: [
{
id: "gpt-5.5",
name: "GPT-5.5",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 400000,
maxTokens: 128000,
},
],
...overrides,
};
}
async function resolveProvidersForConfigEnvTest(params: {
cfg: OpenClawConfig;
onResolveImplicitProviders: (env: NodeJS.ProcessEnv) => void;
@@ -188,6 +207,62 @@ describe("models-config", () => {
expect(observedSnapshot).toBe(pluginMetadataSnapshot);
});
it("does not write unauthenticated model providers that would invalidate models.json", async () => {
const plan = await planOpenClawModelsJsonWithDeps(
{
cfg: { models: { providers: {} } },
agentDir: "/tmp/openclaw-models-config-env-vars-test",
env: {},
existingRaw: "",
existingParsed: null,
},
{
resolveImplicitProviders: async () => ({
openai: createImplicitOpenAiProvider(),
"auth-only": createImplicitOpenAiProvider({
baseUrl: "https://auth.example/v1",
api: "openai-responses",
models: [],
}),
}),
},
);
expect(plan.action).toBe("write");
if (plan.action !== "write") {
throw new Error("Expected models.json write plan");
}
const parsed = JSON.parse(plan.contents) as { providers?: Record<string, unknown> };
expect(parsed.providers?.openai).toBeUndefined();
expect(parsed.providers?.["auth-only"]).toBeDefined();
});
it("falls back to canonical env markers when provider runtime has no api-key policy", async () => {
const plan = await planOpenClawModelsJsonWithDeps(
{
cfg: { models: { providers: {} } },
agentDir: "/tmp/openclaw-models-config-env-vars-test",
env: { OPENAI_API_KEY: "sk-test" } as NodeJS.ProcessEnv,
existingRaw: "",
existingParsed: null,
},
{
resolveImplicitProviders: async () => ({
openai: createImplicitOpenAiProvider(),
}),
},
);
expect(plan.action).toBe("write");
if (plan.action !== "write") {
throw new Error("Expected models.json write plan");
}
const parsed = JSON.parse(plan.contents) as {
providers?: Record<string, { apiKey?: string }>;
};
expect(parsed.providers?.openai?.apiKey).toBe("OPENAI_API_KEY");
});
it("normalizes retired Gemini ids preserved from existing models.json rows", async () => {
const plan = await planOpenClawModelsJsonWithDeps(
{

View File

@@ -180,6 +180,37 @@ describe("models-config merge helpers", () => {
expect(merged["custom-proxy"]?.baseUrl).toBe("http://localhost:4000/v1");
});
it("drops stale invalid existing providers that would poison models.json", () => {
const merged = mergeWithExistingProviderSecrets({
nextProviders: {
openai: createConfigProvider(),
},
existingProviders: {
"claude-cli": {
api: "anthropic-messages",
models: [
createModel({
id: "claude-sonnet-4-6",
name: "Claude Sonnet",
reasoning: true,
}),
],
} as unknown as ExistingProviderConfig,
"auth-only": {
baseUrl: "https://auth.example/v1",
api: "openai-responses",
apiKey: preservedApiKey,
models: [],
} as ExistingProviderConfig,
},
secretRefManagedProviders: new Set<string>(),
});
expect(merged["claude-cli"]).toBeUndefined();
expect(merged["auth-only"]?.apiKey).toBe(preservedApiKey);
expect(merged.openai).toBeDefined();
});
it("preserves non-empty existing apiKey and baseUrl from models.json", () => {
const merged = mergeWithExistingProviderSecrets({
nextProviders: {

View File

@@ -212,6 +212,13 @@ function shouldPreserveExistingBaseUrl(params: {
return !existingApi || !nextApi || existingApi === nextApi;
}
function isExistingProviderSelfContained(entry: ExistingProviderConfig): boolean {
if (!Array.isArray(entry.models) || entry.models.length === 0) {
return true;
}
return Boolean(entry.baseUrl?.trim() && entry.apiKey);
}
export function mergeWithExistingProviderSecrets(params: {
nextProviders: Record<string, ProviderConfig>;
existingProviders: Record<string, ExistingProviderConfig>;
@@ -220,6 +227,9 @@ export function mergeWithExistingProviderSecrets(params: {
const { nextProviders, existingProviders, secretRefManagedProviders } = params;
const mergedProviders: Record<string, ProviderConfig> = {};
for (const [key, entry] of Object.entries(existingProviders)) {
if (!isExistingProviderSelfContained(entry)) {
continue;
}
mergedProviders[key] = entry;
}
for (const [key, newEntry] of Object.entries(nextProviders)) {

View File

@@ -105,6 +105,22 @@ function resolveProvidersForMode(params: {
});
}
function isWritableProviderConfig(provider: ProviderConfig): boolean {
if (!Array.isArray(provider.models) || provider.models.length === 0) {
return true;
}
return Boolean(provider.baseUrl?.trim() && provider.apiKey);
}
function filterWritableProviders(
providers: Record<string, ProviderConfig>,
): Record<string, ProviderConfig> {
const next = Object.fromEntries(
Object.entries(providers).filter(([, provider]) => isWritableProviderConfig(provider)),
);
return Object.keys(next).length === Object.keys(providers).length ? providers : next;
}
export async function planOpenClawModelsJsonWithDeps(
params: {
cfg: OpenClawConfig;
@@ -181,7 +197,9 @@ export async function planOpenClawModelsJsonWithDeps(
sourceSecretDefaults: params.sourceConfigForSecrets?.secrets?.defaults,
secretRefManagedProviders,
}) ?? normalizedMergedProviders;
const finalProviders = applyNativeStreamingUsageCompat(secretEnforcedProviders);
const finalProviders = applyNativeStreamingUsageCompat(
filterWritableProviders(secretEnforcedProviders),
);
const nextContents = `${JSON.stringify({ providers: finalProviders }, null, 2)}\n`;
if (params.existingRaw === nextContents) {

View File

@@ -287,13 +287,12 @@ export function resolveMissingProviderApiKey(params: {
const authMode = params.provider.auth;
if (params.providerApiKeyResolver && (!authMode || authMode === "aws-sdk")) {
const resolvedApiKey = params.providerApiKeyResolver(params.env);
if (!resolvedApiKey) {
return params.provider;
if (resolvedApiKey) {
return {
...params.provider,
apiKey: resolvedApiKey,
};
}
return {
...params.provider,
apiKey: resolvedApiKey,
};
}
if (authMode === "aws-sdk") {
const awsEnvVar = resolveAwsSdkApiKeyVarName(params.env);

View File

@@ -237,16 +237,15 @@ async function writeLiveGatewayConfig(params: {
},
},
},
// The Codex plugin owns the `codex/*` catalog/auth marker. Keeping the
// fixture on that provider proves the app-server harness path instead of
// exercising legacy OpenAI-Codex provider overrides.
// The Codex plugin owns the `codex/*` catalog/auth marker. Keeping runtime
// policy on the model entry proves the app-server harness path.
agents: {
defaults: {
workspace: params.workspace,
agentRuntime: { id: "codex" },
skipBootstrap: true,
timeoutSeconds: CODEX_HARNESS_AGENT_TIMEOUT_SECONDS,
model: { primary: params.modelKey },
models: { [params.modelKey]: { agentRuntime: { id: "codex" } } },
sandbox: { mode: "off" },
},
list: [
@@ -254,7 +253,6 @@ async function writeLiveGatewayConfig(params: {
id: "dev",
default: true,
workspace: params.workspace,
agentRuntime: { id: "codex" },
model: { primary: params.modelKey },
models: { [params.modelKey]: { agentRuntime: { id: "codex" } } },
},

View File

@@ -68,7 +68,10 @@ const REQUIRE_PROFILE_KEYS = isLiveProfileKeyModeEnabled();
const LIVE_CREDENTIAL_PRECEDENCE = REQUIRE_PROFILE_KEYS ? "profile-first" : "env-first";
const PROVIDERS = parseFilter(process.env.OPENCLAW_LIVE_GATEWAY_PROVIDERS);
const GATEWAY_LIVE_SMOKE = isTruthyEnvValue(process.env.OPENCLAW_LIVE_GATEWAY_SMOKE);
const THINKING_LEVEL = GATEWAY_LIVE_SMOKE ? "low" : "high";
const THINKING_LEVEL = resolveGatewayLiveThinkingLevel({
raw: process.env.OPENCLAW_LIVE_GATEWAY_THINKING,
smoke: GATEWAY_LIVE_SMOKE,
});
const ENABLE_EXTRA_TOOL_PROBES = !GATEWAY_LIVE_SMOKE;
const ENABLE_EXTRA_IMAGE_PROBES = !GATEWAY_LIVE_SMOKE;
const THINKING_TAG_RE = /<\s*\/?\s*(?:(?:antml:)?(?:think(?:ing)?|thought)|antthinking)\s*>/i;
@@ -1005,6 +1008,13 @@ describe("providerScopedModelRegistryProviders", () => {
});
describe("resolveGatewayLiveModelThinkingLevel", () => {
it("allows release lanes to lower gateway live thinking without smoke mode", () => {
expect(resolveGatewayLiveThinkingLevel({ raw: "low", smoke: false })).toBe("low");
expect(resolveGatewayLiveThinkingLevel({ raw: undefined, smoke: false })).toBe("high");
expect(resolveGatewayLiveThinkingLevel({ raw: undefined, smoke: true })).toBe("low");
expect(resolveGatewayLiveThinkingLevel({ raw: "wat", smoke: false })).toBe("high");
});
it("clamps requested thinking to levels supported by model metadata", () => {
expect(
resolveGatewayLiveModelThinkingLevel({
@@ -2096,6 +2106,18 @@ function resolveGatewayLiveModelThinkingLevel(params: {
return clampThinkingLevel(model, normalized);
}
function resolveGatewayLiveThinkingLevel(params: { raw?: string; smoke: boolean }): string {
const raw = params.raw?.trim().toLowerCase();
if (!raw) {
return params.smoke ? "low" : "high";
}
return ["off", "minimal", "low", "medium", "high", "xhigh"].includes(raw)
? raw
: params.smoke
? "low"
: "high";
}
function buildLiveGatewayConfig(params: {
cfg: OpenClawConfig;
candidates: Array<Model>;

View File

@@ -405,6 +405,56 @@ describe("resolvePluginDiscoveryProvidersRuntime", () => {
});
});
it("ignores manifest model catalogs that cannot form valid models.json providers", () => {
mocks.resolveDiscoveredProviderPluginIds.mockReturnValue(["anthropic"]);
mocks.loadPluginMetadataSnapshot.mockReturnValue({
index: { plugins: [] },
manifestRegistry: {
plugins: [
{
...createManifestPluginWithModelCatalog("anthropic"),
modelCatalog: {
providers: {
"claude-cli": {
models: [
{
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
reasoning: true,
input: ["text"],
contextWindow: 200000,
maxTokens: 64000,
},
],
},
anthropic: {
baseUrl: "https://api.anthropic.com",
api: "anthropic-messages",
models: [
{
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
reasoning: true,
input: ["text"],
contextWindow: 200000,
maxTokens: 64000,
},
],
},
},
discovery: { "claude-cli": "static", anthropic: "static" },
},
},
],
diagnostics: [],
},
});
const providers = resolvePluginDiscoveryProvidersRuntime({ discoveryEntriesOnly: true });
expect(providers.map((provider) => provider.id)).toEqual(["anthropic"]);
});
it("keeps manifest catalogs and loads only scoped plugins that have no entry", () => {
const dynamicProvider = createProvider({ id: "minimax", mode: "catalog" });
mocks.resolveDiscoveredProviderPluginIds.mockReturnValue(["minimax", "openai"]);

View File

@@ -134,6 +134,9 @@ function providerConfigFromManifestRows(
rows: readonly NormalizedModelCatalogRow[],
): ModelProviderConfig | undefined {
const firstRow = rows[0];
if (!firstRow?.baseUrl || !firstRow.api) {
return undefined;
}
const models = rows
.map((row) => modelDefinitionFromManifestRow(row))
.filter((model): model is ModelDefinitionConfig => Boolean(model));