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
23 changes: 12 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@ douane connects your Claude, ChatGPT (Codex), Antigravity, Grok, GitHub Copilot

## Features

- One base URL and one placeholder key for Claude Code, the Grok CLI, Boite and any OpenAI-compatible client.
- One base URL and a client key for each of Claude Code, the Grok CLI, Boite and any OpenAI-compatible client.
- Chat Completions, Anthropic Messages and Responses, streaming included, with translation between them; native Codex WebSocket sessions.
- Live quotas per account: five-hour, daily, weekly and monthly windows, reset times, plan tier, extra credits and banked resets, refreshed in the background.
- Four routing strategies, per provider if you like, and failover to another account of the same provider when one runs out.
- Conversation affinity: native session ids and `prompt_cache_key` pin a conversation to its account, so cache reads keep landing.
- Client keys: give each tool or machine its own key, limit it to some accounts, and split each provider's quota windows between them by percentage.
- Client keys: every call carries a client key, so you see which tool or machine spends what. Limit a client to some accounts, and split each provider's quota windows between clients by percentage.
- Per-account reserve: stop spending an account at 5 % (or any share) of its longest quota window, or of the windows you pick (5 hours, weekly, both), so you keep some for yourself.
- Request history with latency, token and cache counters; never conversation bodies or credentials.
- OAuth and device sign-in from the dashboard, JSON import of existing CLI credentials, and `douanectl` for scripting.
Expand Down Expand Up @@ -92,18 +92,18 @@ douane --config douane.toml

Open `http://127.0.0.1:8787/admin`, choose **Connect an account** and sign in to a provider. When douane runs on another machine, paste the callback URL or code shown by the provider back into the dialog: the provider's localhost redirect does not need to reach the server.

Then point a client at it:
Then create a client on the **Clients** page (or `douanectl client add Desktop --key-file desktop.key`) and point a tool at douane with its key:

```sh
curl http://127.0.0.1:8787/v1/models
curl http://127.0.0.1:8787/v1/models -H 'Authorization: Bearer dk_...'

curl http://127.0.0.1:8787/v1/chat/completions \
-H 'Authorization: Bearer douane' \
-H 'Authorization: Bearer dk_...' \
-H 'Content-Type: application/json' \
-d '{"model":"<id from /v1/models>","messages":[{"role":"user","content":"Hello"}],"stream":true}'
```

