Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions crates/adapters/examples/anthropic_live.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
//! Live end-to-end check: read Anthropic **API** usage with a real key and print the snapshot.
//! NOT a CI test (hits the network). Provide the key via the ANTHROPIC_API_KEY env var so the
//! check needs no keychain entry:
//!
//! ANTHROPIC_API_KEY=sk-ant-api-… cargo run -p mlt-adapters --example anthropic_live
//!
//! With a normal (non-admin) key, expect the honest-limitation note rather than a spend figure —
//! that is the point of task 008.
use mlt_adapters::{ReqwestHttp, SystemClock};
use mlt_core::domain::ProviderId;
use mlt_core::ports::{PortError, SecretStore};
use mlt_core::providers::anthropic::AnthropicStrategy;
use mlt_core::providers::{FetchContext, FetchStrategy};
use std::sync::Arc;

/// Serves the key from ANTHROPIC_API_KEY, standing in for the OS keychain for this hand-run
/// check (the real app reads the user-entered key the same way, via the `SecretStore` port).
struct EnvKey(String);
impl SecretStore for EnvKey {
fn get(&self, _key: &str) -> Result<Option<String>, PortError> {
Ok(Some(self.0.clone()))
}
fn set(&self, _key: &str, _value: &str) -> Result<(), PortError> {
Ok(())
}
fn delete(&self, _key: &str) -> Result<(), PortError> {
Ok(())
}
}

#[tokio::main]
async fn main() {
let Ok(key) = std::env::var("ANTHROPIC_API_KEY") else {
eprintln!("set ANTHROPIC_API_KEY to your sk-ant-api-… key");
std::process::exit(1);
};
let strategy = AnthropicStrategy {
secrets: Arc::new(EnvKey(key)),
http: Arc::new(ReqwestHttp::new()),
clock: Arc::new(SystemClock),
};
let ctx = FetchContext {
provider: ProviderId::new("anthropic"),
};
match strategy.fetch(&ctx).await {
Ok(snapshot) => println!("{}", serde_json::to_string_pretty(&snapshot).unwrap()),
Comment thread
ogrodev marked this conversation as resolved.
Err(e) => {
eprintln!("usage fetch failed: {e}");
std::process::exit(1);
}
}
}
52 changes: 52 additions & 0 deletions crates/adapters/examples/openai_live.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
//! Live end-to-end check: read OpenAI usage with a real key and print the snapshot. NOT a CI test
//! (hits the network). Provide the key via the OPENAI_API_KEY env var so the check needs no
//! keychain entry:
//!
//! OPENAI_API_KEY=sk-… cargo run -p mlt-adapters --example openai_live
//!
//! A normal key (not an `sk-admin…` org key) is expected to hit the honest org-usage limitation —
//! the snapshot still prints, carrying the limitation note instead of a fabricated percentage.
use mlt_adapters::{ReqwestHttp, SystemClock};
use mlt_core::domain::ProviderId;
use mlt_core::ports::{PortError, SecretStore};
use mlt_core::providers::openai::OpenAiStrategy;
use mlt_core::providers::{FetchContext, FetchStrategy};
use std::sync::Arc;

/// Serves the key from OPENAI_API_KEY, standing in for the OS keychain for this hand-run check
/// (the real app reads the user-entered key the same way, via the `SecretStore` port).
struct EnvKey(String);
impl SecretStore for EnvKey {
fn get(&self, _key: &str) -> Result<Option<String>, PortError> {
Ok(Some(self.0.clone()))
}
fn set(&self, _key: &str, _value: &str) -> Result<(), PortError> {
Ok(())
}
fn delete(&self, _key: &str) -> Result<(), PortError> {
Ok(())
}
}

#[tokio::main]
async fn main() {
let Ok(key) = std::env::var("OPENAI_API_KEY") else {
eprintln!("set OPENAI_API_KEY to your sk-… key");
std::process::exit(1);
};
let strategy = OpenAiStrategy {
secrets: Arc::new(EnvKey(key)),
http: Arc::new(ReqwestHttp::new()),
clock: Arc::new(SystemClock),
};
let ctx = FetchContext {
provider: ProviderId::new("openai"),
};
match strategy.fetch(&ctx).await {
Ok(snapshot) => println!("{}", serde_json::to_string_pretty(&snapshot).unwrap()),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Err(e) => {
eprintln!("usage fetch failed: {e}");
std::process::exit(1);
}
}
}
22 changes: 22 additions & 0 deletions crates/adapters/src/anthropic.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//! Anthropic **API** adapter: assemble the API-key usage strategy from our keychain + HTTP client.
//!
//! This is the Anthropic *API* provider (ADR 0014/0016) — distinct from the `"claude-code"`
//! subscription provider. There is no login to discover and no OAuth to refresh: the user pastes
//! a normal `sk-ant-api…` key (stored by task 003 under our own keychain service), which the
//! strategy reads via the `SecretStore` port and uses to poll Anthropic's org cost endpoint. All
//! IO lives here; the parsing and honest-note mapping are pure in [`mlt_core::providers::anthropic`].
use std::sync::Arc;

use mlt_core::providers::anthropic::AnthropicStrategy;

use crate::{KeyringSecretStore, ReqwestHttp, SystemClock, KEYCHAIN_SERVICE};

