Skip to content

fix(models-config): let config baseUrl/apiKey override agent models.json in merge mode #37331#38518

Closed
maweibin wants to merge 3 commits intoopenclaw:mainfrom
maweibin:fix/37309-models-json-config-baseurl-wins
Closed

fix(models-config): let config baseUrl/apiKey override agent models.json in merge mode #37331#38518
maweibin wants to merge 3 commits intoopenclaw:mainfrom
maweibin:fix/37309-models-json-config-baseurl-wins

Conversation

@maweibin
Copy link
Copy Markdown
Contributor

@maweibin maweibin commented Mar 7, 2026

Fixes #37309

Problem

In models.mode: "merge", updating baseUrl (or apiKey) in openclaw.json and restarting the gateway had no effect: the agent kept using the values stored in .openclaw/agent/.../models.json. Users had to manually edit the hidden models.json to correct a wrong URL.

Cause

mergeWithExistingProviderSecrets always preserved non-empty apiKey/baseUrl from the existing agent models.json, overwriting the values coming from config, so config was never able to override once agent state existed.

Solution

In merge mode, only preserve agent apiKey/baseUrl when config does not provide a non-empty value. When config provides a non-empty baseUrl or apiKey, config wins. Thus:

  • Correcting openclaw.json and restarting takes effect.
  • When config omits or leaves these fields empty, existing agent values are still preserved.

Changes

  • src/agents/models-config.ts: In mergeWithExistingProviderSecrets, preserve existing apiKey/baseUrl only when the corresponding value from config is missing or empty.
  • src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts: Renamed and updated the test that had expected agent-to-win; added a test that agent values are preserved when config provides empty strings.
  • src/config/schema.help.ts: Updated models.mode help text to describe the new merge behavior.

Testing

  • pnpm test -- src/agents/models-config --run (71 tests) passes.

@openclaw-barnacle openclaw-barnacle bot added agents Agent runtime and tooling size: S labels Mar 7, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 25bbfcc801

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +167 to +169
const configHasBaseUrl =
typeof (newEntry as { baseUrl?: string }).baseUrl === "string" &&
(newEntry as { baseUrl?: string }).baseUrl !== "";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Check explicit config fields before overriding preserved baseUrl

newEntry at this point already includes implicit provider defaults from resolveProvidersForModelsJson (it merges implicit + explicit providers), so this configHasBaseUrl check is true even when openclaw.json does not define models.providers.<id>.baseUrl. In merge mode that suppresses preserved.baseUrl, so restarting can overwrite an existing agent models.json endpoint with the implicit default for discovered providers (for example providers injected from env/profile), which regresses the intended "preserve when config omits value" behavior. The override check needs to key off explicit config presence, not just non-empty newEntry.baseUrl.

Useful? React with 👍 / 👎.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 7, 2026

Greptile Summary

This PR fixes a merge-mode precedence bug where config apiKey/baseUrl values were always overridden by whatever was stored in the agent's models.json, making it impossible to correct a wrong URL or key by editing openclaw.json. The fix in mergeWithExistingProviderSecrets now lets config win whenever it supplies a non-empty value, and only falls back to the agent's stored value when config omits or empties the field.

Key changes:

  • src/agents/models-config.tsmergeWithExistingProviderSecrets now checks whether the incoming config entry carries a non-empty apiKey/baseUrl before deciding to preserve the agent's existing value.
  • src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts — renamed test to match the new expected behavior; added a companion test asserting agent values are preserved when config supplies empty strings.
  • src/config/schema.help.ts — updated models.mode help text to document the new precedence rules.

Issues found:

  • The configHasApiKey guard uses typeof ... === "string", which returns false for SecretRef objects (e.g., { env: "..." } form). Since SecretInputSchema is z.union([z.string(), SecretRefSchema]), a config entry using a secret reference would incorrectly evaluate as "no value provided," causing the agent's old string apiKey to be preserved. See inline comment on models-config.ts.
  • The new "preserves agent values when config is empty" test passes baseUrl: "" directly, bypassing schema validation. ModelProviderSchema enforces z.string().min(1) for baseUrl, so this branch is unreachable in production via normal config loading. The apiKey: "" half of the test is valid.

Confidence Score: 3/5

  • Safe to merge for the common plain-string apiKey case; users relying on SecretRef-form apiKeys in merge mode will hit a residual bug.
  • The core fix is logically correct and well-tested for string-typed values. However, the typeof ... === "string" guard misses the SecretRef object form that the schema explicitly permits for apiKey, meaning the very override behavior this PR introduces would silently not apply for that input style.
  • src/agents/models-config.ts lines 164–169 — the configHasApiKey check needs to handle non-string (SecretRef) values.

Last reviewed commit: 25bbfcc

