Skip to content

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

Open
TheArchitectit wants to merge 2 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 2 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.

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.
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