commit 48182fda9a11903d8e5bc1df49e2a6f00deea33b from: mtmn date: Sat Sep 5 00:03:55 2026 UTC add shorthand for quota and compose commit - 182e3431233226e0209de750f784d79f6a4dedac commit + 48182fda9a11903d8e5bc1df49e2a6f00deea33b blob - 0d5452cdf5907dd381bd0e845f8e4e46646098fe blob + 095f3684503a9f5661db7b6e347fca256f267d93 --- Cargo.lock +++ Cargo.lock @@ -13,7 +13,7 @@ dependencies = [ [[package]] name = "alpaca" -version = "0.5.0" +version = "0.6.0" dependencies = [ "anyhow", "assert_cmd", @@ -918,9 +918,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.1" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown", @@ -1005,9 +1005,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.104" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" dependencies = [ "cfg-if", "futures-util", @@ -2136,9 +2136,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" dependencies = [ "cfg-if", "once_cell", @@ -2149,9 +2149,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.77" +version = "0.4.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" dependencies = [ "js-sys", "wasm-bindgen", @@ -2159,9 +2159,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2169,31 +2169,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.5", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.104" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" dependencies = [ "js-sys", "wasm-bindgen", blob - 5bf1d4db57792e827114536014f7b926f85d7824 blob + 2778703e010dc80903516e05ec2ed144be3103f0 --- Cargo.toml +++ Cargo.toml @@ -1,7 +1,7 @@ [package] name = "alpaca" authors = ["leoshimo", "mtmn"] -version = "0.5.0" +version = "0.6.0" edition = "2024" description = "Unix native interface for LLMs" repository = "https://github.com/leoshimo/cogni" blob - 11a471e935699a617c2d5715c19cb4b7c0fb58f8 blob + 8068673f35d24b281739faae2d7a9d74c764730b --- README.md +++ README.md @@ -224,17 +224,18 @@ See `man/alpaca-compose.1`. ## alpaca quota -`alpaca quota` asks a provider how much of your quota you have used. It prints -each quota window as a bar showing the used share, with a countdown to the next -reset. Where the provider reports per-model request counts, it prints those as a +`alpaca quota` shows how much of your quota you have used. It asks the +provider, then prints each quota window as a bar with a countdown to the next +reset. If the provider reports per-model counts, it also prints those as a table. -Pick the provider with `-p` (default `ollama`): +Pick the provider with `-p`. It defaults to `ollama`: ```sh $ alpaca quota # Ollama Cloud $ alpaca quota -p anthropic # Claude Code $ alpaca quota -p openai # Codex CLI +$ alpaca quota -p deepinfra # DeepInfra ``` Each provider has its own default credential and base URL: @@ -244,16 +245,30 @@ Each provider has its own default credential and base | `ollama` | `$OLLAMA_API_KEY` | `$OLLAMA_API_ENDPOINT`, then `https://ollama.com` | | `anthropic` | `$CLAUDE_CODE_OAUTH_TOKEN`, then `~/.claude/.credentials.json` | `https://api.anthropic.com` | | `openai` | `$CODEX_ACCESS_TOKEN`, then `~/.codex/auth.json` | `https://chatgpt.com` | +| `deepinfra` | `$DEEPINFRA_API_KEY` | `https://api.deepinfra.com` | -The last two read the credential file the vendor CLI writes when you log in. -They work as soon as you have logged into that CLI. Those endpoints serve the -vendor CLIs and are not documented public APIs, so they may change without -notice. Both expect a short lived OAuth token: if the credential file is stale, +For Anthropic and OpenAI, alpaca reads the credential file the vendor CLI +writes when you log in. Log in to that CLI once and it works. Those endpoints +serve the vendor CLIs, not a documented public API, so they may change without +notice. Both use a short-lived OAuth token. If the credential file goes stale, you get an authentication error until the vendor CLI refreshes it. +DeepInfra bills per token or per second, with no fixed quota. So alpaca prints +no window for it. Instead, it prints the current month's spend as a note, and +billed units per model as a table. + +The top-level `apikey` and `base_url` settings apply to Ollama only. To use +them with Anthropic, OpenAI or DeepInfra, declare them in a profile and select +it with `--profile`. A profile chosen automatically through `default_profile` +does not apply to those providers. Command-line options and +`API_KEY`/`API_ENDPOINT` override any profile. + +DeepInfra also reads a key from the top-level `deepinfra_apikey` setting, with +no profile needed. An explicit profile's `apikey` still overrides it. + Options: -- `-p, --provider `: one of `ollama`, `anthropic`, `openai` +- `-p, --provider `: one of `ollama`, `anthropic`, `openai`, `deepinfra` - `-T, --timeout `: request timeout in seconds - `--apikey `: API key or OAuth token, replacing the provider default - `--base-url `: base URL, replacing the provider default blob - 371fda1c82d78e9dc50af7835131473d8c4c2f8a blob + 968d4d7596eb6197fb4ba69442a9bd3728dafac5 --- config.example.toml +++ config.example.toml @@ -9,6 +9,7 @@ apikey = "your-api-key" base_url = "https://ollama.com" reasoning_effort = "none" default_profile = "ollama-cloud" +deepinfra_apikey = "your-deepinfra-key" [ollama-cloud] apikey = "your-ollama-cloud-key" blob - 6f75d4b9e0d69798da77a91723c0e625cc1db52c blob + 639f04b1cf41484047b231a54ed56b59103ff9c3 --- man/alpaca-config.5.scd +++ man/alpaca-config.5.scd @@ -54,6 +54,10 @@ The file uses TOML. Unknown keys and invalid values ar *default_profile* Profile to activate automatically when *--profile* is not given. +*deepinfra_apikey* + API key for *alpaca quota -p deepinfra*. Unlike *apikey*, this applies + with no profile needed. Keep the file private if you set this value. + # PROFILES A *[profile-name]* table defines a profile. Profiles inherit every top-level @@ -68,7 +72,13 @@ activate a profile, or set *default_profile* to activa *system*, *apikey*, *base_url* and *reasoning_effort*. A config setting overrides a value saved in a transcript. -*alpaca quota* uses *output_format*, *timeout*, *apikey* and *base_url*. +*alpaca quota* uses *output_format* and *timeout*. Top-level *apikey* and +*base_url* apply to Ollama only. Anthropic, OpenAI and DeepInfra use those +settings only when a profile selected with *--profile* declares them. Values +inherited from the top level, or from an automatic *default_profile*, do not +replace their provider defaults. DeepInfra also accepts a key from top-level +*deepinfra_apikey*, which needs no profile. An explicit profile's *apikey* +still overrides it. # EXAMPLE blob - 1b9ef5568282a43dd6f351a1f94621076fe17e20 blob + 9862c8b8562daf49fff8cf13c67f42df4962f50a --- man/alpaca-quota.1.scd +++ man/alpaca-quota.1.scd @@ -13,40 +13,47 @@ alpaca quota - show provider session and weekly usage # DESCRIPTION *alpaca quota* calls the usage endpoint of one provider with your credential. -It prints each quota window as a bar showing the used share and a countdown to -the next reset. Some providers also report a per-model request table. +It prints each quota window as a bar showing the used share, with a countdown +to the next reset. Some providers also report a per-model request table. -The provider is chosen with *-p*, and defaults to *ollama*. Each provider has -its own default credential and base URL, either of which can be overridden. +Choose the provider with *-p*. It defaults to *ollama*. Each provider has its +own default credential and base URL. You can override either. *ollama* - Ollama Cloud. Key from *OLLAMA_API_KEY*, base URL from + Ollama Cloud. Takes the key from *OLLAMA_API_KEY*, and the base URL from *OLLAMA_API_ENDPOINT*, else *https://ollama.com*. Prints the session - (5 hour) and weekly (7 day) quotas and a per-model request table for each. - The endpoint reports no reset time, so the countdowns are computed locally: - session windows align to UTC multiples of 5 hours from the epoch, and weekly - windows use the same rule with a 4 day offset, so all accounts share the same - boundary. + (5 hour) and weekly (7 day) quotas, each with a per-model request table. + The endpoint reports no reset time, so alpaca computes the countdowns + itself: session windows align to UTC multiples of 5 hours from the epoch, + and weekly windows use the same rule with a 4 day offset. This keeps the + boundary the same for every account. *anthropic* - Claude Code. Token from *CLAUDE_CODE_OAUTH_TOKEN*, else + Claude Code. Takes the token from *CLAUDE_CODE_OAUTH_TOKEN*, else _~/.claude/.credentials.json_. Base URL *https://api.anthropic.com*. - Prints whichever windows the account has, and extra spend when it is enabled. + Prints whichever windows the account has, plus extra spend when it is + enabled. *openai* - Codex CLI. Token from *CODEX_ACCESS_TOKEN*, else _~/.codex/auth.json_. Base - URL *https://chatgpt.com*. Prints the plan, its rate limit windows, and the - credit balance. + Codex CLI. Takes the token from *CODEX_ACCESS_TOKEN*, else + _~/.codex/auth.json_. Base URL *https://chatgpt.com*. Prints the plan, its + rate limit windows, and the credit balance. -The *anthropic* and *openai* endpoints are not documented public APIs, so they -may change without notice. Both need a short-lived OAuth token. If the -credential file is stale, you will get an authentication error until the vendor +*deepinfra* + DeepInfra. Takes the key from *DEEPINFRA_API_KEY*. Base URL + *https://api.deepinfra.com*. DeepInfra bills per token or per second, with + no fixed quota, so alpaca prints no window for it. Instead it prints the + current month's spend as a note, and billed units per model as a table. + +The *anthropic* and *openai* endpoints are not a documented public API, so +they may change without notice. Both use a short-lived OAuth token. If the +credential file goes stale, you get an authentication error until the vendor CLI refreshes it. # OPTIONS *-p*, *--provider* _provider_ - One of *ollama*, *anthropic* or *openai*. + One of *ollama*, *anthropic*, *openai* or *deepinfra*. *-T*, *--timeout* _secs_ Request timeout in seconds. Defaults to *ALPACA_TIMEOUT*, else 60. @@ -72,8 +79,11 @@ CLI refreshes it. # CONFIGURATION -This command uses *output_format*, *timeout*, *apikey* and *base_url* from the -Alpaca config file. Command-line options and environment variables take +This command uses *output_format* and *timeout* from the Alpaca config file. +Top-level *apikey* and *base_url* apply to Ollama only. For Anthropic, OpenAI +or DeepInfra, declare connection values in a profile and select it with +*--profile*. A profile chosen automatically through *default_profile* does not +apply to those providers. Command-line options and environment variables take priority. See *alpaca-config*(5). # EXIT STATUS blob - 56dfa03213b316112651c6e8ed43997494aaac7f blob + b67830cd2c8ba83c8e309142a45e47cd594a0540 --- src/cli/config_tests.rs +++ src/cli/config_tests.rs @@ -10,6 +10,14 @@ fn configured_chat(toml: &str, args: &[&str]) -> Resul } } +fn configured_quota(toml: &str, args: &[&str]) -> Result { + let config = Config::from_toml(toml)?; + match parse_args_with_config(args, &config)? { + Invocation::Quota(args) => Ok(args), + other => Err(format!("expected quota invocation, got {other:?}").into()), + } +} + #[test] fn config_parses_every_supported_setting() -> Result<()> { let args = configured_chat( @@ -127,6 +135,93 @@ fn config_applies_to_compose_and_quota() -> Result<()> } #[test] +fn generic_connection_config_only_applies_to_ollama_quota() -> Result<()> { + let toml = r#" + apikey = "ollama-key" + base_url = "https://ollama.example" + "#; + + let ollama = configured_quota(toml, &["alpaca", "quota"])?; + assert_eq!(ollama.api_key.as_deref(), Some("ollama-key")); + assert_eq!(ollama.base_url.as_deref(), Some("https://ollama.example")); + + for provider in ["anthropic", "openai", "deepinfra"] { + let quota = configured_quota(toml, &["alpaca", "quota", "-p", provider])?; + assert_eq!(quota.api_key, None); + assert_eq!(quota.base_url, None); + } + Ok(()) +} + +#[test] +fn deepinfra_apikey_is_used_without_a_profile() -> Result<()> { + let toml = r#" + apikey = "ollama-key" + deepinfra_apikey = "deepinfra-key" + "#; + + let quota = configured_quota(toml, &["alpaca", "quota", "-p", "deepinfra"])?; + assert_eq!(quota.api_key.as_deref(), Some("deepinfra-key")); + assert_eq!(quota.base_url, None); + + let ollama = configured_quota(toml, &["alpaca", "quota"])?; + assert_eq!( + ollama.api_key.as_deref(), + Some("ollama-key"), + "deepinfra_apikey does not leak into other providers" + ); + Ok(()) +} + +#[test] +fn an_explicit_profile_overrides_deepinfra_apikey() -> Result<()> { + let toml = r#" + deepinfra_apikey = "top-level-key" + + [work] + apikey = "profile-key" + "#; + + let quota = configured_quota( + toml, + &["alpaca", "quota", "--profile", "work", "-p", "deepinfra"], + )?; + assert_eq!(quota.api_key.as_deref(), Some("profile-key")); + Ok(()) +} + +#[test] +fn non_ollama_quota_only_uses_connection_values_from_explicit_profile() -> Result<()> { + let toml = r#" + apikey = "ollama-key" + base_url = "https://ollama.example" + default_profile = "ollama-cloud" + + [ollama-cloud] + apikey = "cloud-key" + base_url = "https://cloud.example" + timeout = 17 + + [codex] + apikey = "codex-key" + base_url = "https://codex.example" + "#; + + let automatic = configured_quota(toml, &["alpaca", "quota", "-p", "openai"])?; + assert_eq!(automatic.api_key, None); + assert_eq!(automatic.base_url, None); + assert_eq!(automatic.timeout, Duration::from_secs(17)); + + let explicit = configured_quota( + toml, + &["alpaca", "quota", "--profile", "codex", "-p", "openai"], + )?; + assert_eq!(explicit.api_key.as_deref(), Some("codex-key")); + assert_eq!(explicit.base_url.as_deref(), Some("https://codex.example")); + Ok(()) +} + +#[test] fn config_rejects_unknown_keys_and_non_positive_timeout() { assert!(Config::from_toml("modle = 'typo'").is_err()); assert!(Config::from_toml("timeout = 0").is_err()); blob - d2e1fba388bd246eb53dc21bc31e52e2e00efa22 blob + c8fc471830becd0924c55338e11e820ce9349999 --- src/cli.rs +++ src/cli.rs @@ -42,6 +42,7 @@ struct Settings { base_url: Option, reasoning_effort: Option, default_profile: Option, + deepinfra_apikey: Option, } #[derive(Debug, Default, Clone)] @@ -116,6 +117,37 @@ impl Config { profiles: HashMap::new(), }) } + + /// Resolve an active profile and prevent generic Ollama connection + /// settings from being reused by another quota provider. + fn for_invocation(&self, profile: Option<&str>, provider: Option) -> Result { + let active = profile.or(self.default_profile.as_deref()); + let mut config = match active { + Some(name) => self.with_profile(name)?, + None => self.clone(), + }; + + if matches!( + provider, + Some(Provider::Anthropic | Provider::OpenAI | Provider::DeepInfra) + ) { + let explicit = profile.and_then(|name| self.profiles.get(name)); + config.settings.apikey = explicit.and_then(|settings| settings.apikey.clone()); + config.settings.base_url = explicit.and_then(|settings| settings.base_url.clone()); + } + + // deepinfra_apikey is a top-level fallback, so it applies even without + // an explicit profile. + if provider == Some(Provider::DeepInfra) { + config.settings.apikey = config + .settings + .apikey + .clone() + .or_else(|| self.settings.deepinfra_apikey.clone()); + } + + Ok(config) + } } impl Settings { @@ -143,6 +175,10 @@ impl Settings { .default_profile .clone() .or_else(|| self.default_profile.clone()), + deepinfra_apikey: other + .deepinfra_apikey + .clone() + .or_else(|| self.deepinfra_apikey.clone()), } } } @@ -315,13 +351,10 @@ pub fn parse() -> Result { .try_get_matches_from(&args) .unwrap_or_else(|err| err.exit()); let profile = pre_matches.get_one::("profile").cloned(); + let provider = quota_provider(&pre_matches); let config = Config::load()?; - let profile = profile.or_else(|| config.default_profile.clone()); - let config = match profile { - Some(name) => config.with_profile(&name)?, - None => config, - }; + let config = config.for_invocation(profile.as_deref(), provider)?; let matches = cli(&config).get_matches_from(args); Ok(invocation_from_matches(&matches, &config)) @@ -345,15 +378,19 @@ fn cli(config: &Config) -> Command { fn parse_args_with_config(args: &[&str], config: &Config) -> Result { let pre_matches = cli(config).try_get_matches_from(args)?; let profile = pre_matches.get_one::("profile").cloned(); - let profile = profile.or_else(|| config.default_profile.clone()); - let config = match profile { - Some(name) => config.with_profile(&name)?, - None => config.clone(), - }; + let provider = quota_provider(&pre_matches); + let config = config.for_invocation(profile.as_deref(), provider)?; let matches = cli(&config).try_get_matches_from(args)?; Ok(invocation_from_matches(&matches, &config)) } +fn quota_provider(matches: &ArgMatches) -> Option { + matches + .subcommand_matches("quota") + .and_then(|quota| quota.get_one::("provider")) + .copied() +} + /// Arguments shared by the default command and `alpaca chat` fn chat_args(cmd: Command, config: &Config) -> Command { output_format_args(cmd, config) @@ -386,7 +423,9 @@ fn chat_args(cmd: Command, config: &Config) -> Command fn quota_command(config: &Config) -> Command { output_format_args( - Command::new("quota").about("Report how much provider quota is used"), + Command::new("quota") + .visible_alias("q") + .about("Report how much provider quota is used"), config, ) .arg( @@ -407,7 +446,9 @@ fn quota_command(config: &Config) -> Command { fn compose_command(config: &Config) -> Command { output_format_args( - Command::new("compose").about("Edit a prompt in $EDITOR, then send it"), + Command::new("compose") + .visible_alias("c") + .about("Edit a prompt in $EDITOR, then send it"), config, ) .arg(default_value_opt(model_arg(), config.model.clone())) @@ -717,7 +758,7 @@ impl ValueEnum for ColorChoice { impl ValueEnum for Provider { fn value_variants<'a>() -> &'a [Self] { - &[Self::Ollama, Self::Anthropic, Self::OpenAI] + &[Self::Ollama, Self::Anthropic, Self::OpenAI, Self::DeepInfra] } fn to_possible_value(&self) -> Option { @@ -725,6 +766,7 @@ impl ValueEnum for Provider { Self::Ollama => "ollama", Self::Anthropic => "anthropic", Self::OpenAI => "openai", + Self::DeepInfra => "deepinfra", })) } } @@ -1019,6 +1061,20 @@ mod test { } #[test] + fn quota_alias_q() -> Result<()> { + let args = quota_args(&["alpaca", "q"])?; + assert_eq!(args.provider, Provider::Ollama); + Ok(()) + } + + #[test] + fn compose_alias_c() -> Result<()> { + let args = compose_args(&["alpaca", "c"])?; + assert_eq!(args.model, None); + Ok(()) + } + + #[test] fn quota_json_shorthand() -> Result<()> { let args = quota_args(&["alpaca", "quota", "--json"])?; assert_eq!(args.output_format, OutputFormat::JSON); blob - 858707aa6886d8401f46bafc36d226f20f923c4f blob + ca996b9323cfcf11e3b73e8117ef57362229a2c3 --- src/usage/mod.rs +++ src/usage/mod.rs @@ -5,6 +5,7 @@ //! about quota windows and per-model request counts. pub mod anthropic; +pub mod deepinfra; pub mod ollama; pub mod openai; pub mod render; @@ -17,6 +18,11 @@ use serde::{Deserialize, Serialize}; use crate::Error; +struct Credential { + token: String, + account_id: Option, +} + /// Largest response body read from a usage endpoint const MAX_BODY: usize = 1 << 20; @@ -28,6 +34,7 @@ pub enum Provider { Ollama, Anthropic, OpenAI, + DeepInfra, } /// Provider usage, normalised for rendering @@ -55,14 +62,16 @@ pub struct Window { pub resets_in_secs: Option, } -/// One table of per-model request counts +/// One table of per-model counts #[derive(Debug, Clone, PartialEq, Serialize)] pub struct Table { pub heading: String, + /// Name of the counted unit, shown as the column header (e.g. "reqs") + pub unit: &'static str, pub rows: Vec, } -/// Requests made against one model +/// A count of some unit against one model, named by the enclosing [`Table`] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ModelCount { pub name: String, @@ -98,6 +107,7 @@ impl Provider { .unwrap_or_else(|| "https://ollama.com".to_string()), Self::Anthropic => "https://api.anthropic.com".to_string(), Self::OpenAI => "https://chatgpt.com".to_string(), + Self::DeepInfra => "https://api.deepinfra.com".to_string(), } } @@ -108,13 +118,15 @@ impl Provider { Self::Ollama => "/api/usage", Self::Anthropic => "/api/oauth/usage", Self::OpenAI => "/backend-api/codex/usage", + // `from=current` selects the current billing month. + Self::DeepInfra => "/payment/usage?from=current", } } /// Whether the credential is sent as a bearer token #[must_use] pub fn bearer(self) -> bool { - matches!(self, Self::Anthropic | Self::OpenAI) + matches!(self, Self::Anthropic | Self::OpenAI | Self::DeepInfra) } /// The credential this provider uses when none is given on the command line @@ -123,19 +135,38 @@ impl Provider { /// /// Returns [`Error::NoCredentials`] when no environment variable is set and /// the vendor CLI's credential file is missing or malformed. - pub fn credential(self) -> Result { + fn credential(self) -> Result { let from_env = |name: &str| std::env::var(name).ok().filter(|key| !key.is_empty()); - let key = match self { - Self::Ollama => from_env("OLLAMA_API_KEY"), + let credential = match self { + Self::Ollama => from_env("OLLAMA_API_KEY").map(|token| Credential { + token, + account_id: None, + }), Self::Anthropic => from_env("CLAUDE_CODE_OAUTH_TOKEN") - .or_else(|| anthropic::token_from_file().ok().flatten()), - Self::OpenAI => { - from_env("CODEX_ACCESS_TOKEN").or_else(|| openai::token_from_file().ok().flatten()) - } + .or_else(|| anthropic::token_from_file().ok().flatten()) + .map(|token| Credential { + token, + account_id: None, + }), + Self::OpenAI => from_env("CODEX_ACCESS_TOKEN") + .map(|token| Credential { + token, + account_id: None, + }) + .or_else(|| { + openai::credential_from_file() + .ok() + .flatten() + .map(|(token, account_id)| Credential { token, account_id }) + }), + Self::DeepInfra => from_env("DEEPINFRA_API_KEY").map(|token| Credential { + token, + account_id: None, + }), }; - key.ok_or(Error::NoCredentials { provider: self }) + credential.ok_or(Error::NoCredentials { provider: self }) } /// Normalise a usage response body @@ -148,6 +179,7 @@ impl Provider { Self::Ollama => ollama::view(body), Self::Anthropic => anthropic::view(body), Self::OpenAI => openai::view(body), + Self::DeepInfra => deepinfra::view(body), } } @@ -159,7 +191,11 @@ impl Provider { /// over TLS 1.2. No compression feature is enabled for reqwest, so no /// `Accept-Encoding` header is sent either, which the same filter requires. fn http_client(self) -> Result { - let builder = reqwest::Client::builder().user_agent(user_agent()); + let agent = match self { + Self::Anthropic => "claude-cli (external, cli)".to_string(), + _ => user_agent(), + }; + let builder = reqwest::Client::builder().user_agent(agent); let builder = match self { Self::OpenAI => builder .http1_only() @@ -176,6 +212,7 @@ impl fmt::Display for Provider { Self::Ollama => "ollama", Self::Anthropic => "anthropic", Self::OpenAI => "openai", + Self::DeepInfra => "deepinfra", }; f.write_str(name) } @@ -191,6 +228,7 @@ pub struct Client { provider: Provider, base_url: String, api_key: String, + account_id: Option, timeout: Duration, } @@ -208,8 +246,11 @@ impl Client { base_url: Option, timeout: Duration, ) -> Result { - let api_key = match api_key { - Some(key) => key, + let credential = match api_key { + Some(token) => Credential { + token, + account_id: None, + }, None => provider.credential()?, }; @@ -217,7 +258,8 @@ impl Client { http: provider.http_client()?, provider, base_url: base_url.unwrap_or_else(|| provider.base_url()), - api_key, + api_key: credential.token, + account_id: credential.account_id, timeout, }) } @@ -235,15 +277,22 @@ impl Client { self.api_key.clone() }; - let resp = self + let mut request = self .http .get(self.endpoint()) .timeout(self.timeout) .header("Authorization", credential) - .header("Accept", "application/json") - .send() - .await?; + .header("Accept", "application/json"); + if self.provider == Provider::Anthropic { + request = request.header("anthropic-beta", "oauth-2025-04-20"); + } + if let Some(account_id) = &self.account_id { + request = request.header("ChatGPT-Account-Id", account_id); + } + + let resp = request.send().await?; + let status = resp.status(); let body = read_body(resp).await?; @@ -316,6 +365,7 @@ mod test { assert_eq!(Provider::Ollama.to_string(), "ollama"); assert_eq!(Provider::Anthropic.to_string(), "anthropic"); assert_eq!(Provider::OpenAI.to_string(), "openai"); + assert_eq!(Provider::DeepInfra.to_string(), "deepinfra"); } #[test] @@ -323,6 +373,7 @@ mod test { assert!(!Provider::Ollama.bearer()); assert!(Provider::Anthropic.bearer()); assert!(Provider::OpenAI.bearer()); + assert!(Provider::DeepInfra.bearer()); } #[test] blob - /dev/null blob + 9340a12fb201054eeaee820e6b34cab833858ce3 (mode 644) --- /dev/null +++ src/usage/deepinfra.rs @@ -0,0 +1,170 @@ +//! `DeepInfra` usage +//! +//! `DeepInfra` bills per token or per second with no fixed quota, so there is no +//! window to show a used share of. The billing endpoint instead reports the +//! current month's spend, which becomes the trailing note, and a per-model +//! breakdown of billed units, mirroring Ollama's per-model request table. + +use serde::Deserialize; + +use super::{ModelCount, Table, View}; +use crate::Error; + +#[derive(Debug, Default, Deserialize)] +struct ModelMeta { + #[serde(default)] + model_name: String, +} + +#[derive(Debug, Default, Deserialize)] +struct UsageItem { + #[serde(default)] + model: ModelMeta, + /// Billed seconds or tokens + #[serde(default)] + units: u64, +} + +#[derive(Debug, Default, Deserialize)] +struct UsageMonth { + #[serde(default)] + period: String, + /// Total cost for the period, in cents + #[serde(default)] + total_cost: i64, + #[serde(default)] + items: Vec, +} + +#[derive(Debug, Deserialize)] +struct Usage { + #[serde(default)] + months: Vec, +} + +/// Normalise the `DeepInfra` billing usage response +/// +/// # Errors +/// +/// Returns an error if the body is not a `DeepInfra` usage response. +pub fn view(body: &[u8]) -> Result { + let value: serde_json::Value = + serde_json::from_slice(body).map_err(|err| Error::UnexpectedResponse(err.to_string()))?; + if !["months", "initial_month"] + .iter() + .any(|key| value.get(key).is_some()) + { + return Err(Error::UnexpectedResponse( + "response contains no deepinfra usage fields".to_string(), + )); + } + let usage: Usage = + serde_json::from_value(value).map_err(|err| Error::UnexpectedResponse(err.to_string()))?; + + let month = usage.months.first(); + let items = month.map_or([].as_slice(), |month| month.items.as_slice()); + + Ok(View { + title: "deepinfra usage".to_string(), + subtitle: month + .map(|month| month.period.clone()) + .filter(|period| !period.is_empty()), + note: month.map(|month| format!("total {}", cents(month.total_cost))), + windows: vec![], + tables: vec![Table { + heading: "models".to_string(), + unit: "units", + rows: by_model(items), + }], + }) +} + +/// Format a cent amount as a dollar figure +fn cents(cents: i64) -> String { + let whole = cents / 100; + let fraction = cents.abs() % 100; + format!("{whole}.{fraction:02} USD") +} + +/// Billed units per model, busiest first +/// +/// A model can appear in several items when its pricing changed mid-month, so +/// units are summed per model name before sorting. +fn by_model(items: &[UsageItem]) -> Vec { + let mut totals: Vec = Vec::new(); + for item in items { + if item.model.model_name.is_empty() { + continue; + } + match totals + .iter_mut() + .find(|count| count.name == item.model.model_name) + { + Some(count) => count.requests += item.units, + None => totals.push(ModelCount { + name: item.model.model_name.clone(), + requests: item.units, + }), + } + } + totals.sort_by_key(|count| std::cmp::Reverse(count.requests)); + totals +} + +#[cfg(test)] +mod test { + use super::*; + use anyhow::Result; + + #[test] + fn reads_the_current_months_total_cost_and_models() -> Result<()> { + let body = br#"{ + "months": [{"period": "2026.09", "interval": {}, "total_cost": 1234, "items": [ + {"model": {"model_name": "meta-llama/Llama-3.1-70B"}, "units": 100, "rate": 0, "cost": 900, "pricing_type": "token", "interval": {}}, + {"model": {"model_name": "meta-llama/Llama-3.1-70B"}, "units": 50, "rate": 0, "cost": 100, "pricing_type": "token", "interval": {}}, + {"model": {"model_name": "Qwen/Qwen2.5-7B"}, "units": 80, "rate": 0, "cost": 234, "pricing_type": "token", "interval": {}} + ]}], + "initial_month": "2024.01" + }"#; + + let view = view(body)?; + + assert_eq!(view.title, "deepinfra usage"); + assert_eq!(view.subtitle.as_deref(), Some("2026.09")); + assert_eq!(view.note.as_deref(), Some("total 12.34 USD")); + assert!(view.windows.is_empty()); + assert_eq!(view.tables[0].heading, "models"); + assert_eq!(view.tables[0].unit, "units"); + assert_eq!( + view.tables[0].rows, + vec![ + ModelCount { + name: "meta-llama/Llama-3.1-70B".to_string(), + requests: 150 + }, + ModelCount { + name: "Qwen/Qwen2.5-7B".to_string(), + requests: 80 + } + ], + "units for the same model are summed, then sorted busiest first" + ); + Ok(()) + } + + #[test] + fn omits_note_and_subtitle_without_months() -> Result<()> { + let view = view(br#"{"months": [], "initial_month": "2024.01"}"#)?; + + assert_eq!(view.note, None); + assert_eq!(view.subtitle, None); + assert!(view.tables[0].rows.is_empty()); + Ok(()) + } + + #[test] + fn rejects_non_usage_bodies() { + assert!(view(b"not json").is_err()); + assert!(view(b"{}").is_err()); + } +} blob - f430e5638ccc6d2517cdc38eed5d1a76ca3d56f2 blob + 7edd3c721a6b7ae084eb5b2dc8b9b97b1bb07917 --- src/usage/ollama.rs +++ src/usage/ollama.rs @@ -107,10 +107,12 @@ fn build(usage: &Usage, now: DateTime) -> View { tables: vec![ Table { heading: "session models".to_string(), + unit: "reqs", rows: sorted(&usage.limits.session.models), }, Table { heading: "weekly models".to_string(), + unit: "reqs", rows: sorted(&usage.limits.weekly.models), }, ], blob - af79856fd6db58534020742a9dcedcf3f79bdd04 blob + 416d9d517bd6246ab07f8d5dd6d96a468b3fc895 --- src/usage/openai.rs +++ src/usage/openai.rs @@ -51,12 +51,12 @@ struct Usage { credits: Credits, } -/// The OAuth token the Codex CLI writes when you log in +/// The OAuth token and account ID the Codex CLI writes when you log in /// /// # Errors /// /// Returns an error if the credential file exists but cannot be parsed. -pub fn token_from_file() -> Result, Error> { +pub fn credential_from_file() -> Result)>, Error> { #[derive(Deserialize)] struct Auth { tokens: Tokens, @@ -65,6 +65,8 @@ pub fn token_from_file() -> Result, Err #[derive(Deserialize)] struct Tokens { access_token: String, + #[serde(default)] + account_id: Option, } let path = crate::home_dir()?.join(".codex").join("auth.json"); @@ -75,7 +77,9 @@ pub fn token_from_file() -> Result, Err }; let auth: Auth = serde_json::from_slice(&data)?; - Ok(Some(auth.tokens.access_token).filter(|token| !token.is_empty())) + let token = auth.tokens.access_token; + let account_id = auth.tokens.account_id.filter(|id| !id.is_empty()); + Ok((!token.is_empty()).then_some((token, account_id))) } /// Normalise the Codex usage response blob - 22ae73796ea44d2c93c71930324702ae2e3b28ee blob + 9792cdefe7306a63fd9505a433b040f9ac390aed --- src/usage/render.rs +++ src/usage/render.rs @@ -95,7 +95,12 @@ pub fn render(mut dest: impl Write, view: &View, style let headings = view .tables .iter() - .map(|table| format!("{:COUNT_WIDTH$}", table.heading, "reqs")) + .map(|table| { + format!( + "{:COUNT_WIDTH$}", + table.heading, table.unit + ) + }) .collect::>() .join(" "); writeln!( @@ -219,6 +224,7 @@ mod test { ], tables: vec![Table { heading: "session models".to_string(), + unit: "reqs", rows: vec![ModelCount { name: "big".to_string(), requests: 9, @@ -310,6 +316,7 @@ mod test { let view = View { tables: vec![Table { heading: "models".to_string(), + unit: "reqs", rows: vec![], }], ..view() blob - 1cd9a68a621bd35fa406dfaa708eeb65132392e0 blob + c0c5d2d74dcc99af46eed8d8e2bda1cea623dc32 --- tests/chat.rs +++ tests/chat.rs @@ -4,7 +4,18 @@ use assert_cmd::Command; use assert_fs::prelude::*; use predicates::prelude::*; use serde_json::json; +use std::sync::LazyLock; +use tempfile::TempDir; +static ISOLATED_CONFIG_HOME: LazyLock = + LazyLock::new(|| TempDir::new().expect("failed to create isolated config dir")); + +fn alpaca() -> Command { + let mut cmd = Command::cargo_bin("alpaca").unwrap(); + cmd.env("XDG_CONFIG_HOME", ISOLATED_CONFIG_HOME.path()); + cmd +} + /// A canned successful native `/api/chat` response body fn ok_body() -> &'static str { r#"{ @@ -23,8 +34,7 @@ fn ok_body() -> &'static str { #[test] fn chat_no_message() { - Command::cargo_bin("alpaca") - .unwrap() + alpaca() .assert() .success() .stdout(predicate::str::contains("Usage: alpaca")); @@ -32,8 +42,7 @@ fn chat_no_message() { #[test] fn chat_no_file() { - Command::cargo_bin("alpaca") - .unwrap() + alpaca() .args(["file_does_not_exist"]) .assert() .failure() @@ -61,8 +70,7 @@ fn chat_user_message_from_flag() { .with_body(ok_body()) .create(); - let cmd = Command::cargo_bin("alpaca") - .unwrap() + let cmd = alpaca() .args(["-u", "Hello"]) .env("API_ENDPOINT", server.url()) .env("API_KEY", "ABCDE") @@ -93,8 +101,7 @@ fn chat_user_message_from_stdin() { .with_body(ok_body()) .create(); - let cmd = Command::cargo_bin("alpaca") - .unwrap() + let cmd = alpaca() .args(["-"]) .write_stdin("Hello") .env("API_ENDPOINT", server.url()) @@ -126,8 +133,7 @@ fn chat_with_reasoning_effort() { .with_body(ok_body()) .create(); - let cmd = Command::cargo_bin("alpaca") - .unwrap() + let cmd = alpaca() .args(["-u", "Hello", "--reasoning-effort", "medium"]) .env("API_ENDPOINT", server.url()) .env("API_KEY", "ABCDE") @@ -177,8 +183,7 @@ fn chat_multiple_messages() { .with_body(ok_body()) .create(); - let cmd = Command::cargo_bin("alpaca") - .unwrap() + let cmd = alpaca() .args([ "-s", "SYSTEM", "-u", "USER_1", "-a", "ASSI_1", "-u", "USER_2", "-a", "ASSI_2", ]) @@ -216,8 +221,7 @@ fn chat_api_error() { .with_body(r#"{ "error": "invalid options: temperature out of range" }"#) .create(); - let cmd = Command::cargo_bin("alpaca") - .unwrap() + let cmd = alpaca() .args(["-u", "USER", "-t", "1000"]) .write_stdin("USER_STDIN") .env("API_ENDPOINT", server.url()) @@ -263,8 +267,7 @@ fn chat_against_a_completions_endpoint() { ) .create(); - let cmd = Command::cargo_bin("alpaca") - .unwrap() + let cmd = alpaca() .args([ "-u", "Hello", @@ -306,8 +309,7 @@ fn chat_user_message_from_file() { .with_body(ok_body()) .create(); - let cmd = Command::cargo_bin("alpaca") - .unwrap() + let cmd = alpaca() .args([infile.path().to_str().unwrap()]) .env("API_ENDPOINT", server.url()) .env("API_KEY", "ABCDE") blob - 9736052666c25a9bc0b82b75fc7c01fbd428aec4 blob + 4b686f032d9b144b55be19d40cf878adfedcc433 --- tests/quota.rs +++ tests/quota.rs @@ -2,7 +2,12 @@ use assert_cmd::Command; use predicates::prelude::*; +use std::sync::LazyLock; +use tempfile::TempDir; +static ISOLATED_CONFIG_HOME: LazyLock = + LazyLock::new(|| TempDir::new().expect("failed to create isolated config dir")); + /// A canned successful Ollama `/api/usage` response body fn ok_body() -> &'static str { r#"{ @@ -18,7 +23,9 @@ fn ok_body() -> &'static str { } fn alpaca() -> Command { - Command::cargo_bin("alpaca").unwrap() + let mut cmd = Command::cargo_bin("alpaca").unwrap(); + cmd.env("XDG_CONFIG_HOME", ISOLATED_CONFIG_HOME.path()); + cmd } #[test] @@ -160,3 +167,96 @@ fn quota_rejects_oversized_responses() { "response body exceeds 1048576 bytes", )); } + +#[test] +fn anthropic_quota_sends_oauth_beta_header() { + let mut server = mockito::Server::new(); + let mock = server + .mock("GET", "/api/oauth/usage") + .match_header("authorization", "Bearer TOKEN") + .match_header("anthropic-beta", "oauth-2025-04-20") + .match_header("user-agent", "claude-cli (external, cli)") + .with_body(r#"{"five_hour":{"utilization":25}}"#) + .create(); + + alpaca() + .args([ + "quota", + "-p", + "anthropic", + "--apikey", + "TOKEN", + "--base-url", + &server.url(), + ]) + .assert() + .success(); + + mock.assert(); +} + +#[test] +fn deepinfra_quota_reports_the_current_months_spend() { + let mut server = mockito::Server::new(); + let mock = server + .mock("GET", "/payment/usage?from=current") + .match_header("authorization", "Bearer KEY") + .with_body( + r#"{"months": [{"period": "2026.09", "interval": {}, "total_cost": 1234, "items": [ + {"model": {"model_name": "meta-llama/Llama-3.1-70B"}, "units": 100, "rate": 0, "cost": 1234, "pricing_type": "token", "interval": {}} + ]}], + "initial_month": "2024.01"}"#, + ) + .create(); + + alpaca() + .args([ + "quota", + "-p", + "deepinfra", + "--apikey", + "KEY", + "--base-url", + &server.url(), + ]) + .assert() + .success() + .stdout( + predicate::str::contains("deepinfra usage") + .and(predicate::str::contains("2026.09")) + .and(predicate::str::contains("total 12.34 USD")) + .and(predicate::str::contains("meta-llama/Llama-3.1-70B")) + .and(predicate::str::contains("units")), + ); + + mock.assert(); +} + +#[test] +fn openai_quota_sends_account_id_from_codex_credentials() { + let home = TempDir::new().unwrap(); + let codex = home.path().join(".codex"); + std::fs::create_dir(&codex).unwrap(); + std::fs::write( + codex.join("auth.json"), + r#"{"tokens":{"access_token":"TOKEN","account_id":"ACCOUNT"}}"#, + ) + .unwrap(); + + let mut server = mockito::Server::new(); + let mock = server + .mock("GET", "/backend-api/codex/usage") + .match_header("authorization", "Bearer TOKEN") + .match_header("chatgpt-account-id", "ACCOUNT") + .with_body(r#"{"plan_type":"plus"}"#) + .create(); + + alpaca() + .env("HOME", home.path()) + .env_remove("CODEX_ACCESS_TOKEN") + .args(["quota", "-p", "openai", "--base-url", &server.url()]) + .assert() + .success(); + + mock.assert(); +}