Comment on lines +223 to 263
it("preserves agent apiKey/baseUrl when config values are empty", async () => {
await withTempHome(async () => {
await writeAgentModelsJson({
providers: {
custom: {
baseUrl: "https://agent.example/v1",
apiKey: "AGENT_KEY",
api: "openai-responses",
models: [{ id: "agent-model", name: "Agent model", input: ["text"] }],
},
},
});
await ensureOpenClawModelsJson({
models: {
mode: "merge",
providers: {
custom: {
baseUrl: "",
apiKey: "",
api: "openai-responses",
models: [
{
id: "config-model",
name: "Config model",
input: ["text"],
reasoning: false,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8192,
maxTokens: 2048,
},
],
},
},
},
});
const parsed = await readGeneratedModelsJson<{
providers: Record<string, { apiKey?: string; baseUrl?: string }>;
}>();
expect(parsed.providers.custom?.apiKey).toBe("AGENT_KEY");
expect(parsed.providers.custom?.baseUrl).toBe("https://agent.example/v1");
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test exercises unreachable baseUrl: "" path

The new test passes baseUrl: "" directly to ensureOpenClawModelsJson, bypassing schema validation. In production, ModelProviderSchema enforces baseUrl: z.string().min(1), so an empty string is rejected before it can reach mergeWithExistingProviderSecrets. The configHasBaseUrl empty-string branch in the implementation is therefore unreachable via the normal config loading path.

The apiKey: "" half of the test is valid (no .min(1) constraint on apiKey). The more realistic scenario to test for baseUrl would be omitting the field entirely, which also results in configHasBaseUrl = false and agent-value preservation.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts
Line: 223-263

Comment:
**Test exercises unreachable `baseUrl: ""` path**

The new test passes `baseUrl: ""` directly to `ensureOpenClawModelsJson`, bypassing schema validation. In production, `ModelProviderSchema` enforces `baseUrl: z.string().min(1)`, so an empty string is rejected before it can reach `mergeWithExistingProviderSecrets`. The `configHasBaseUrl` empty-string branch in the implementation is therefore unreachable via the normal config loading path.

The `apiKey: ""` half of the test is valid (no `.min(1)` constraint on `apiKey`). The more realistic scenario to test for `baseUrl` would be omitting the field entirely, which also results in `configHasBaseUrl = false` and agent-value preservation.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +164 to +169
const configHasApiKey =
typeof (newEntry as { apiKey?: string }).apiKey === "string" &&
(newEntry as { apiKey?: string }).apiKey !== "";
const configHasBaseUrl =
typeof (newEntry as { baseUrl?: string }).baseUrl === "string" &&
(newEntry as { baseUrl?: string }).baseUrl !== "";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SecretRef-typed apiKey treated as absent

typeof ... === "string" returns false when apiKey is a SecretRef object — the { env: "..." } form that SecretInputSchema permits as a valid value. When that happens, configHasApiKey evaluates to false and the agent's existing string value is preserved, silently ignoring the user's config — which is the exact class of bug this PR is intended to fix.

SecretInputSchema is defined as z.union([z.string(), SecretRefSchema]), so both forms are valid config values. The check should accept any non-nullish, non-empty-string value rather than requiring typeof ... === "string":

const rawVal = (newEntry as { apiKey?: unknown }).apiKey;
const configHasApiKey = rawVal !== undefined && rawVal !== null && rawVal !== "";

baseUrl is fine as-is because ModelProviderSchema constrains it to z.string().min(1), so the string type check is sufficient there.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/models-config.ts
Line: 164-169

Comment:
**`SecretRef`-typed `apiKey` treated as absent**

`typeof ... === "string"` returns `false` when `apiKey` is a `SecretRef` object — the `{ env: "..." }` form that `SecretInputSchema` permits as a valid value. When that happens, `configHasApiKey` evaluates to `false` and the agent's existing string value is preserved, silently ignoring the user's config — which is the exact class of bug this PR is intended to fix.

`SecretInputSchema` is defined as `z.union([z.string(), SecretRefSchema])`, so both forms are valid config values. The check should accept any non-nullish, non-empty-string value rather than requiring `typeof ... === "string"`:

```typescript
const rawVal = (newEntry as { apiKey?: unknown }).apiKey;
const configHasApiKey = rawVal !== undefined && rawVal !== null && rawVal !== "";
```

`baseUrl` is fine as-is because `ModelProviderSchema` constrains it to `z.string().min(1)`, so the string type check is sufficient there.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ae58599b5b

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +165 to +169
typeof (newEntry as { apiKey?: string }).apiKey === "string" &&
(newEntry as { apiKey?: string }).apiKey !== "";
const configHasBaseUrl =
typeof (newEntry as { baseUrl?: string }).baseUrl === "string" &&
(newEntry as { baseUrl?: string }).baseUrl !== "";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat whitespace-only overrides as missing in merge mode

In merge mode, configHasApiKey/configHasBaseUrl currently use !== "", so values like ' ' are treated as explicit overrides instead of empty. When an existing models.json provider has working credentials/endpoints and config contains whitespace-only apiKey/baseUrl (for example from templating or copy/paste), this branch suppresses preservation and writes unusable values, causing auth/endpoint failures after restart. The emptiness check should normalize/trim before deciding whether config intentionally overrides stored secrets.

Useful? React with 👍 / 👎.

@steipete
Copy link
Copy Markdown
Contributor

steipete commented Mar 7, 2026

Thanks for the patch.

Dedup triage outcome:

Closing this as superseded by #39103 for the current merge target.

@steipete steipete closed this Mar 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling size: S

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Cannot update vLLM base URL; agent caches old config in hidden models.json

2 participants