Adds both OpenAI codecs — chat completions and the newer responses API — plus the OpenAiProvider and registry wiring, so the same session can run against OpenAI in addition to Anthropic.
Adds harness-providers::auth, a persisted auth.json credential store for provider tokens, laying the groundwork for Copilot's device-flow login and token refresh.
Extends the model catalog with per-token pricing and accumulates cost per engine step, threading the running total through the store and up into harness-app so it can be surfaced in the UI.
Adds a bundled models.dev snapshot plus harness-providers::modelsdev to resolve model metadata, and wires live cost display into the TUI's input bar and message state, updating the render snapshots accordingly.
Adds the Copilot provider: device-flow login modal support, copilot_internal/v2/token exchange with a direct-Bearer fallback, and routing across the three codecs, per the dual-path mitigation noted for Copilot token variance.
darman
requested review from claude-reviewer 2026-07-11 14:42:03 +02:00
Lots of surface here and it's mostly in great shape: the cost math (ModelCost::cost_of) is correct and explicitly documents the reasoning-tokens-are-in-output subtlety, models.dev has a clean cache→baked-snapshot fallback, the device-flow state machine is pure and thoroughly tested, and the Copilot codec routing is nicely factored. Two things worth fixing, one security-flavored.
The Err(_) arm catches all errors, including TokenError::Network (timeout, DNS blip), and caches a never-expiring (expires_at: 0) direct-Bearer token. Because needs_refresh is always false for expires_at == 0, a single transient failure during the first exchange pins the provider to direct-Bearer mode for the entire process lifetime — even though the exchange endpoint is actually supported. The sticky fallback should be reserved for TokenError::ExchangeUnsupported; on Network/Unexpected, don't cache — let the next token() call retry the exchange. The doc comment ("when the exchange endpoint rejects the request") already describes the intended narrower behavior.
auth.json secret is briefly world-readable during write (low-medium)
AuthStorage::write (auth.rs:624):
std::fs::write(&tmp,&json)?;// created at umask default, typically 0644
set_owner_only(&tmp)?;// chmod 0600 *after* the secret is on disk
std::fs::rename(&tmp,&self.path)?;
There's a window where the temp file containing the OAuth/API secret exists at 0644 before the chmod lands. Create it 0600 from the start via OpenOptions::new().write(true).create_new(true).mode(0o600) (unix), and consider create_dir_all + chmod 0700 on the parent directory so the file isn't sitting in a world-readable dir either.
Minor
openai.rs: flavor_for(&req.model) is computed twice (once to pick the path/body, once to pick the decoder). Harmless but easy to compute once and reuse.
Copilot exchange returns TokenError::Network for any non-success status that isn't 401/403/404 (e.g. 500). Combined with the fix above, make sure a 5xx from the exchange endpoint retries rather than sticking to fallback.
Nice test coverage throughout (auth 0600 assertion, cost-of-usage, cache TTL, poll state machine).
— automated review (Claude)
## Review: M3 — All providers + auth
Lots of surface here and it's mostly in great shape: the cost math (`ModelCost::cost_of`) is correct and explicitly documents the reasoning-tokens-are-in-output subtlety, models.dev has a clean cache→baked-snapshot fallback, the device-flow state machine is pure and thoroughly tested, and the Copilot codec routing is nicely factored. Two things worth fixing, one security-flavored.
### Copilot: transient network error permanently disables token exchange (medium)
`TokenProvider::token` (`copilot/token.rs:2321`):
```rust
let fresh = match exchange(&self.client, &self.oauth_token).await {
Ok(tok) => tok,
Err(_) => CopilotToken { token: self.oauth_token.clone(), expires_at: 0 },
};
*guard = Some(fresh.clone());
```
The `Err(_)` arm catches **all** errors, including `TokenError::Network` (timeout, DNS blip), and caches a never-expiring (`expires_at: 0`) direct-Bearer token. Because `needs_refresh` is always false for `expires_at == 0`, a single transient failure during the first exchange pins the provider to direct-Bearer mode for the entire process lifetime — even though the exchange endpoint is actually supported. The sticky fallback should be reserved for `TokenError::ExchangeUnsupported`; on `Network`/`Unexpected`, don't cache — let the next `token()` call retry the exchange. The doc comment ("when the exchange endpoint rejects the request") already describes the intended narrower behavior.
### auth.json secret is briefly world-readable during write (low-medium)
`AuthStorage::write` (`auth.rs:624`):
```rust
std::fs::write(&tmp, &json)?; // created at umask default, typically 0644
set_owner_only(&tmp)?; // chmod 0600 *after* the secret is on disk
std::fs::rename(&tmp, &self.path)?;
```
There's a window where the temp file containing the OAuth/API secret exists at 0644 before the chmod lands. Create it 0600 from the start via `OpenOptions::new().write(true).create_new(true).mode(0o600)` (unix), and consider `create_dir_all` + chmod `0700` on the parent directory so the file isn't sitting in a world-readable dir either.
### Minor
- `openai.rs`: `flavor_for(&req.model)` is computed twice (once to pick the path/body, once to pick the decoder). Harmless but easy to compute once and reuse.
- Copilot `exchange` returns `TokenError::Network` for any non-success status that isn't 401/403/404 (e.g. 500). Combined with the fix above, make sure a 5xx from the exchange endpoint retries rather than sticking to fallback.
Nice test coverage throughout (auth 0600 assertion, cost-of-usage, cache TTL, poll state machine).
— automated review (Claude)
You are not authorized to merge this pull request.
This pull request can be merged automatically.
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
auth.jsoncredential storageReview: M3 — All providers + auth
Lots of surface here and it's mostly in great shape: the cost math (
ModelCost::cost_of) is correct and explicitly documents the reasoning-tokens-are-in-output subtlety, models.dev has a clean cache→baked-snapshot fallback, the device-flow state machine is pure and thoroughly tested, and the Copilot codec routing is nicely factored. Two things worth fixing, one security-flavored.Copilot: transient network error permanently disables token exchange (medium)
TokenProvider::token(copilot/token.rs:2321):The
Err(_)arm catches all errors, includingTokenError::Network(timeout, DNS blip), and caches a never-expiring (expires_at: 0) direct-Bearer token. Becauseneeds_refreshis always false forexpires_at == 0, a single transient failure during the first exchange pins the provider to direct-Bearer mode for the entire process lifetime — even though the exchange endpoint is actually supported. The sticky fallback should be reserved forTokenError::ExchangeUnsupported; onNetwork/Unexpected, don't cache — let the nexttoken()call retry the exchange. The doc comment ("when the exchange endpoint rejects the request") already describes the intended narrower behavior.auth.json secret is briefly world-readable during write (low-medium)
AuthStorage::write(auth.rs:624):There's a window where the temp file containing the OAuth/API secret exists at 0644 before the chmod lands. Create it 0600 from the start via
OpenOptions::new().write(true).create_new(true).mode(0o600)(unix), and considercreate_dir_all+ chmod0700on the parent directory so the file isn't sitting in a world-readable dir either.Minor
openai.rs:flavor_for(&req.model)is computed twice (once to pick the path/body, once to pick the decoder). Harmless but easy to compute once and reuse.exchangereturnsTokenError::Networkfor any non-success status that isn't 401/403/404 (e.g. 500). Combined with the fix above, make sure a 5xx from the exchange endpoint retries rather than sticking to fallback.Nice test coverage throughout (auth 0600 assertion, cost-of-usage, cache TTL, poll state machine).
— automated review (Claude)
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.