diff --git a/docs/src/content/docs/providers/opencode-go.md b/docs/src/content/docs/providers/opencode-go.md index 0e48b9d3..e78d928f 100644 --- a/docs/src/content/docs/providers/opencode-go.md +++ b/docs/src/content/docs/providers/opencode-go.md @@ -1,6 +1,6 @@ --- title: OpenCode Go -description: Configure an OpenCode Go API key, model routing, streaming, tools, and provider overrides. +description: Configure an OpenCode Go API key, account usage, model routing, streaming, tools, and provider overrides. --- OpenCode Go uses the API at `https://opencode.ai/zen/go/v1`. Its catalog spans @@ -22,6 +22,26 @@ claude-code-proxy serve `opencode.apiKey` configuration key is also supported. The proxy does not implement an OpenCode login flow. +To see the current percentage used and reset time for each account limit, run: + +```sh +claude-code-proxy opencode usage +claude-code-proxy opencode usage --json +``` + +This fetches OpenCode Go's rolling five-hour, weekly, and monthly windows. The +upstream `/usage` endpoint is implemented by OpenCode but is not yet listed in +its public API table, so its response format may evolve. The JSON form preserves +additional upstream fields for scripting. + +The proxy also exposes the same limits in the standard Claude Code Router +account format. In Claude Code Router, enable **Fetch usage** for the proxy +provider and select **Standard usage endpoint**. The dashboard will discover +`/.well-known/ccr/account`; `/v1/account/limits` is available as a compatible +alias. These routes use the proxy's configured OpenCode key and ignore the +incoming placeholder key. Successful upstream results are cached for 60 seconds; +an expired refresh failure is returned rather than silently serving stale data. + ## Models Run `claude-code-proxy models` for the statically registered catalog. Every diff --git a/docs/src/content/docs/reference/command-reference.md b/docs/src/content/docs/reference/command-reference.md index 9d5b9d48..45544647 100644 --- a/docs/src/content/docs/reference/command-reference.md +++ b/docs/src/content/docs/reference/command-reference.md @@ -1,6 +1,6 @@ --- title: Command reference -description: Canonical claude-code-proxy command syntax for serving, monitoring, listing models, version output, and provider authentication. +description: Canonical claude-code-proxy command syntax for serving, monitoring, listing models, provider authentication, and OpenCode Go usage. --- Running `claude-code-proxy` without a subcommand is equivalent to `claude-code-proxy serve`. @@ -74,6 +74,17 @@ A missing credential makes `auth status` exit with status 1. Other provider comm Logout removes the local proxy-owned credential. It does not call the provider to revoke a refresh token. +## OpenCode Go usage + +```sh +claude-code-proxy opencode usage [--json] +``` + +Fetches the account's rolling five-hour, weekly, and monthly usage directly +from OpenCode Go. The default output is human-readable; `--json` prints the +upstream response for scripts. The command uses the same API key and base URL +as OpenCode model requests. + ## Development commands From a source checkout: diff --git a/docs/src/content/docs/reference/http-api.md b/docs/src/content/docs/reference/http-api.md index e5fbbaf0..3cca7abc 100644 --- a/docs/src/content/docs/reference/http-api.md +++ b/docs/src/content/docs/reference/http-api.md @@ -1,6 +1,6 @@ --- title: HTTP API -description: Local routes for health checks, Anthropic Messages, token counts, model discovery, OpenAI-compatible requests, and Codex images. +description: Local routes for health checks, account usage, Anthropic Messages, token counts, model discovery, OpenAI-compatible requests, and Codex images. --- The server exposes the Anthropic and OpenAI routes supported by the proxy. Each route uses the configured provider credential for the selected model. @@ -19,6 +19,31 @@ Liveness check: It does not verify provider credentials or upstream availability. +## OpenCode Go account usage + +```text +GET /.well-known/ccr/account +GET /v1/account/limits +``` + +Both routes return OpenCode Go's rolling five-hour, weekly, and monthly account +limits as the same normalized account snapshot. Available percentages become +quota meters with used and remaining values; reset times and upstream statuses +are included when supplied. The response follows Claude Code Router's standard +account endpoint contract, so a proxy provider configured with **Fetch usage** +and **Standard usage endpoint** can display the limits in its dashboard. + +The server caches the latest successful upstream response for 60 seconds, so +frequent dashboard polling does not make an upstream request each time. After +the cache expires, an upstream failure is returned explicitly instead of +serving an unmarked stale snapshot. + +The routes use the proxy-owned OpenCode credential. Incoming bearer or API-key +headers are ignored, as on generation routes. Responses include +`Cache-Control: no-store`. Because account usage is visible without client +authentication, keep the listener on loopback or protect it as described +above. + ## `POST /v1/messages` Accepts an Anthropic Messages request in streaming or non-streaming mode. `POST /v1/messages?beta=true` reaches the same route. diff --git a/src/main.rs b/src/main.rs index 68f85548..6b1061b3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -66,6 +66,12 @@ enum Commands { #[command(subcommand)] command: ProviderGroup, }, + /// Inspect OpenCode Go account state + #[command(name = "opencode")] + OpenCode { + #[command(subcommand)] + command: OpenCodeGroup, + }, } #[derive(Debug, Subcommand)] @@ -76,6 +82,16 @@ enum ProviderGroup { }, } +#[derive(Debug, Subcommand)] +enum OpenCodeGroup { + /// Show rolling, weekly, and monthly usage limits + Usage { + /// Print the upstream response as JSON + #[arg(long)] + json: bool, + }, +} + fn main() -> Result<()> { let cli = Cli::parse(); @@ -165,6 +181,9 @@ fn main() -> Result<()> { Commands::Kimi { command } => run_provider_cli("kimi", command), Commands::Cursor { command } => run_provider_cli("cursor", command), Commands::Grok { command } => run_provider_cli("grok", command), + Commands::OpenCode { command } => match command { + OpenCodeGroup::Usage { json } => run_opencode_usage(json), + }, } } @@ -222,6 +241,28 @@ fn run_provider_cli(name: &str, command: ProviderGroup) -> Result<()> { } } +fn run_opencode_usage(json: bool) -> Result<()> { + let client = claude_code_proxy::providers::opencode::client::OpenCodeClient::new( + config::opencode_base_url(), + config::opencode_api_key(), + )?; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + let usage = runtime + .block_on(client.get_usage()) + .map_err(|error| anyhow::anyhow!(error.message))?; + if json { + println!("{}", serde_json::to_string_pretty(&usage)?); + } else { + println!( + "{}", + claude_code_proxy::providers::opencode::usage::format_text(&usage) + ); + } + Ok(()) +} + fn print_models(registry: &Registry, full: bool) { let grouped = registry.grouped_models(); for provider in ["codex", "kimi", "grok", "opencode", "cursor"] { @@ -314,6 +355,19 @@ mod tests { assert!(matches!(cli.command, Some(Commands::Demo))); } + #[test] + fn opencode_usage_command_parses_json_flag() { + let cli = + Cli::try_parse_from(["claude-code-proxy", "opencode", "usage", "--json"]).unwrap(); + + assert!(matches!( + cli.command, + Some(Commands::OpenCode { + command: OpenCodeGroup::Usage { json: true } + }) + )); + } + #[test] fn listen_url_brackets_ipv6_addresses() { assert_eq!(listen_url("::1", 18765), "http://[::1]:18765"); diff --git a/src/providers/opencode/client.rs b/src/providers/opencode/client.rs index dd2b59ec..fe5ea85a 100644 --- a/src/providers/opencode/client.rs +++ b/src/providers/opencode/client.rs @@ -1,14 +1,19 @@ +use std::collections::BTreeMap; use std::sync::Arc; use std::time::Duration; use futures_util::StreamExt; use http::StatusCode; -use serde::Serialize; +use serde::{Deserialize, Serialize}; +use serde_json::Value; use super::model::EndpointKind; use crate::traffic::TrafficCapture; const MAX_BUFFERED_RESPONSE_BYTES: usize = 8 * 1024 * 1024; +const MAX_USAGE_RESPONSE_BYTES: usize = 64 * 1024; +const USAGE_REQUEST_TIMEOUT: Duration = Duration::from_secs(10); +const USER_AGENT: &str = concat!("claude-code-proxy/", env!("CARGO_PKG_VERSION")); pub struct OpenCodeClient { client: Arc, @@ -20,6 +25,47 @@ pub struct OpenCodeResponse { response: reqwest::Response, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct OpenCodeUsageResponse { + pub usage: OpenCodeUsage, + #[serde(flatten)] + pub(super) extra: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct OpenCodeUsage { + pub rolling: Option, + pub weekly: Option, + pub monthly: Option, + #[serde(flatten)] + pub(super) extra: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenCodeUsageWindow { + pub status: Option, + pub percent: Option, + pub resets_at: Option, + #[serde(flatten)] + pub(super) extra: BTreeMap, +} + +impl OpenCodeUsage { + fn has_known_data(&self) -> bool { + [&self.rolling, &self.weekly, &self.monthly] + .into_iter() + .flatten() + .any(OpenCodeUsageWindow::has_known_data) + } +} + +impl OpenCodeUsageWindow { + fn has_known_data(&self) -> bool { + self.status.is_some() || self.percent.is_some() || self.resets_at.is_some() + } +} + #[derive(Debug)] pub struct OpenCodeError { pub status: StatusCode, @@ -41,15 +87,27 @@ impl OpenCodeResponse { } pub async fn into_bytes(self) -> Result, OpenCodeError> { + self.into_bytes_with_limit( + MAX_BUFFERED_RESPONSE_BYTES, + "OpenCode Go upstream response exceeds the size limit", + ) + .await + } + + async fn into_bytes_with_limit( + self, + limit: usize, + size_error: &'static str, + ) -> Result, OpenCodeError> { let mut stream = self.into_stream(); let mut bytes = Vec::new(); while let Some(chunk) = stream.next().await { let chunk = chunk?; - if bytes.len().saturating_add(chunk.len()) > MAX_BUFFERED_RESPONSE_BYTES { + if bytes.len().saturating_add(chunk.len()) > limit { return Err(OpenCodeError { status: StatusCode::BAD_GATEWAY, retry_after: None, - message: "OpenCode Go upstream response exceeds the size limit".to_string(), + message: size_error.to_string(), }); } bytes.extend_from_slice(&chunk); @@ -64,6 +122,7 @@ impl OpenCodeClient { let client = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .connect_timeout(Duration::from_secs(10)) + .user_agent(USER_AGENT) .build()?; Ok(Self { client: Arc::new(client), @@ -80,13 +139,7 @@ impl OpenCodeClient { traffic: Option>, session_id: Option<&str>, ) -> Result { - let Some(api_key) = self.api_key.as_deref().filter(|key| !key.is_empty()) else { - return Err(OpenCodeError { - status: StatusCode::UNAUTHORIZED, - retry_after: None, - message: "OpenCode Go API key is not configured; set CCP_OPENCODE_API_KEY, OPENCODE_API_KEY, or opencode.apiKey in config.json".to_string(), - }); - }; + let api_key = self.api_key()?; let url = self.endpoint_url(endpoint); let accept = if stream { "text/event-stream" @@ -157,11 +210,71 @@ impl OpenCodeClient { } if !response.status().is_success() { - return Err(rejected_response(response).await); + return Err(rejected_response(response, Some(api_key)).await); } Ok(OpenCodeResponse { response }) } + pub async fn get_usage(&self) -> Result { + let api_key = self.api_key()?; + let response = self + .client + .get(self.usage_url()) + .header(http::header::ACCEPT, "application/json") + .header(http::header::AUTHORIZATION, format!("Bearer {api_key}")) + .timeout(USAGE_REQUEST_TIMEOUT) + .send() + .await + .map_err(|error| OpenCodeError { + status: if error.is_timeout() { + StatusCode::GATEWAY_TIMEOUT + } else { + StatusCode::BAD_GATEWAY + }, + retry_after: None, + message: if error.is_timeout() { + "OpenCode Go usage request timed out" + } else { + "OpenCode Go usage request failed" + } + .to_string(), + })?; + if !response.status().is_success() { + return Err(rejected_response(response, Some(api_key)).await); + } + let bytes = OpenCodeResponse { response } + .into_bytes_with_limit( + MAX_USAGE_RESPONSE_BYTES, + "OpenCode Go usage response exceeds the size limit", + ) + .await?; + let parsed: OpenCodeUsageResponse = + serde_json::from_slice(&bytes).map_err(|_| OpenCodeError { + status: StatusCode::BAD_GATEWAY, + retry_after: None, + message: "OpenCode Go usage response was invalid".to_string(), + })?; + if !parsed.usage.has_known_data() { + return Err(OpenCodeError { + status: StatusCode::BAD_GATEWAY, + retry_after: None, + message: "OpenCode Go usage response contained no recognized windows".to_string(), + }); + } + Ok(parsed) + } + + fn api_key(&self) -> Result<&str, OpenCodeError> { + self.api_key + .as_deref() + .filter(|key| !key.is_empty()) + .ok_or_else(|| OpenCodeError { + status: StatusCode::UNAUTHORIZED, + retry_after: None, + message: "OpenCode Go API key is not configured; set CCP_OPENCODE_API_KEY, OPENCODE_API_KEY, or opencode.apiKey in config.json".to_string(), + }) + } + fn endpoint_url(&self, endpoint: EndpointKind) -> reqwest::Url { let mut url = self.base_url.clone(); let base_path = url.path().trim_end_matches('/'); @@ -173,9 +286,16 @@ impl OpenCodeClient { url.set_path(&format!("{base_path}/{suffix}")); url } + + fn usage_url(&self) -> reqwest::Url { + let mut url = self.base_url.clone(); + let base_path = url.path().trim_end_matches('/'); + url.set_path(&format!("{base_path}/usage")); + url + } } -async fn rejected_response(response: reqwest::Response) -> OpenCodeError { +async fn rejected_response(response: reqwest::Response, secret: Option<&str>) -> OpenCodeError { let status = response.status(); let retry_after = response .headers() @@ -194,7 +314,7 @@ async fn rejected_response(response: reqwest::Response) -> OpenCodeError { let remaining = 64 * 1024 - body.len(); body.extend_from_slice(&chunk[..chunk.len().min(remaining)]); } - let message = serde_json::from_slice::(&body) + let mut message = serde_json::from_slice::(&body) .ok() .and_then(|value| { value @@ -205,6 +325,9 @@ async fn rejected_response(response: reqwest::Response) -> OpenCodeError { }) .filter(|value| !value.is_empty()) .unwrap_or_else(|| format!("OpenCode Go upstream returned HTTP {status}")); + if let Some(secret) = secret.filter(|secret| !secret.is_empty()) { + message = message.replace(secret, "[redacted]"); + } OpenCodeError { status, retry_after, @@ -237,7 +360,8 @@ mod tests { Json, Router, extract::{OriginalUri, State}, http::HeaderMap, - routing::post, + response::IntoResponse, + routing::{get, post}, }; use std::sync::Mutex; @@ -305,6 +429,181 @@ mod tests { client.endpoint_url(EndpointKind::Responses).as_str(), "https://opencode.ai/zen/go/v1/responses" ); + assert_eq!( + client.usage_url().as_str(), + "https://opencode.ai/zen/go/v1/usage" + ); + } + + #[tokio::test] + async fn usage_uses_bearer_auth_and_parses_all_windows() { + async fn usage(headers: HeaderMap) -> Json { + assert_eq!( + headers + .get(http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()), + Some("Bearer test-key") + ); + assert_eq!( + headers + .get(http::header::USER_AGENT) + .and_then(|value| value.to_str().ok()), + Some(USER_AGENT) + ); + Json(serde_json::json!({ + "usage": { + "rolling": {"status":"ok", "percent":12.5, "resetsAt":"2026-09-10T12:00:00.000Z"}, + "weekly": {"status":"ok", "percent":34, "resetsAt":"2026-09-14T00:00:00.000Z"}, + "monthly": {"status":"rate-limited", "percent":100, "resetsAt":"2026-10-01T00:00:00.000Z"} + } + })) + } + + let app = Router::new().route("/v1/usage", get(usage)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let client = + OpenCodeClient::new(format!("http://{address}/v1"), Some("test-key".to_string())) + .unwrap(); + + let response = client.get_usage().await.unwrap(); + assert_eq!( + response + .usage + .rolling + .as_ref() + .and_then(|window| window.percent), + Some(12.5) + ); + assert_eq!( + response + .usage + .weekly + .as_ref() + .and_then(|window| window.percent), + Some(34.0) + ); + assert_eq!( + response + .usage + .monthly + .as_ref() + .and_then(|window| window.status.as_deref()), + Some("rate-limited") + ); + server.abort(); + } + + #[tokio::test] + async fn usage_accepts_partial_windows_and_preserves_unknown_fields() { + let app = Router::new().route( + "/v1/usage", + get(|| async { + Json(serde_json::json!({ + "usage": { + "rolling": {"percent":12.5, "futureWindowField":true}, + "futureUsageField": "kept" + }, + "futureRootField": {"kept": true} + })) + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let client = + OpenCodeClient::new(format!("http://{address}/v1"), Some("test-key".to_string())) + .unwrap(); + + let response = client.get_usage().await.unwrap(); + assert!(response.usage.weekly.is_none()); + assert!( + response + .usage + .rolling + .as_ref() + .and_then(|window| window.resets_at.as_ref()) + .is_none() + ); + let serialized = serde_json::to_value(response).unwrap(); + assert_eq!(serialized["futureRootField"]["kept"], true); + assert_eq!(serialized["usage"]["futureUsageField"], "kept"); + assert_eq!(serialized["usage"]["rolling"]["futureWindowField"], true); + server.abort(); + } + + #[tokio::test] + async fn usage_preserves_retry_after_on_rate_limit() { + let app = Router::new().route( + "/v1/usage", + get(|| async { + ( + StatusCode::TOO_MANY_REQUESTS, + [(http::header::RETRY_AFTER, "17")], + Json(serde_json::json!({"error":{"message":"try later test-key"}})), + ) + .into_response() + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let client = + OpenCodeClient::new(format!("http://{address}/v1"), Some("test-key".to_string())) + .unwrap(); + + let error = client.get_usage().await.unwrap_err(); + assert_eq!(error.status, StatusCode::TOO_MANY_REQUESTS); + assert_eq!(error.retry_after.as_deref(), Some("17")); + assert_eq!(error.message, "try later [redacted]"); + server.abort(); + } + + #[tokio::test] + async fn usage_requires_an_api_key() { + let client = OpenCodeClient::new("https://example.com/v1".to_string(), None).unwrap(); + let error = client.get_usage().await.unwrap_err(); + assert_eq!(error.status, StatusCode::UNAUTHORIZED); + assert!(error.message.contains("OPENCODE_API_KEY")); + } + + #[tokio::test] + async fn invalid_usage_response_is_rejected() { + let app = Router::new().route("/v1/usage", get(|| async { "not json" })); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let client = + OpenCodeClient::new(format!("http://{address}/v1"), Some("test-key".to_string())) + .unwrap(); + + let error = client.get_usage().await.unwrap_err(); + assert_eq!(error.status, StatusCode::BAD_GATEWAY); + assert_eq!(error.message, "OpenCode Go usage response was invalid"); + server.abort(); + } + + #[tokio::test] + async fn usage_response_without_recognized_window_data_is_rejected() { + let app = Router::new().route( + "/v1/usage", + get(|| async { Json(serde_json::json!({"usage": {}})) }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let client = + OpenCodeClient::new(format!("http://{address}/v1"), Some("test-key".to_string())) + .unwrap(); + + let error = client.get_usage().await.unwrap_err(); + assert_eq!(error.status, StatusCode::BAD_GATEWAY); + assert_eq!( + error.message, + "OpenCode Go usage response contained no recognized windows" + ); + server.abort(); } #[tokio::test] diff --git a/src/providers/opencode/mod.rs b/src/providers/opencode/mod.rs index bc984e9a..42476c46 100644 --- a/src/providers/opencode/mod.rs +++ b/src/providers/opencode/mod.rs @@ -3,6 +3,7 @@ pub mod client; pub mod messages; pub mod model; pub mod responses; +pub mod usage; use std::sync::Arc; diff --git a/src/providers/opencode/usage.rs b/src/providers/opencode/usage.rs new file mode 100644 index 00000000..473a6dfd --- /dev/null +++ b/src/providers/opencode/usage.rs @@ -0,0 +1,440 @@ +use std::time::{Duration, Instant}; + +use serde_json::{Map, Value, json}; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; +use tokio::sync::Mutex; + +use super::client::{OpenCodeClient, OpenCodeError, OpenCodeUsageResponse, OpenCodeUsageWindow}; + +const DEFAULT_CACHE_TTL: Duration = Duration::from_secs(60); + +#[derive(Debug, Clone)] +pub struct OpenCodeUsageSnapshot { + pub response: OpenCodeUsageResponse, + fetched_at: OffsetDateTime, +} + +struct CacheEntry { + snapshot: OpenCodeUsageSnapshot, + stored_at: Instant, +} + +pub struct OpenCodeUsageService { + client: OpenCodeClient, + cache: Mutex>, + ttl: Duration, +} + +impl OpenCodeUsageService { + pub fn new(client: OpenCodeClient) -> Self { + Self::with_ttl(client, DEFAULT_CACHE_TTL) + } + + fn with_ttl(client: OpenCodeClient, ttl: Duration) -> Self { + Self { + client, + cache: Mutex::new(None), + ttl, + } + } + + pub async fn get(&self) -> Result { + self.get_at(Instant::now()).await + } + + async fn get_at(&self, now: Instant) -> Result { + // Hold the lock through refresh so concurrent dashboard polls coalesce. + let mut cache = self.cache.lock().await; + if let Some(entry) = cache.as_ref() + && now.saturating_duration_since(entry.stored_at) < self.ttl + { + return Ok(entry.snapshot.clone()); + } + + let response = self.client.get_usage().await?; + let snapshot = OpenCodeUsageSnapshot { + response, + fetched_at: OffsetDateTime::now_utc(), + }; + *cache = Some(CacheEntry { + snapshot: snapshot.clone(), + stored_at: now, + }); + Ok(snapshot) + } +} + +pub fn format_text(response: &OpenCodeUsageResponse) -> String { + let rows = [ + ("Rolling (5 hour)", response.usage.rolling.as_ref()), + ("Weekly", response.usage.weekly.as_ref()), + ("Monthly", response.usage.monthly.as_ref()), + ]; + let mut output = String::from("OpenCode Go usage:"); + for (label, window) in rows { + output.push_str(&format!("\n {label}: {}", format_window(window))); + } + output +} + +fn format_window(window: Option<&OpenCodeUsageWindow>) -> String { + let Some(window) = window else { + return "unavailable".to_string(); + }; + + let mut fields = Vec::new(); + if let Some(percent) = window.percent { + fields.push(format!("{percent}% used")); + } + if let Some(status) = window.status.as_deref() { + fields.push(format!("status {status}")); + } + if let Some(reset_at) = window.resets_at.as_deref() { + fields.push(format!("resets {reset_at}")); + } + + if fields.is_empty() { + "unavailable".to_string() + } else { + fields.join(", ") + } +} + +pub fn ccr_snapshot(snapshot: &OpenCodeUsageSnapshot) -> Value { + let mut meters = Vec::new(); + let mut clamped = false; + + for (id, label, window_name, window) in [ + ( + "rolling", + "5h quota", + "5h", + snapshot.response.usage.rolling.as_ref(), + ), + ( + "weekly", + "Weekly quota", + "weekly", + snapshot.response.usage.weekly.as_ref(), + ), + ( + "monthly", + "Monthly quota", + "monthly", + snapshot.response.usage.monthly.as_ref(), + ), + ] { + let Some(window) = window else { + continue; + }; + let (meter, meter_clamped) = ccr_meter(id, label, window_name, window); + meters.push(meter); + clamped |= meter_clamped; + } + + let mut result = Map::new(); + result.insert("provider".into(), Value::String("OpenCode Go".into())); + result.insert( + "status".into(), + Value::String(overall_status(&snapshot.response).into()), + ); + result.insert("meters".into(), Value::Array(meters)); + result.insert( + "updatedAt".into(), + Value::String( + snapshot + .fetched_at + .format(&Rfc3339) + .expect("UTC timestamps are valid RFC 3339"), + ), + ); + if clamped { + result.insert( + "message".into(), + Value::String( + "OpenCode Go returned an out-of-range percentage; displayed values were clamped to 0-100%." + .into(), + ), + ); + } + Value::Object(result) +} + +fn ccr_meter( + id: &str, + label: &str, + window_name: &str, + window: &OpenCodeUsageWindow, +) -> (Value, bool) { + let mut meter = Map::new(); + meter.insert("id".into(), Value::String(format!("opencode_go_{id}"))); + meter.insert("kind".into(), Value::String("quota".into())); + meter.insert("label".into(), Value::String(label.into())); + meter.insert("unit".into(), Value::String("%".into())); + meter.insert("window".into(), Value::String(window_name.into())); + + let mut clamped = false; + if let Some(percent) = window.percent { + let normalized = percent.clamp(0.0, 100.0); + clamped = normalized != percent; + meter.insert("limit".into(), json!(100.0)); + meter.insert("used".into(), json!(normalized)); + meter.insert("remaining".into(), json!(100.0 - normalized)); + } + if let Some(reset_at) = window.resets_at.as_deref() { + meter.insert("resetAt".into(), Value::String(reset_at.into())); + } + if let Some(status) = window.status.as_deref() { + meter.insert( + "details".into(), + json!([{"label": "OpenCode status", "status": status}]), + ); + } + + (Value::Object(meter), clamped) +} + +fn overall_status(response: &OpenCodeUsageResponse) -> &'static str { + let windows = [ + response.usage.rolling.as_ref(), + response.usage.weekly.as_ref(), + response.usage.monthly.as_ref(), + ]; + + if windows.iter().flatten().any(|window| { + window.status.as_deref() == Some("rate-limited") + || window.percent.is_some_and(|percent| percent >= 95.0) + }) { + "critical" + } else if windows + .iter() + .flatten() + .any(|window| window.percent.is_some_and(|percent| percent >= 80.0)) + { + "warning" + } else { + "ok" + } +} + +#[cfg(test)] +mod tests { + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + + use axum::{Json, Router, response::IntoResponse, routing::get}; + + use super::*; + use crate::providers::opencode::client::OpenCodeUsage; + + fn window( + status: Option<&str>, + percent: Option, + reset: Option<&str>, + ) -> OpenCodeUsageWindow { + OpenCodeUsageWindow { + status: status.map(str::to_string), + percent, + resets_at: reset.map(str::to_string), + extra: Default::default(), + } + } + + fn response() -> OpenCodeUsageResponse { + OpenCodeUsageResponse { + usage: OpenCodeUsage { + rolling: Some(window(Some("ok"), Some(12.5), Some("2026-09-10T12:00:00Z"))), + weekly: Some(window(Some("warning"), Some(82.0), None)), + monthly: Some(window(Some("ok"), Some(33.0), Some("2026-10-01T00:00:00Z"))), + extra: Default::default(), + }, + extra: Default::default(), + } + } + + #[test] + fn text_output_has_stable_window_order() { + assert_eq!( + format_text(&response()), + concat!( + "OpenCode Go usage:\n", + " Rolling (5 hour): 12.5% used, status ok, resets 2026-09-10T12:00:00Z\n", + " Weekly: 82% used, status warning\n", + " Monthly: 33% used, status ok, resets 2026-10-01T00:00:00Z" + ) + ); + } + + #[test] + fn text_output_marks_missing_windows_and_fields_unavailable() { + let response = OpenCodeUsageResponse { + usage: OpenCodeUsage { + rolling: Some(window(None, None, None)), + weekly: None, + monthly: None, + extra: Default::default(), + }, + extra: Default::default(), + }; + + assert_eq!( + format_text(&response), + "OpenCode Go usage:\n Rolling (5 hour): unavailable\n Weekly: unavailable\n Monthly: unavailable" + ); + } + + #[test] + fn ccr_response_contains_supported_meter_fields() { + let snapshot = OpenCodeUsageSnapshot { + response: response(), + fetched_at: OffsetDateTime::UNIX_EPOCH, + }; + let value = ccr_snapshot(&snapshot); + + assert_eq!(value["provider"], "OpenCode Go"); + assert_eq!(value["status"], "warning"); + assert_eq!(value["updatedAt"], "1970-01-01T00:00:00Z"); + assert_eq!(value["meters"].as_array().unwrap().len(), 3); + let rolling = &value["meters"][0]; + assert_eq!(rolling["id"], "opencode_go_rolling"); + assert_eq!(rolling["kind"], "quota"); + assert_eq!(rolling["used"], 12.5); + assert_eq!(rolling["remaining"], 87.5); + assert_eq!(rolling["limit"], 100.0); + assert_eq!(rolling["unit"], "%"); + assert_eq!(rolling["window"], "5h"); + assert_eq!(rolling["resetAt"], "2026-09-10T12:00:00Z"); + assert_eq!(rolling["details"][0]["status"], "ok"); + } + + #[test] + fn ccr_response_does_not_fabricate_missing_quota_values() { + let snapshot = OpenCodeUsageSnapshot { + response: OpenCodeUsageResponse { + usage: OpenCodeUsage { + rolling: Some(window(Some("ok"), None, None)), + weekly: None, + monthly: None, + extra: Default::default(), + }, + extra: Default::default(), + }, + fetched_at: OffsetDateTime::UNIX_EPOCH, + }; + let value = ccr_snapshot(&snapshot); + let meter = &value["meters"][0]; + + assert_eq!(value["meters"].as_array().unwrap().len(), 1); + assert!(meter.get("used").is_none()); + assert!(meter.get("remaining").is_none()); + assert!(meter.get("limit").is_none()); + assert!(meter.get("resetAt").is_none()); + } + + #[test] + fn ccr_response_clamps_out_of_range_percentages_explicitly() { + let mut response = response(); + response.usage.rolling.as_mut().unwrap().percent = Some(120.0); + let snapshot = OpenCodeUsageSnapshot { + response, + fetched_at: OffsetDateTime::UNIX_EPOCH, + }; + let value = ccr_snapshot(&snapshot); + + assert_eq!(value["meters"][0]["used"], 100.0); + assert_eq!(value["meters"][0]["remaining"], 0.0); + assert!(value["message"].as_str().unwrap().contains("clamped")); + } + + #[test] + fn rate_limited_window_is_critical() { + let mut response = response(); + response.usage.rolling.as_mut().unwrap().status = Some("rate-limited".into()); + let snapshot = OpenCodeUsageSnapshot { + response, + fetched_at: OffsetDateTime::UNIX_EPOCH, + }; + + assert_eq!(ccr_snapshot(&snapshot)["status"], "critical"); + } + + #[tokio::test] + async fn cache_reuses_success_until_ttl_and_then_refreshes() { + let requests = Arc::new(AtomicUsize::new(0)); + let requests_for_handler = Arc::clone(&requests); + let app = Router::new().route( + "/usage", + get(move || { + let requests = Arc::clone(&requests_for_handler); + async move { + let count = requests.fetch_add(1, Ordering::SeqCst) + 1; + Json(json!({ + "usage": {"rolling": {"percent": count as f64}} + })) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + let client = + OpenCodeClient::new(format!("http://{address}"), Some("secret".into())).unwrap(); + let ttl = Duration::from_secs(60); + let service = OpenCodeUsageService::with_ttl(client, ttl); + let start = Instant::now(); + + let first = service.get_at(start).await.unwrap(); + let cached = service + .get_at(start + Duration::from_secs(59)) + .await + .unwrap(); + let refreshed = service.get_at(start + ttl).await.unwrap(); + + assert_eq!(requests.load(Ordering::SeqCst), 2); + assert_eq!(first.response.usage.rolling.unwrap().percent, Some(1.0)); + assert_eq!(cached.response.usage.rolling.unwrap().percent, Some(1.0)); + assert_eq!(refreshed.response.usage.rolling.unwrap().percent, Some(2.0)); + } + + #[tokio::test] + async fn expired_cache_does_not_hide_refresh_failure() { + let requests = Arc::new(AtomicUsize::new(0)); + let requests_for_handler = Arc::clone(&requests); + let app = Router::new().route( + "/usage", + get(move || { + let requests = Arc::clone(&requests_for_handler); + async move { + if requests.fetch_add(1, Ordering::SeqCst) == 0 { + Json(json!({"usage": {"rolling": {"percent": 10}}})).into_response() + } else { + ( + http::StatusCode::SERVICE_UNAVAILABLE, + Json(json!({"error": {"message": "temporarily unavailable"}})), + ) + .into_response() + } + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + let client = + OpenCodeClient::new(format!("http://{address}"), Some("secret".into())).unwrap(); + let ttl = Duration::from_secs(60); + let service = OpenCodeUsageService::with_ttl(client, ttl); + let start = Instant::now(); + + service.get_at(start).await.unwrap(); + let error = service.get_at(start + ttl).await.unwrap_err(); + + assert_eq!(requests.load(Ordering::SeqCst), 2); + assert_eq!(error.status, http::StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(error.message, "temporarily unavailable"); + } +} diff --git a/src/server.rs b/src/server.rs index 913a71cc..c53ed760 100644 --- a/src/server.rs +++ b/src/server.rs @@ -33,8 +33,8 @@ use axum::{ Json, Router, body::Body, extract::{DefaultBodyLimit, FromRequest, Multipart, Query, State}, - http::{Request, StatusCode}, - response::Response, + http::{HeaderValue, Request, StatusCode}, + response::{IntoResponse, Response}, routing::{get, post}, }; use http_body_util::{BodyExt, StreamBody}; @@ -226,6 +226,13 @@ pub fn app_with_features( monitor: Option, features: AppFeatures, ) -> Router { + let opencode_usage = crate::providers::opencode::client::OpenCodeClient::new( + crate::config::opencode_base_url(), + crate::config::opencode_api_key(), + ) + .map(crate::providers::opencode::usage::OpenCodeUsageService::new) + .map(Arc::new) + .map_err(|error| error.to_string()); let native_responses = features .responses_api .then(|| Arc::new(CodexNativeBackend::new())); @@ -252,6 +259,7 @@ pub fn app_with_features( let state = Arc::new(AppState { registry, monitor, + opencode_usage, native_responses, chat_completions, images, @@ -259,6 +267,8 @@ pub fn app_with_features( }); let router = Router::new() .route("/healthz", get(healthz)) + .route("/.well-known/ccr/account", get(handler_opencode_account)) + .route("/v1/account/limits", get(handler_opencode_account)) .route("/v1/messages", post(handler_messages)) .route("/v1/messages/count_tokens", post(handler_count_tokens)) .route("/v1/models", get(handler_models)); @@ -295,6 +305,7 @@ pub fn app_with_features( struct AppState { registry: Arc, monitor: Option, + opencode_usage: Result, String>, native_responses: Option>, chat_completions: Option>, images: Option>, @@ -305,6 +316,55 @@ async fn healthz() -> Json { Json(json!({ "ok": true })) } +async fn handler_opencode_account(State(state): State>) -> Response { + let service = match &state.opencode_usage { + Ok(service) => service, + Err(error) => { + return account_response( + StatusCode::INTERNAL_SERVER_ERROR, + json!({"error":{"type":"api_error", "message":error}}), + ); + } + }; + opencode_account_response(service).await +} + +async fn opencode_account_response( + service: &crate::providers::opencode::usage::OpenCodeUsageService, +) -> Response { + match service.get().await { + Ok(snapshot) => account_response( + StatusCode::OK, + crate::providers::opencode::usage::ccr_snapshot(&snapshot), + ), + Err(error) => account_error_response(error), + } +} + +fn account_error_response(error: crate::providers::opencode::client::OpenCodeError) -> Response { + let mut response = account_response( + error.status, + json!({"error":{"type":"api_error", "message":error.message}}), + ); + if let Some(retry_after) = error.retry_after + && let Ok(value) = HeaderValue::from_str(&retry_after) + { + response + .headers_mut() + .insert(http::header::RETRY_AFTER, value); + } + response +} + +fn account_response(status: StatusCode, body: Value) -> Response { + let mut response = (status, Json(body)).into_response(); + response.headers_mut().insert( + http::header::CACHE_CONTROL, + HeaderValue::from_static("no-store"), + ); + response +} + #[derive(serde::Deserialize)] struct ModelsQuery { limit: Option, @@ -2172,6 +2232,98 @@ fn _unused(session_state: Option<&SessionState>) { let _ = session_state; } +#[cfg(test)] +mod opencode_account_tests { + use axum::{Json, Router, response::IntoResponse, routing::get}; + use http_body_util::BodyExt; + + use super::opencode_account_response; + use crate::providers::opencode::{client::OpenCodeClient, usage::OpenCodeUsageService}; + + async fn service_for(app: Router) -> OpenCodeUsageService { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let client = + OpenCodeClient::new(format!("http://{address}"), Some("secret".into())).unwrap(); + OpenCodeUsageService::new(client) + } + + #[tokio::test] + async fn account_response_is_ccr_compatible_and_not_stored() { + let app = Router::new().route( + "/usage", + get(|| async { + Json(serde_json::json!({ + "usage": { + "rolling": {"status":"ok", "percent":12.5, "resetsAt":"2026-09-10T12:00:00Z"}, + "weekly": {"status":"ok", "percent":20}, + "monthly": {"status":"ok", "percent":30} + } + })) + }), + ); + let service = service_for(app).await; + + let response = opencode_account_response(&service).await; + assert_eq!(response.status(), http::StatusCode::OK); + assert_eq!( + response.headers().get(http::header::CACHE_CONTROL).unwrap(), + "no-store" + ); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let value: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(value["provider"], "OpenCode Go"); + assert_eq!(value["meters"].as_array().unwrap().len(), 3); + assert_eq!(value["meters"][0]["kind"], "quota"); + assert_eq!(value["meters"][0]["used"], 12.5); + } + + #[tokio::test] + async fn account_response_preserves_rate_limit_retry_after() { + let app = Router::new().route( + "/usage", + get(|| async { + ( + http::StatusCode::TOO_MANY_REQUESTS, + [(http::header::RETRY_AFTER, "23")], + Json(serde_json::json!({"error":{"message":"try later"}})), + ) + .into_response() + }), + ); + let service = service_for(app).await; + + let response = opencode_account_response(&service).await; + assert_eq!(response.status(), http::StatusCode::TOO_MANY_REQUESTS); + assert_eq!( + response.headers().get(http::header::RETRY_AFTER).unwrap(), + "23" + ); + assert_eq!( + response.headers().get(http::header::CACHE_CONTROL).unwrap(), + "no-store" + ); + } + + #[tokio::test] + async fn account_response_reports_missing_proxy_credential() { + let client = OpenCodeClient::new("https://example.com/v1".into(), None).unwrap(); + let service = OpenCodeUsageService::new(client); + + let response = opencode_account_response(&service).await; + assert_eq!(response.status(), http::StatusCode::UNAUTHORIZED); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let value: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!( + value["error"]["message"] + .as_str() + .unwrap() + .contains("OPENCODE_API_KEY") + ); + } +} + #[cfg(test)] mod request_id_header_tests { use super::{REQUEST_ID_HEADER, RequestMonitorGuard, monitor_response_body}; diff --git a/tests/cli.rs b/tests/cli.rs index 8cc555dd..42ee7309 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -49,6 +49,7 @@ fn help_describes_visible_commands_and_hides_demo() -> Result<(), Box Result<(), Box Result<(), Box> { + let temp = TempDir::new()?; + let mut cmd = Command::cargo_bin("claude-code-proxy")?; + cmd.args(["opencode", "usage"]) + .env("CCP_CONFIG_DIR", temp.path()) + .env_remove("CCP_OPENCODE_API_KEY") + .env_remove("OPENCODE_API_KEY") + .assert() + .failure() + .code(1) + .stderr(contains("OPENCODE_API_KEY")); + Ok(()) +} + #[test] fn invalid_command_exits_two() -> Result<(), Box> { Command::cargo_bin("claude-code-proxy")?