fix(models-config): let config baseUrl/apiKey override agent models.json in merge mode #37331#38518
fix(models-config): let config baseUrl/apiKey override agent models.json in merge mode #37331#38518maweibin wants to merge 3 commits intoopenclaw:mainfrom
Conversation
There was a problem hiding this comment.
💡 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".
| const configHasBaseUrl = | ||
| typeof (newEntry as { baseUrl?: string }).baseUrl === "string" && | ||
| (newEntry as { baseUrl?: string }).baseUrl !== ""; |
There was a problem hiding this comment.
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 SummaryThis PR fixes a merge-mode precedence bug where config Key changes:
Issues found:
Confidence Score: 3/5
Last reviewed commit: 25bbfcc |
| 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"); | ||
| }); |
There was a problem hiding this 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.
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.| 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 !== ""; |
There was a problem hiding this 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":
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.There was a problem hiding this comment.
💡 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".
| 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 !== ""; |
There was a problem hiding this comment.
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 👍 / 👎.
|
Thanks for the patch. Dedup triage outcome:
Closing this as superseded by #39103 for the current merge target. |
Fixes #37309
Problem
In
models.mode: "merge", updatingbaseUrl(orapiKey) inopenclaw.jsonand restarting the gateway had no effect: the agent kept using the values stored in.openclaw/agent/.../models.json. Users had to manually edit the hiddenmodels.jsonto correct a wrong URL.Cause
mergeWithExistingProviderSecretsalways preserved non-emptyapiKey/baseUrlfrom the existing agentmodels.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/baseUrlwhen config does not provide a non-empty value. When config provides a non-emptybaseUrlorapiKey, config wins. Thus:openclaw.jsonand restarting takes effect.Changes
mergeWithExistingProviderSecrets, preserve existingapiKey/baseUrlonly when the corresponding value from config is missing or empty.models.modehelp text to describe the new merge behavior.Testing
pnpm test -- src/agents/models-config --run(71 tests) passes.