// crates/atomcode-core/src/coding_plan/client.rs
//
// Blocking HTTP client for the three CodingPlan REST endpoints. Reuses the
// OAuth token already on disk (from `crate::auth`) — the token authenticates
// both `atomgit.com` and `api.gitcode.com` (same backend, different front
// domains). Every request carries `ATOMCODE_USER_AGENT` so AtomGit's
// API gateway sees a consistent identifier.
//
// Blocking (not async) is deliberate: the coding-plan flow runs synchronously
// before / outside the agent event loop. Async would force tokio::block_on
// or channel plumbing at every call site for zero concurrency benefit.
use anyhow::{anyhow, Context, Result};
use super::types::{ClaimResponse, ModelEntry, PlanType, StatusResponse};
use crate::auth;
/// Default CodingPlan REST API base URL.
/// Override with the `ATOMCODE_CODINGPLAN_API_BASE` environment variable.
const DEFAULT_API_BASE: &str = "https://api.gitcode.com/api/v5";
/// Return the CodingPlan REST API base URL, reading
/// `ATOMCODE_CODINGPLAN_API_BASE` once at first call and caching the
/// result for the process lifetime.
///
/// Read order:
/// 1. `ATOMCODE_CODINGPLAN_API_BASE` env var (trimmed, trailing `/`
/// stripped, empty value treated as unset).
/// 2. [`DEFAULT_API_BASE`].
pub fn api_base_url() -> String {
use std::sync::OnceLock;
static BASE: OnceLock<String> = OnceLock::new();
BASE.get_or_init(|| {
std::env::var("ATOMCODE_CODINGPLAN_API_BASE")
.ok()
.map(|v| v.trim().trim_end_matches('/').to_string())
.filter(|v| !v.is_empty())
.unwrap_or_else(|| DEFAULT_API_BASE.to_string())
})
.clone()
}
/// Typed error surfaced when the API rejects the bearer token (401/403).
///
/// Carried inside `anyhow::Error` by every `Client` method so the
/// orchestrator can `downcast_ref::<AuthExpired>()` and decide to
/// re-run OAuth instead of just printing the failure. Before this
/// existed `/codingplan` would emit "already logged in" + "claim failed
/// — run `atomcode login` again" and leave the user to do it manually,
/// even though `/login` would have fixed it in one step.
///
/// The Display text matches the legacy error string verbatim so
/// rendered reports stay byte-identical when no recovery happens
/// (e.g. running `atomcode` against a server we never reach for
/// re-auth, or a non-interactive scripted invocation).
#[derive(Debug)]
pub struct AuthExpired {
pub status: u16,
}
impl std::fmt::Display for AuthExpired {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"authentication failed ({}) — run `atomcode login` again",
self.status
)
}
}
impl std::error::Error for AuthExpired {}
/// True iff `err` (or any error in its cause chain) is an `AuthExpired`.
/// Centralised so the orchestrator and shell callers agree on what
/// "stale token" looks like — anywhere we want to decide "rerun OAuth?"
/// goes through here.
pub fn is_auth_expired(err: &anyhow::Error) -> bool {
err.chain().any(|e| e.is::<AuthExpired>())
}
/// Token-authenticated blocking REST client for CodingPlan endpoints.
pub struct Client {
http: reqwest::blocking::Client,
token: String,
}
impl Client {
/// Build a client using the currently-stored OAuth token. Refreshes
/// the token if expired. Errors with a user-facing message if the
/// user isn't logged in.
pub fn from_stored_auth() -> Result<Self> {
if !auth::is_logged_in() {
return Err(anyhow!(
"not logged in — run `atomcode login` (or the codingplan flow) first"
));
}
// If the local access token can't be made valid (expired and the
// broker refused our refresh_token, or the file is malformed),
// there's no way to proceed without a fresh OAuth round-trip.
// Surface as `AuthExpired` so the orchestrator triggers the same
// recovery path it uses for an API-side 401, instead of bailing
// with a generic "build client" error that callers can't act on.
let token = match auth::get_valid_token() {
Ok(t) => t,
Err(e) => {
return Err(anyhow::Error::new(AuthExpired { status: 401 })
.context(format!("local token unusable: {:#}", e)));
}
};
// Timeouts are critical here: `/status` and the background drift
// monitor both call these endpoints synchronously from the TUI
// event loop, and without a cap a slow / unreachable gateway
// hangs the entire UI until the OS eventually gives up (minutes
// on a VPN flap). 5s connect + 10s total covers every realistic
// latency for a healthy path and fails fast otherwise — the
// error surfaces as a benign "status fetch failed" line next to
// the rest of the status report.
let http = reqwest::blocking::Client::builder()
.connect_timeout(std::time::Duration::from_secs(5))
.timeout(std::time::Duration::from_secs(10))
.user_agent(crate::ATOMCODE_USER_AGENT)
.build()
.unwrap_or_else(|_| reqwest::blocking::Client::new());
Ok(Self { http, token })
}
/// `POST /coding-plan/claim-v2` — claim a specific CodingPlan tier.
/// Server reports `duplicate=true` when the user already holds the
/// tier (or a higher one); callers should treat that as success and
/// stop the cascade rather than retrying lower tiers — those would
/// either also report duplicate or unnecessarily downgrade.
///
/// Body shape: `{"plan_type": "Max" | "Pro" | "Lite"}`. The user
/// asked us to start at Max and walk down, so the orchestrator
/// (`step_claim`) calls this in `PlanType::CASCADE_ORDER`.
pub fn claim_v2(&self, plan_type: PlanType) -> Result<ClaimResponse> {
let url = format!("{}/coding-plan/claim-v2", api_base_url());
let body_str = format!(r#"{{"plan_type":"{}"}}"#, plan_type.as_str());
let resp = self
.http
.post(&url)
.bearer_auth(&self.token)
.header("Content-Type", "application/json")
.body(body_str)
.send()
.with_context(|| format!("POST {} failed", url))?;
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return Err(anyhow::Error::new(AuthExpired {
status: status.as_u16(),
}));
}
let body = resp.text().unwrap_or_default();
if !status.is_success() {
return Err(anyhow!("{}", format_api_error("claim-v2", status, &body)));
}
serde_json::from_str::<ClaimResponse>(&body).with_context(|| {
format!(
"parse claim-v2 response (body: {})",
truncate_for_error(&body, 200)
)
})
}
/// `GET /coding-plan/models-v2?plan_type=<tier>` — model catalogue
/// from the v2 endpoint. Every entry now carries `plan_available`
/// telling the caller whether the user's tier covers that model;
/// the renderer uses it to apply strikethrough on locked entries.
/// Empty list is a legitimate return when the entitlement hasn't
/// been provisioned yet.
pub fn list_models_v2(&self, plan_type: PlanType) -> Result<Vec<ModelEntry>> {
let url = format!(
"{}/coding-plan/models-v2?plan_type={}",
api_base_url(),
plan_type.as_str()
);
let resp = self
.http
.get(&url)
.bearer_auth(&self.token)
.send()
.with_context(|| format!("GET {} failed", url))?;
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return Err(anyhow::Error::new(AuthExpired {
status: status.as_u16(),
}));
}
let body = resp.text().unwrap_or_default();
if !status.is_success() {
return Err(anyhow!("{}", format_api_error("models-v2", status, &body)));
}
serde_json::from_str::<Vec<ModelEntry>>(&body).with_context(|| {
format!(
"parse models-v2 response (body: {})",
truncate_for_error(&body, 200)
)
})
}
/// `GET /coding-plan/status-v2` — audit/quota/expiry snapshot. Same
/// response envelope as v1; only the path changed under the v2
/// rollout, so the parser type stays put.
pub fn status_v2(&self) -> Result<StatusResponse> {
let url = format!("{}/coding-plan/status-v2", api_base_url());
let resp = self
.http
.get(&url)
.bearer_auth(&self.token)
.send()
.with_context(|| format!("GET {} failed", url))?;
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return Err(anyhow::Error::new(AuthExpired {
status: status.as_u16(),
}));
}
let body = resp.text().unwrap_or_default();
if !status.is_success() {
return Err(anyhow!("{}", format_api_error("status-v2", status, &body)));
}
serde_json::from_str::<StatusResponse>(&body).with_context(|| {
format!(
"parse status-v2 response (body: {})",
truncate_for_error(&body, 200)
)
})
}
}
fn truncate_for_error(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
s.to_string()
} else {
let head: String = s.chars().take(max_chars).collect();
format!("{}…", head)
}
}
/// Format a non-2xx response from any CodingPlan endpoint into a
/// user-facing error string. Tries three body shapes in priority order:
///
/// 1. Product payload `{"message": "..."}` (non-empty) — shown verbatim
/// (e.g. `全平台日限额已满` from a 429).
/// 2. Spring default error body `{"timestamp":..,"status":..,"error":..,
/// "path":".."}` — rendered as `HTTP <code> — 接口暂不可用 (<path>)`
/// so the user sees which endpoint 404'd without staring at raw JSON.
/// 3. Raw text fallback — `CodingPlan <descriptor> returned <status> — <body>`
/// with a 200-char body cap.
///
/// `descriptor` is a short name for the caller (`claim` / `models` /
/// `status`) used only in shape-3 fallback where no structured info exists.
fn format_api_error(descriptor: &str, status: reqwest::StatusCode, body: &str) -> String {
if let Ok(val) = serde_json::from_str::<serde_json::Value>(body) {
// Shape 1: product payload with non-empty `message`.
if let Some(msg) = val.get("message").and_then(|v| v.as_str()) {
if !msg.is_empty() {
return msg.to_string();
}
}
// Shape 2: Spring error body with `path` (and no usable message).
if let Some(path) = val.get("path").and_then(|v| v.as_str()) {
return format!("HTTP {} — 接口暂不可用 ({})", status.as_u16(), path);
}
}
// Shape 3: raw text fallback.
format!(
"CodingPlan {} returned {} — {}",
descriptor,
status,
truncate_for_error(body, 200)
)
}
#[cfg(test)]
mod tests {
use super::*;
/// `AuthExpired` must Display identically to the legacy
/// `anyhow!("authentication failed (NNN) — run `atomcode login` again")`
/// string so existing renderers / log scrapers / users that grep
/// for the hint don't see a stealth wording change.
#[test]
fn auth_expired_display_matches_legacy_string() {
let e = AuthExpired { status: 401 };
assert_eq!(
e.to_string(),
"authentication failed (401) — run `atomcode login` again"
);
let e = AuthExpired { status: 403 };
assert_eq!(
e.to_string(),
"authentication failed (403) — run `atomcode login` again"
);
}
/// `is_auth_expired` finds the marker on a direct `AuthExpired`
/// error AND through the `with_context` chain that callers wrap
/// around it ("build client: ...", "list models-v2: ..."). Without
/// walking the chain we'd miss it the moment any step layers a
/// context onto the original anyhow::Error.
#[test]
fn is_auth_expired_walks_cause_chain() {
let raw = anyhow::Error::new(AuthExpired { status: 401 });
assert!(is_auth_expired(&raw));
let wrapped: anyhow::Error =
Err::<(), _>(anyhow::Error::new(AuthExpired { status: 401 }))
.context("list models-v2")
.unwrap_err();
assert!(is_auth_expired(&wrapped));
let unrelated = anyhow!("some other failure");
assert!(!is_auth_expired(&unrelated));
}
#[test]
fn format_api_error_extracts_message_from_product_payload() {
let body = r#"{"success":false,"duplicate":false,"message":"全平台日限额已满"}"#;
let msg = format_api_error("claim", reqwest::StatusCode::TOO_MANY_REQUESTS, body);
assert_eq!(msg, "全平台日限额已满");
}
#[test]
fn format_api_error_uses_path_from_spring_error_body() {
let body = r#"{"timestamp":"2026-04-23T06:44:11.638+00:00","status":404,"error":"Not Found","path":"/api/v5/coding-plan/claim"}"#;
let msg = format_api_error("claim", reqwest::StatusCode::NOT_FOUND, body);
assert_eq!(msg, "HTTP 404 — 接口暂不可用 (/api/v5/coding-plan/claim)");
}
#[test]
fn format_api_error_path_takes_precedence_when_message_empty() {
let body = r#"{"message":"","path":"/api/v5/coding-plan/models"}"#;
let msg = format_api_error("models", reqwest::StatusCode::NOT_FOUND, body);
assert_eq!(msg, "HTTP 404 — 接口暂不可用 (/api/v5/coding-plan/models)");
}
#[test]
fn format_api_error_falls_back_on_non_json_body() {
let body = "<html>502 Bad Gateway</html>";
let msg = format_api_error("status", reqwest::StatusCode::BAD_GATEWAY, body);
assert!(msg.contains("502"), "status code missing: {}", msg);
assert!(
msg.contains("CodingPlan status"),
"descriptor missing: {}",
msg
);
assert!(
msg.contains("502 Bad Gateway"),
"body should be echoed: {}",
msg
);
}
#[test]
fn format_api_error_falls_back_on_json_with_no_known_fields() {
let body = r#"{"foo":"bar"}"#;
let msg = format_api_error("claim", reqwest::StatusCode::INTERNAL_SERVER_ERROR, body);
assert!(msg.contains("500"), "status code missing: {}", msg);
assert!(
msg.contains("CodingPlan claim"),
"descriptor missing: {}",
msg
);
}
#[test]
fn format_api_error_message_wins_over_path_when_both_present() {
let body = r#"{"message":"全平台日限额已满","path":"/api/v5/coding-plan/claim"}"#;
let msg = format_api_error("claim", reqwest::StatusCode::TOO_MANY_REQUESTS, body);
assert_eq!(msg, "全平台日限额已满");
}
}