Model ids carry their provider prefix, for example `claude/...` or `codex/...`. Until you create a [client key](#client-keys), the key `douane` is a placeholder: authentication upstream uses the connected subscription.
Model ids carry their provider prefix, for example `claude/...` or `codex/...`. The [client key](#client-keys) only identifies the caller to douane: authentication upstream uses the connected subscription.

![Accounts page with plan, state and reserve per account](docs/images/accounts.png)

Expand All @@ -113,10 +113,10 @@ The dashboard's **Set up a tool** page writes these settings for you, with your

| Client | Settings |
| --- | --- |
| OpenAI-compatible tools | Base URL `http://127.0.0.1:8787/v1`, API key `douane`, a model id from `/v1/models` |
| Claude Code | `ANTHROPIC_BASE_URL=http://127.0.0.1:8787` (no `/v1`), `ANTHROPIC_AUTH_TOKEN=douane` |
| Grok CLI | `XAI_API_KEY=douane`, `GROK_MODELS_BASE_URL` and `GROK_XAI_API_BASE_URL` set to `http://127.0.0.1:8787/v1`, `GROK_MODELS_LIST_URL=http://127.0.0.1:8787/v1/models?provider=xai&ids=upstream` |
| [Boite](https://github.com/beboite/boite) | Subscription proxy set to the dashboard URL: Boite lists the models and draws each account's quota bars from `/v1/quotas` |
| OpenAI-compatible tools | Base URL `http://127.0.0.1:8787/v1`, API key: a client key `dk_...`, a model id from `/v1/models` |
| Claude Code | `ANTHROPIC_BASE_URL=http://127.0.0.1:8787` (no `/v1`), `ANTHROPIC_AUTH_TOKEN=dk_...` |
| Grok CLI | `XAI_API_KEY=dk_...`, `GROK_MODELS_BASE_URL` and `GROK_XAI_API_BASE_URL` set to `http://127.0.0.1:8787/v1`, `GROK_MODELS_LIST_URL=http://127.0.0.1:8787/v1/models?provider=xai&ids=upstream` |
| [Boite](https://github.com/beboite/boite) | Subscription proxy set to the dashboard URL, and its key field to a client key: Boite lists the models and draws each account's quota bars from `/v1/quotas` |

For the Grok CLI, a session saved by `grok login` takes precedence over the key; set `GROK_AUTH_PATH` to a missing file to keep it out.

Expand Down Expand Up @@ -226,11 +226,12 @@ Banked resets for Claude and Codex appear on each account card. Using one asks f

## Client keys

With no client defined, anyone who reaches the client API can use every account. Create a client on the **Clients** page, with `POST /admin/api/clients` or `douanectl client add`, and from then on every `/v1` call needs a client key, sent as the tool's API key (`Authorization: Bearer dk_...` or `x-api-key`). Other keys get 401. The key is shown once; douane keeps only its hash. Deleting the last client opens the API again.
Every `/v1` call needs a client key, sent as the tool's API key (`Authorization: Bearer dk_...` or `x-api-key`); other keys, and every call while no client exists, get 401. Create clients on the **Clients** page, with `POST /admin/api/clients` or `douanectl client add`. The key is shown once; douane keeps only its hash.

- **Access.** A client reaches every account, accounts connected later included, or only the providers, custom routes and single accounts you pick. `/v1/models` and `/v1/quotas` list only what it may use.
- **Shares.** A share is a percentage of each account-wide window (five hours, day, week, month) of every account of a provider. Shares of a provider add up to 100 % at most. What no share covers is common to every client. A client's unused share stays reserved for it until the last seventh of the window (the last day of a week), then any client may use it. A client with no share uses the common part, then freed shares. A client held back gets 429 until the window resets. In the hours before the weekly reset (`free_before_reset_hours`, see above), shares hold no one back.
- **Usage.** Providers report usage per account, not per caller, so douane attributes it: each rise of a window goes to the clients whose calls ended on that account since the previous reading, weighted by tokens, or to the calls still running. A rise that no call through douane explains counts as outside use. Calls of several clients interleaving on one account make the split an estimate; one client per account keeps it exact.
- **Consumption.** The **Clients** page shows, over 24 hours, 7 or 30 days, each client's tokens, calls, cache reads and errors, its part of the total, its tokens over time and its models, limits or not. `GET /admin/api/clients/stats?hours=168` returns the same totals by client, provider and model, and by client and hour.
- **Quota bars.** With a client key, `/v1/quotas` shows each window as the part the client has used of what it may use, so a full bar means it is held back.

```sh
Expand Down
51 changes: 23 additions & 28 deletions crates/douane/src/clients.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
//! Client keys: which subscriptions each caller may use, and the share of a
//! provider's quota windows it holds.
//!
//! With no client stored, the client API stays open as before. Once one
//! exists, every call needs a client key. A share is a percentage of each
//! Every call to the client API needs a client key: with no client stored,
//! douane refuses every call until one is created. A share is a percentage of each
//! account-wide window (five hours, day, week, month) of every account in a
//! pool. Unused shares stay reserved for their holders until the last seventh
//! of the window, when anyone may use them; what no share covers is common.
Expand Down Expand Up @@ -213,13 +213,18 @@ fn presented_key(headers: &HeaderMap) -> Option<&str> {

#[derive(Debug, PartialEq)]
pub enum Refusal {
/// No client exists yet, so no key can be right.
NoClient,
Missing,
Unknown,
}

impl Refusal {
pub fn message(&self) -> &'static str {
match self {
Refusal::NoClient => {
"douane has no client yet: create one on the Clients page or with `douanectl client add`, then send its key as the API key"
}
Refusal::Missing => {
"a douane client key is required: create one on the Clients page and send it as the API key"
}
Expand Down Expand Up @@ -335,23 +340,17 @@ impl Clients {
self.read().clone()
}

/// Keys are required as soon as one client exists.
pub fn enforced(&self) -> bool {
!self.read().is_empty()
}

/// `None` while no client exists: the API is open.
pub fn authenticate(&self, headers: &HeaderMap) -> Result<Option<Client>, Refusal> {
/// The client whose key the caller presents. There is no keyless access.
pub fn authenticate(&self, headers: &HeaderMap) -> Result<Client, Refusal> {
let list = self.read();
if list.is_empty() {
return Ok(None);
return Err(Refusal::NoClient);
}
let key = presented_key(headers).ok_or(Refusal::Missing)?;
let hash = key_hash(key);
list.iter()
.find(|c| c.key_hash == hash)
.cloned()
.map(Some)
.ok_or(Refusal::Unknown)
}

Expand Down Expand Up @@ -825,9 +824,16 @@ mod tests {
}

#[test]
fn the_api_stays_open_until_a_client_exists_then_needs_its_key() {
fn every_call_needs_a_client_key_even_before_the_first_client() {
let (_dir, clients) = clients();
assert_eq!(clients.authenticate(&HeaderMap::new()), Ok(None));
assert_eq!(
clients.authenticate(&HeaderMap::new()),
Err(Refusal::NoClient)
);
assert_eq!(
clients.authenticate(&bearer("boite-subscription-proxy")),
Err(Refusal::NoClient)
);
let (client, key) = clients.create(named("famille")).unwrap();
assert!(key.starts_with(KEY_PREFIX));
assert!(
Expand All @@ -843,17 +849,11 @@ mod tests {
clients.authenticate(&bearer("boite-subscription-proxy")),
Err(Refusal::Unknown)
);
assert_eq!(
clients.authenticate(&bearer(&key)).unwrap().unwrap().id,
client.id
);
assert_eq!(clients.authenticate(&bearer(&key)).unwrap().id, client.id);
let mut anthropic = HeaderMap::new();
anthropic.insert("authorization", "Bearer ".parse().unwrap());
anthropic.insert("x-api-key", key.parse().unwrap());
assert_eq!(
clients.authenticate(&anthropic).unwrap().unwrap().id,
client.id
);
assert_eq!(clients.authenticate(&anthropic).unwrap().id, client.id);
// A rotated key replaces the old one.
let (_, rotated) = clients
.update(
Expand All @@ -865,12 +865,7 @@ mod tests {
)
.unwrap();
assert_eq!(clients.authenticate(&bearer(&key)), Err(Refusal::Unknown));
assert!(
clients
.authenticate(&bearer(&rotated.unwrap()))
.unwrap()
.is_some()
);
assert!(clients.authenticate(&bearer(&rotated.unwrap())).is_ok());
}

#[test]
Expand All @@ -880,7 +875,7 @@ mod tests {
drop(clients);
let reopened = Clients::open(dir.path()).unwrap();
assert_eq!(reopened.list(), vec![client]);
assert!(reopened.authenticate(&bearer(&key)).unwrap().is_some());
assert!(reopened.authenticate(&bearer(&key)).is_ok());
}

#[test]
Expand Down
144 changes: 143 additions & 1 deletion crates/douane/src/ledger.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,48 @@ pub struct Call {
pub cache_read_tokens: u64,
pub cache_write_tokens: u64,
pub error: Option<String>,
/// The douane client that made the call; `None` without client keys.
/// The douane client that made the call; `None` for calls made before
/// client keys were required.
#[serde(default)]
pub client_id: Option<String>,
}

/// What one client spent on one model of one provider over a period.
/// `client_id` is empty for calls made before keys were required.
#[derive(Clone, Debug, Default, Serialize, PartialEq)]
pub struct ClientTotals {
pub client_id: String,
pub provider: String,
pub model: String,
pub calls: u64,
pub errors: u64,
pub input_tokens: u64,
pub output_tokens: u64,
pub cache_read_tokens: u64,
pub cache_write_tokens: u64,
pub latency_ms: u64,
pub last_at: String,
}

/// One client's calls and tokens in one UTC hour, `hour` as `YYYY-MM-DDTHH`.
#[derive(Clone, Debug, Default, Serialize, PartialEq)]
pub struct ClientHour {
pub client_id: String,
pub hour: String,
pub calls: u64,
pub tokens: u64,
}

#[derive(Clone, Debug, Default, Serialize, PartialEq)]
pub struct ClientStats {
pub totals: Vec<ClientTotals>,
pub hours: Vec<ClientHour>,
}

enum Command {
Record(Call),
List(usize, oneshot::Sender<Vec<Call>>),
Stats(String, oneshot::Sender<ClientStats>),
Flush(oneshot::Sender<()>),
}

Expand Down Expand Up @@ -85,6 +119,9 @@ impl Ledger {
})().unwrap_or_default();
let _=reply.send(rows);
},
Command::Stats(since,reply) => {
let _=reply.send(client_stats(&db,&since).unwrap_or_default());
},
Command::Flush(reply) => { let _=db.execute_batch("PRAGMA wal_checkpoint(PASSIVE)"); let _=reply.send(()); },
}
}
Expand Down Expand Up @@ -132,10 +169,115 @@ impl Ledger {
}
rx.await.unwrap_or_default()
}
/// Totals by client, provider and model, and by client and hour, of the
/// calls recorded since `since` (RFC 3339, UTC).
pub async fn client_stats(&self, since: String) -> ClientStats {
let (tx, rx) = oneshot::channel();
if self.tx.send(Command::Stats(since, tx)).await.is_err() {
return ClientStats::default();
}
rx.await.unwrap_or_default()
}
pub async fn flush(&self) {
let (tx, rx) = oneshot::channel();
if self.tx.send(Command::Flush(tx)).await.is_ok() {
let _ = rx.await;
}
}
}

fn client_stats(db: &Connection, since: &str) -> rusqlite::Result<ClientStats> {
let mut totals = db.prepare(
"SELECT coalesce(client_id,''),provider,model,count(*),coalesce(sum(status>=400 OR error IS NOT NULL),0), coalesce(sum(input_tokens),0),coalesce(sum(output_tokens),0),coalesce(sum(cache_read_tokens),0), coalesce(sum(cache_write_tokens),0),coalesce(sum(latency_ms),0),max(started_at) FROM subscription_calls WHERE started_at>=?1 GROUP BY 1,2,3 ORDER BY 1,2,3",
)?;
let totals = totals
.query_map([since], |r| {
Ok(ClientTotals {
client_id: r.get(0)?,
provider: r.get(1)?,
model: r.get(2)?,
calls: r.get(3)?,
errors: r.get(4)?,
input_tokens: r.get(5)?,
output_tokens: r.get(6)?,
cache_read_tokens: r.get(7)?,
cache_write_tokens: r.get(8)?,
latency_ms: r.get(9)?,
last_at: r.get(10)?,
})
})?
.collect::<rusqlite::Result<_>>()?;
let mut hours = db.prepare(
"SELECT coalesce(client_id,''),substr(started_at,1,13),count(*), coalesce(sum(input_tokens+output_tokens),0) FROM subscription_calls WHERE started_at>=?1 GROUP BY 1,2 ORDER BY 2,1",
)?;
let hours = hours
.query_map([since], |r| {
Ok(ClientHour {
client_id: r.get(0)?,
hour: r.get(1)?,
calls: r.get(2)?,
tokens: r.get(3)?,
})
})?
.collect::<rusqlite::Result<_>>()?;
Ok(ClientStats { totals, hours })
}

#[cfg(test)]
mod tests {
use super::*;

fn call(id: &str, at: &str, client: Option<&str>, model: &str, status: u16) -> Call {
Call {
id: id.into(),
started_at: at.into(),
provider: "claude".into(),
account_id: "a".into(),
model: model.into(),
status,
latency_ms: 100,
input_tokens: 1000,
output_tokens: 50,
cache_read_tokens: 800,
client_id: client.map(Into::into),
..Call::default()
}
}

#[tokio::test]
async fn client_stats_sum_each_client_by_model_and_by_hour_since_a_time() {
let dir = tempfile::tempdir().unwrap();
let ledger = Ledger::open(&dir.path().join("calls.sqlite")).unwrap();
ledger.record(call("1", "2026-10-20T08:00:00Z", Some("nuno"), "opus", 200));
ledger.record(call("2", "2026-10-21T08:10:00Z", Some("nuno"), "opus", 200));
ledger.record(call("3", "2026-10-21T08:50:00Z", Some("nuno"), "opus", 429));
ledger.record(call("4", "2026-10-21T09:05:00Z", Some("nuno"), "sonnet", 200));
ledger.record(call("5", "2026-10-21T09:06:00Z", Some("famille"), "opus", 200));
ledger.record(call("6", "2026-10-21T09:07:00Z", None, "opus", 200));
ledger.flush().await;
let stats = ledger.client_stats("2026-10-21T00:00:00Z".into()).await;
let opus = stats
.totals
.iter()
.find(|t| t.client_id == "nuno" && t.model == "opus")
.unwrap();
assert_eq!(
(opus.calls, opus.errors, opus.input_tokens, opus.output_tokens),
(2, 1, 2000, 100)
);
assert_eq!(opus.cache_read_tokens, 1600);
assert_eq!(opus.last_at, "2026-10-21T08:50:00Z");
assert_eq!(stats.totals.len(), 4);
assert!(stats.totals.iter().any(|t| t.client_id.is_empty()));
let nuno: Vec<_> = stats
.hours
.iter()
.filter(|h| h.client_id == "nuno")
.map(|h| (h.hour.as_str(), h.calls, h.tokens))
.collect();
assert_eq!(
nuno,
[("2026-10-21T08", 2, 2100), ("2026-10-21T09", 1, 1050)]
);
}
}
1 change: 1 addition & 0 deletions crates/douane/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ pub fn router(state: Arc<AppState>) -> Router {
"/clients",
get(management::clients).post(management::create_client),
)
.route("/clients/stats", get(management::client_stats))
.route(
"/clients/{id}",
patch(management::update_client).delete(management::delete_client),
Expand Down
8 changes: 7 additions & 1 deletion crates/douane/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,13 @@ async fn main() -> Result<()> {
let listener = tokio::net::TcpListener::bind(&listen)
.await
.with_context(|| format!("binding {listen}"))?;
tracing::info!(listen=%listen,accounts=state.accounts.list().await.len(),"subscription proxy ready");
let clients = state.clients.list().len();
tracing::info!(listen=%listen,accounts=state.accounts.list().await.len(),clients,"subscription proxy ready");
if clients == 0 {
tracing::warn!(
"no client yet: every API call is refused until one is created on the Clients page or with `douanectl client add`"
);
}
axum::serve(listener, douane::router(state.clone()))
.with_graceful_shutdown(shutdown())
.await?;
Expand Down
Loading
Loading