Skip to content

feat: 3-tier provider credential resolution (env var → .env → stored config)#3211

Open
TheArchitectit wants to merge 3 commits into
ultraworkers:mainfrom
TheArchitectit:worktree-provider-config-fallback
Open

feat: 3-tier provider credential resolution (env var → .env → stored config)#3211
TheArchitectit wants to merge 3 commits into
ultraworkers:mainfrom
TheArchitectit:worktree-provider-config-fallback

Conversation

@TheArchitectit
Copy link
Copy Markdown
Contributor

Problem

PR #3017 merged setup_wizard.rs which saves provider config (kind, apiKey, baseUrl, model) to ~/.claw/settings.json. However, no provider ever reads that stored config back. The credential resolution path is purely env-var-based:

  • AuthSource::from_env_or_saved() — Despite its name, never reads from stored config
  • resolve_startup_auth_source() — Discards its OAuth config callback (let _ = load_oauth_config;) and reads only env vars
  • OpenAiCompatClient::from_env() — Reads only from env vars
  • read_base_url() (both providers) — Reads only from env vars

Result: Users who run the setup wizard see no effect. The wizard is dead code.

Solution

Implement a 3-tier credential resolution chain:

Tier Source Priority
1 Environment variable Highest (immediate override)
2 .env file in current working directory Medium
3 Stored config in ~/.claw/settings.json Lowest (fallback)

How it works

read_env_or_config("ANTHROPIC_API_KEY", "anthropic")
  ├── Tier 1: std::env::var("ANTHROPIC_API_KEY")  → found? return it
  ├── Tier 2: dotenv_value("ANTHROPIC_API_KEY")    → found? return it
  └── Tier 3: settings.json provider.apiKey         → kind=="anthropic"? return it

The stored provider kind must match the provider being resolved for — if settings.json has kind: "xai" and we are resolving credentials for the Anthropic provider, tier 3 returns None. This prevents cross-provider credential leakage.

Changes

New types

  • RuntimeProviderConfig — struct with kind, api_key, base_url fields parsed from the "provider" JSON object in settings.json
  • RuntimeConfig::provider() — accessor for stored provider config
  • parse_optional_provider_config() — extracts provider section from merged settings

Updated functions

  • AuthSource::from_env_or_saved() — Now actually reads from stored config (tier 3) when env vars are absent. Previously the "or_saved" name was aspirational — the function was identical to from_env().
  • resolve_startup_auth_source() — Same 3-tier treatment. No longer discards the OAuth config callback (kept for API compatibility).
  • has_auth_from_env_or_saved() — Also checks stored config
  • read_base_url() (anthropic.rs) — 3-tier base URL resolution
  • read_base_url() (openai_compat.rs) — 3-tier base URL resolution

New functions

  • read_env_or_config() (mod.rs) — Core 3-tier credential resolution
  • read_base_url_from_config() (mod.rs) — Base URL resolution from stored config
  • OpenAiCompatClient::from_env_or_saved() — 3-tier resolution for OpenAI/xAI/DashScope providers (analogous to the Anthropic path)

Tests

3 new tests for RuntimeProviderConfig parsing:

  • provider_config_default_is_empty_when_unset — empty settings → all fields None
  • provider_config_parses_kind_api_key_and_base_url — full provider section parsed correctly
  • provider_config_handles_partial_provider_object — only kind+apiKey, no baseUrl

Diff verification

5 files changed, 349 insertions(+), 5 deletions(-)

All additions, no deletions. No upstream commits are reverted. The diff is feature-only.

Settings.json structure

{
  "provider": {
    "kind": "anthropic",
    "apiKey": "sk-ant-...",
    "baseUrl": "https://api.anthropic.com"
  },
  "model": "sonnet"
}

This is exactly the format that save_user_provider_settings() (already on main from PR #3017) writes.

Testing

# Set up via wizard
claw setup  # or /setup from REPL

# Verify credentials resolve from settings.json (no env vars needed)
unset ANTHROPIC_API_KEY
claw --model sonnet  # should use stored config

# Verify env var still takes priority
export ANTHROPIC_API_KEY=sk-different
claw --model sonnet  # should use env var, not stored config

…config)

