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

178 lines
8.0 KiB
Markdown

# 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.
```rust
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_saved` → `from_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
- [x] `read_env_non_empty` only defined once (in `mod.rs`)
- [x] `resolve_base_url` exists in `mod.rs` and is used by both provider files
- [x] `from_env_or_saved` naming preserved (semantic distinction from `from_env`)
- [x] `cargo fmt --all` passes
- [x] `cargo test -p api` passes (147 unit + 37 integration, all green)
- [x] `cargo check -p api` passes
- [x] 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