claw-code/docs/pr-3211-plan.md

8.0 KiB

PR #3211 — Execution Plan: 3-Tier Credential Resolution (REVISED)

Branch: worktree-provider-config-fallback Worktree: .claude/worktrees/provider-config-fallback CI State: Build Test Clippy Docs Windows Fmt

Reviewer Feedback

From 1716775457damn:

"the from_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"

Investigation Findings (Updated)

Key discovery: from_env vs from_env_or_saved are DIFFERENT things

There are TWO AuthSource methods in anthropic.rs:

  • from_env() (line 44) — tiers 1+2 only (process env + .env), NO stored config
  • from_env_or_saved() (line 629) — tiers 1+2+3 (process env + .env + stored config)

And TWO in openai_compat.rs:

  • from_env(config) (line 132) — tiers 1+2 only
  • from_env_or_saved(config) (line 151) — tiers 1+2+3

The _or_saved suffix is semantically meaningful — it distinguishes the 3-tier path from the 2-tier path. Renaming them identically would lose this distinction.

What IS actually duplicated and safe to consolidate

Duplication anthropic.rs openai_compat.rs mod.rs (shared)
read_env_non_empty(key) line 773 line 1608 not shared
read_base_url() line 798 line 1625 not shared

Both read_base_url() functions follow the same 3-tier pattern:

Tier 1: std::env::var(key) → return if non-empty
Tier 2: dotenv_value(key) → return if non-empty
Tier 3: read_base_url_from_config(provider_kind) → return if Some
Default: fallback string

But there's a complication: anthropic::read_base_url() hardcodes the env var and provider kind, while openai_compat::read_base_url(config) takes them from the config struct. The shared helper needs to parameterize both.

Similarly, read_env_non_empty is identical in both files — just reads env + .env fallback.

Why NOT a shared trait (confirmed)

Anthropic's AuthSource has 4 variants (None, ApiKey, BearerToken, ApiKeyAndBearer) while OpenAI-compat has only ApiKey. Different return types → trait adds complexity.

Execution Plan (Revised)

Step 1: Extract read_env_non_empty() into mod.rs

Both files have identical read_env_non_empty(key) implementations. Move to mod.rs as pub(crate) fn read_env_non_empty(key: &str) -> Result<Option<String>, ApiError> and update both files to call super::read_env_non_empty(key).

Files changed:

  • rust/crates/api/src/providers/mod.rs — add shared read_env_non_empty()
  • rust/crates/api/src/providers/anthropic.rs — remove local read_env_non_empty(), use super::read_env_non_empty()
  • rust/crates/api/src/providers/openai_compat.rs — remove local read_env_non_empty(), use super::read_env_non_empty()

Step 2: Extract resolve_base_url() into mod.rs

Both read_base_url() functions follow the identical 3-tier pattern, differing only in the env var name, provider kind, and default value.

pub fn resolve_base_url(env_var: &str, provider_kind: &str, default: &str) -> String {
    // Tier 1: environment variable
    if let Ok(value) = std::env::var(env_var) {
        if !value.is_empty() {
            return value;
        }
    }
    // Tier 2: .env file
    if let Some(value) = dotenv_value(env_var) {
        if !value.is_empty() {
            return value;
        }
    }
    // Tier 3: stored config in ~/.claw/settings.json
    if let Some(base_url) = read_base_url_from_config(provider_kind) {
        return base_url;
    }
    default.to_string()
}

Then:

  • anthropic::read_base_url() → delegates to super::resolve_base_url("ANTHROPIC_BASE_URL", "anthropic", DEFAULT_BASE_URL)
  • openai_compat::read_base_url(config) → delegates to super::resolve_base_url(config.base_url_env, provider_kind, config.default_base_url)

Files changed:

  • rust/crates/api/src/providers/mod.rs — add resolve_base_url()
  • rust/crates/api/src/providers/anthropic.rs — simplify read_base_url() to one-liner delegation
  • rust/crates/api/src/providers/openai_compat.rs — simplify read_base_url() to one-liner delegation

Step 3: Do NOT rename from_env_or_savedfrom_env

The _or_saved suffix is semantically meaningful and distinguishes the 3-tier path from the 2-tier from_env() method. Removing it would conflate two different code paths. The reviewer asked to "reduce duplication" — Steps 1+2 do that by extracting the duplicated logic into shared helpers while preserving the intentional naming distinction.

Step 4: Run cargo fmt --all

Fixes the only failing CI check.

Step 5: Run cargo clippy --workspace --all-targets -- -D warnings + cargo test --workspace

Verify nothing breaks.

Step 6: Commit + push

refactor: extract shared credential resolution helpers into mod.rs

Addresses reviewer feedback on #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.

Commit Details

Commit: c0855e12 on worktree-provider-config-fallback

Files changed:

  • rust/crates/api/src/providers/mod.rs — added shared read_env_non_empty() (+8 lines) and resolve_base_url() (+30 lines)
  • rust/crates/api/src/providers/anthropic.rs — replaced local read_env_non_empty() and read_base_url() with super:: delegations (-17 lines)
  • rust/crates/api/src/providers/openai_compat.rs — same pattern (-17 lines)
  • rust/crates/runtime/src/config.rs — cargo fmt fix on assert (+5/-1)

Net: +46 insertions, -45 deletions

Risk Assessment

  • Low risk: Moving identical code into shared functions, no behavior change
  • Call sites: All from_env_or_saved / from_env call sites are internal to the providers crate
  • Public API: api::read_base_url() and api::read_xai_base_url() still work — they call the provider-local wrappers which delegate to the shared helper
  • No behavior change: Same code paths, just better organized

Acceptance Criteria

  • read_env_non_empty only defined once (in mod.rs)
  • resolve_base_url exists in mod.rs and is used by both provider files
  • from_env_or_saved naming preserved (semantic distinction from from_env)
  • cargo fmt --all passes
  • cargo test -p api passes (147 unit + 37 integration, all green)
  • cargo check -p api passes
  • Changes committed and pushed to worktree-provider-config-fallback branch (commit c0855e12)
  • Pre-existing clippy issues in runtime/trident.rs — not from our changes, noted