/// Build a ready-to-run Anthropic **API** usage strategy: read the stored API key from our
/// keychain and poll the org cost endpoint. The key is only ever read — never written back.
pub fn anthropic_strategy() -> AnthropicStrategy {
AnthropicStrategy {
secrets: Arc::new(KeyringSecretStore::new(KEYCHAIN_SERVICE)),
http: Arc::new(ReqwestHttp::new()),
clock: Arc::new(SystemClock),
}
}
4 changes: 4 additions & 0 deletions crates/adapters/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,30 @@
//! See `docs/adr/0006-hexagonal-core.md`.

pub mod accounts;
pub mod anthropic;
pub mod claude;
pub mod clock;
pub mod codex;
pub mod consent;
pub mod http;
pub mod identity;
pub mod labels;
pub mod openai;
pub mod openrouter;
pub(crate) mod resilience;
pub mod secrets;
pub mod sources;

pub use accounts::discovered_accounts;
pub use anthropic::anthropic_strategy;
pub use claude::{claude_account_strategy, claude_strategy, ClaudeCredentials};
pub use clock::SystemClock;
pub use codex::codex_strategy;
pub use consent::FileConsentStore;
pub use http::ReqwestHttp;
pub use identity::FileIdentityStore;
pub use labels::FileLabelStore;
pub use openai::openai_strategy;
pub use openrouter::openrouter_strategy;
pub use secrets::KeyringSecretStore;
pub use sources::LocalSourceProbe;
Expand Down
21 changes: 21 additions & 0 deletions crates/adapters/src/openai.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
//! OpenAI adapter: assemble the API-key usage strategy from our keychain + HTTP client.
//!
//! OpenAI is an API-key provider (ADR 0014/0016), so there is no login to discover and no OAuth
//! to refresh — the strategy reads the user-entered key (stored by task 003 under our own
//! keychain service) via the `SecretStore` port and polls OpenAI's org cost endpoint. All IO
//! lives here; the parsing and honesty decisions are pure in [`mlt_core::providers::openai`].
use std::sync::Arc;

use mlt_core::providers::openai::OpenAiStrategy;

use crate::{KeyringSecretStore, ReqwestHttp, SystemClock, KEYCHAIN_SERVICE};

/// Build a ready-to-run OpenAI usage strategy: read the stored API key from our keychain and poll
/// the organization cost endpoint. The key is only ever read — never written back.
pub fn openai_strategy() -> OpenAiStrategy {
OpenAiStrategy {
secrets: Arc::new(KeyringSecretStore::new(KEYCHAIN_SERVICE)),
http: Arc::new(ReqwestHttp::new()),
clock: Arc::new(SystemClock),
}
}
53 changes: 53 additions & 0 deletions crates/core/src/domain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,18 @@ impl AccountIdentity {
}
}

/// An honest, machine-readable annotation about why a snapshot reads the way it does, for
/// API-cost providers whose endpoint exposes spend with no quota (tasks 007/008). Core states
/// the fact; the UI owns all user-facing wording. `None` is the usual windowed case.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum UsageNote {
/// Real API spend over the trailing 30-day window, in USD dollars.
ApiSpend { usd: f64 },
/// The key authenticates but cannot read organization usage — it needs an org admin key.
OrgAdminKeyRequired,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct UsageSnapshot {
pub provider: ProviderId,
Expand All @@ -95,6 +107,13 @@ pub struct UsageSnapshot {
/// Which account this snapshot reports, for display (email/org), or `None` when unknown.
/// Provider-fetched, never user-entered; siloed per provider.
pub account: Option<AccountIdentity>,
/// A typed, machine-readable annotation about *why* this snapshot reads the way it does
/// (e.g. an API-cost provider that exposes spend but no quota; tasks 007/008). Core states
/// the fact; the UI owns all user-facing wording — it never renders this verbatim. `None` is
/// the usual windowed case. `#[serde(default)]` so a snapshot serialized before this field
/// existed still deserializes.
#[serde(default)]
pub note: Option<UsageNote>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
Expand Down Expand Up @@ -141,4 +160,38 @@ mod tests {
};
assert_eq!(over.remaining_percent(), 0.0);
}

#[test]
fn usage_note_serializes_to_the_tagged_wire_shape_the_frontend_expects() {
// The popover's only honesty surface (tasks 007/008) is hand-synced with this exact wire
// shape in src/lib/usage.ts. Pin it so a Rust-side variant rename or a dropped `rename_all`
// fails CI here — before it reaches the frontend, whose exhaustive switch only catches an
// *added* variant, never a renamed `kind` (which would silently render as garbage).
assert_eq!(
serde_json::to_value(UsageNote::ApiSpend { usd: 12.5 }).unwrap(),
serde_json::json!({ "kind": "api_spend", "usd": 12.5 })
);
assert_eq!(
serde_json::to_value(UsageNote::OrgAdminKeyRequired).unwrap(),
serde_json::json!({ "kind": "org_admin_key_required" })
);
}

#[test]
fn usage_snapshot_deserializes_without_a_note_field() {
// `#[serde(default)]` on `note` keeps a snapshot serialized before the field existed (or
// any payload that omits it) deserializing cleanly to `note: None`, never erroring.
let mut value = serde_json::to_value(UsageSnapshot {
provider: ProviderId::new("openai"),
windows: Vec::new(),
status: Status::Ok,
fetched_at: Timestamp(1_700_000_000_000),
account: None,
note: Some(UsageNote::ApiSpend { usd: 1.0 }),
})
.unwrap();
value.as_object_mut().unwrap().remove("note");
let snap: UsageSnapshot = serde_json::from_value(value).unwrap();
assert_eq!(snap.note, None);
}
}
Loading