Before this change, the setup wizard (PR ultraworkers#3017) saved provider config
to ~/.claw/settings.json, but no provider ever read it back. The
wizard was dead code: users who ran it saw no effect.

This commit implements the 3-tier credential resolution chain that
makes stored provider config functional:

1. Environment variable (highest priority, immediate override)
   - ANTHROPIC_API_KEY, OPENAI_API_KEY, XAI_API_KEY, DASHSCOPE_API_KEY
   - ANTHROPIC_BASE_URL, OPENAI_BASE_URL, etc.

2. .env file in the current working directory (via existing dotenv_value)

3. Stored provider config in ~/.claw/settings.json (lowest priority)
   - Reads provider.kind, provider.apiKey, provider.baseUrl
   - Only returned when stored kind matches the provider being resolved

Changes:
- RuntimeProviderConfig: new struct with kind/api_key/base_url fields
  parsed from the 'provider' JSON object in settings.json
- RuntimeFeatureConfig: added provider field
- RuntimeConfig::provider(): accessor for stored provider config
- parse_optional_provider_config(): extracts provider section from
  merged settings
- read_env_or_config() in mod.rs: core 3-tier resolution function
- read_base_url_from_config() in mod.rs: base URL resolution from
  stored config
- AuthSource::from_env_or_saved() in anthropic.rs: now actually reads
  from stored config (tier 3) when env vars are absent
- resolve_startup_auth_source(): same 3-tier treatment
- has_auth_from_env_or_saved(): also checks stored config
- read_base_url() in anthropic.rs: 3-tier base URL resolution
- OpenAiCompatClient::from_env_or_saved(): new method with 3-tier
  resolution for OpenAI/xAI/DashScope providers
- read_base_url() in openai_compat.rs: 3-tier base URL resolution
- 3 new tests for RuntimeProviderConfig parsing

The setup wizard saves settings that providers now actually consume.
Users without env vars can configure providers entirely through the
wizard or by editing ~/.claw/settings.json manually.
@1716775457damn
Copy link
Copy Markdown

This makes the setup wizard actually functional — good catch that #3017 saved config but nothing read it. The 3-tier priority (env var → .env → stored) is well thought out, and covering all four providers (Anthropic, OpenAI, xAI, DashScope) with both API key and base URL resolution is thorough.\n\nMinor concern: the rom_env_or_saved naming across multiple provider files could be consolidated into a shared trait or helper to reduce duplication. Also, cargo fmt is failing — a quick cargo fmt --all before merge will fix that.

TheArchitectit and others added 2 commits June 3, 2026 09:06
Addresses reviewer feedback on ultraworkers#3211 requesting consolidation of
duplicated credential-resolution logic across provider files.

Changes:

1. Extract read_env_non_empty() into mod.rs
   - Previously duplicated identically in anthropic.rs (line 773)
     and openai_compat.rs (line 1608).
   - Both copies read a process env var, fall back to .env via
     dotenv_value(), and return Result<Option<String>, ApiError>.
   - Now defined once as pub(crate) in mod.rs; both provider files
     call super::read_env_non_empty() instead.

2. Extract resolve_base_url() into mod.rs
   - Both anthropic::read_base_url() and openai_compat::read_base_url()
     implemented the same 3-tier base URL resolution:
       Tier 1: process env var (e.g. ANTHROPIC_BASE_URL)
       Tier 2: .env file via dotenv_value()
       Tier 3: stored config via read_base_url_from_config()
       Default: provider-specific fallback string
   - New shared helper: resolve_base_url(env_var, provider_kind, default)
   - anthropic::read_base_url() now delegates to
     super::resolve_base_url("ANTHROPIC_BASE_URL", "anthropic", DEFAULT_BASE_URL)
   - openai_compat::read_base_url(config) now delegates to
     super::resolve_base_url(config.base_url_env, provider_kind, config.default_base_url)

3. Preserve from_env_or_saved() naming
   - The "_or_saved" suffix distinguishes the 3-tier credential path
     (env + .env + stored config) from the 2-tier from_env() path
     (env + .env only). Both methods exist on the same AuthSource type
     and serve different callers. Renaming would lose this semantic
     distinction.

CI: cargo fmt --all run to fix the formatting check failure.
…lpers

Resolved merge conflicts between worktree-provider-config-fallback and
upstream/main while preserving:

1. Shared credential helpers in rust/crates/api/src/providers/mod.rs:
   - read_env_non_empty() — extracted from anthropic.rs + openai_compat.rs
   - resolve_base_url() — 3-tier URL resolution shared across providers

2. RuntimeProviderConfig in rust/crates/runtime/src/config.rs:
   - struct with kind, api_key, base_url fields
   - impl block with from_provider_object(), kind(), api_key(), base_url(), is_set()
   - save_user_provider_settings() and clear_user_provider_settings() helpers
   - All provider_config_* tests

Also merged upstream additions:
- RulesImportConfig enum + impl with should_import()
- RuntimeHookCommand struct + RuntimeHookConfig hooks updates
- All rules_import_* tests
- ConfigFileStatus/ConfigFileReport/ConfigInspection structured reporting

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@TheArchitectit
Copy link
Copy Markdown
Contributor Author

Update: resolved reviewer feedback + merged upstream/main

Ask 1 — Consolidate from_env_or_saved-adjacent duplication

Addressed in commit c0855e1 (extracted shared helpers into mod.rs):

  • read_env_non_empty(key: &str) — moved from anthropic.rs (line 773) and openai_compat.rs (line 1608) into api/src/providers/mod.rs as pub(crate). Both files now call super::read_env_non_empty(key). Eliminates the identical env-var + .env fallback logic that was duplicated across every provider.
  • resolve_base_url(env_var, provider_kind, default) — moved from both providers' read_base_url() into mod.rs as pub. The 3-tier base URL resolution (env → .env → stored config) now lives once. Call sites:
    • anthropic::read_base_url()super::resolve_base_url("ANTHROPIC_BASE_URL", "anthropic", DEFAULT_BASE_URL)
    • openai_compat::read_base_url(config)super::resolve_base_url(config.base_url_env, provider_kind, config.default_base_url)

Not consolidated: The from_env vs from_env_or_saved method names themselves. These are semantically different — from_env checks tiers 1+2 only, while from_env_or_saved checks tiers 1+2+3 (incl. stored ~/.claw/settings.json). Collapsing the names would obscure which tier path a caller gets. The shared helpers extracted above eliminate the actual duplication without losing the naming distinction.

Ask 2 — cargo fmt

cargo fmt --all run in c0855e1.

Additional: merge upstream/main

The branch had gone stale against upstream. Merged in 67da2dc while preserving the helpers above. Conflicts were in:

  • runtime/src/lib.rs — combined upstream's RulesImportConfig/RuntimeHookCommand exports with our RuntimeProviderConfig
  • runtime/src/config.rs — merged upstream's ConfigFileStatus/ConfigFileReport/ConfigInspection structured config reporting with our RuntimeProviderConfig + RulesImportConfig; manually reconstructed interleaved test bodies

CI: cargo check -p api ✅, cargo check -p runtime ✅. Pre-existing clippy issues in trident.rs/claw-rag-service are unchanged and not from this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants