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
33 changes: 33 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ aho-corasick = "1.1.4"
anyhow = "1.0.102"
clap = { version = "4.6.1", features = ["derive"] }
dirs = "6.0.0"
fs2 = "0.4.3"
futures = "0.3.32"
globset = "0.4.18"
httpdate = "1.0.3"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Release binaries cover Linux x86_64 and ARM64 with glibc or musl, plus macOS on
Authenticate once for hosted inference against your organization's entitlement, or bring your own model key:

```sh
postil login # zero-config: stores a credential for hosted inference
postil login # zero-config: stores a renewable login for hosted inference
# or: export MODEL_API_KEY=... # OpenRouter is the default endpoint
# export REVIEW_MODEL=provider/qualified-model
postil doctor # validate the endpoint and repository
Expand Down
6 changes: 3 additions & 3 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ Ignore patterns remove matching paths before grounding, batching, and large-revi
| Variable | Purpose |
| --- | --- |
| `POSTIL_API_KEY`, `OPENROUTER_API_KEY`, `MODEL_API_KEY`, `LLM_API_KEY` | Model provider credential, checked in that order |
| `POSTIL_LOGIN_SERVER` | Postil web app used by `postil login`/`postil logout`; defaults to `https://postil.dev` |
| `POSTIL_LOGIN_SERVER` | Postil web app used by `postil login`, refresh, and `postil logout`; defaults to `https://postil.dev` |
| `POSTIL_API_BASE` | Model endpoint selected by the operator |
| `POSTIL_API_FORMAT` | `openai-compatible` or `anthropic` |
| `POSTIL_ENDPOINT_AUTH_HEADER`, `POSTIL_ENDPOINT_AUTH_VALUE` | Additional private-gateway authentication |
Expand All @@ -74,7 +74,7 @@ postil login
postil logout
```

`postil login` authenticates against postil.dev over a device-authorization flow (open the printed URL, enter the code) and stores a credential at `${XDG_CONFIG_HOME:-~/.config}/postil/credentials.json`, mode `0600` in a `0700` directory. That credential is a fallback: it is used only when none of `POSTIL_API_KEY`, `OPENROUTER_API_KEY`, `MODEL_API_KEY`, or `LLM_API_KEY` is set. When it is used, its `apiBase` and model select the request unless `POSTIL_API_BASE`/`REVIEW_MODEL` are set, which still win. `postil logout` revokes the credential server-side and removes the local file even if that call fails. An expired credential produces one instruction to run `postil login` again rather than a provider authentication error.
`postil login` authenticates against postil.dev over a device-authorization flow (open the printed URL, enter the code) and stores a renewable credential at `${XDG_CONFIG_HOME:-~/.config}/postil/credentials.json`, mode `0600` in a `0700` directory. That credential is a fallback: it is used only when none of `POSTIL_API_KEY`, `OPENROUTER_API_KEY`, `MODEL_API_KEY`, or `LLM_API_KEY` is set. When it is used, Postil rotates the access credential before it expires and persists the replacement; explicit API keys never trigger a refresh. Its `apiBase` and model select the request unless `POSTIL_API_BASE`/`REVIEW_MODEL` are set, which still win. `postil logout` revokes the stored refresh credential server-side and removes the local file even if that call fails. A legacy access-only login, an expired refresh inactivity window, or a rejected refresh produces one instruction to run `postil login` again rather than a provider authentication error. Temporary refresh failures retain the stored credential and ask the user to try again.

## Inspect and initialize

Expand All @@ -84,4 +84,4 @@ postil config
postil doctor
```

`postil config` prints the resolved non-secret configuration and its sources. `postil doctor` validates endpoint reachability, credential acceptance, and repository setup without printing credential values, and reports whether a login credential is present and when it expires.
`postil config` prints the resolved non-secret configuration and its sources. `postil doctor` validates endpoint reachability, credential acceptance, and repository setup without printing credential values. Both commands identify renewable logins, access expiry, refresh inactivity expiry, and legacy access-only logins.
61 changes: 41 additions & 20 deletions src/api_key.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
//! Inference API-key resolution shared by CLI runtime checks.
//!
//! [`resolve_from_process_env`] resolves only the four explicit env vars, in
//! priority order. [`resolve_effective`] adds one more source below all
//! four: a stored `postil login` credential, used only when none of the env
//! vars is set. See `config.rs`'s module doc for the full precedence
//! statement.
//! priority order. Stored-login resolution is invoked only when none of the
//! four is set. See `config.rs`'s module doc for the full precedence statement.

use std::path::Path;
use std::future::Future;

use anyhow::Result;

use crate::credentials::{self, Credentials};
#[cfg(test)]
use crate::credentials::Credentials;

pub(crate) const API_KEY_ENV_VARS: [&str; 4] = [
"POSTIL_API_KEY",
Expand All @@ -33,20 +32,7 @@ pub(crate) fn resolve_with(mut lookup: impl FnMut(&str) -> Option<String>) -> Op
.find_map(|name| lookup(name).filter(|value| !value.trim().is_empty()))
}

/// Bearer key resolved for inference, falling back to a stored `postil
/// login` credential when none of the four explicit env vars is set.
/// `Ok(None)` means neither source has a key; callers turn that into their
/// own "set a key or run `postil login`" message. An expired stored
/// credential is reported here as an error, so the caller surfaces one
/// actionable instruction instead of a confusing upstream auth failure once
/// the request reaches the provider.
pub(crate) fn resolve_effective(credentials_path: &Path) -> Result<Option<String>> {
resolve_effective_with(
|name| std::env::var(name).ok(),
|| credentials::read(credentials_path),
)
}

#[cfg(test)]
pub(crate) fn resolve_effective_with(
env_lookup: impl FnMut(&str) -> Option<String>,
credential_lookup: impl FnOnce() -> Result<Option<Credentials>>,
Expand All @@ -64,10 +50,29 @@ pub(crate) fn resolve_effective_with(
Ok(Some(credentials.token))
}

/// Resolves an explicit key without polling the stored-login future. This
/// keeps BYOK invocations independent of both the credential file and the
/// Postil authentication service.
pub(crate) async fn resolve_explicit_or_stored<F>(
explicit: Option<String>,
stored: F,
) -> Result<Option<String>>
where
F: Future<Output = Result<Option<String>>>,
{
match explicit {
Some(key) => Ok(Some(key)),
None => stored.await,
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::credentials;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};

fn resolve(pairs: &[(&str, &str)]) -> Option<String> {
let values: HashMap<&str, &str> = pairs.iter().copied().collect();
Expand Down Expand Up @@ -112,6 +117,8 @@ mod tests {
version: credentials::CREDENTIALS_VERSION,
token: "pcli_stored-token-not-a-real-secret".to_string(),
expires_at: expires_at.to_string(),
refresh_token: None,
refresh_expires_at: None,
api_base: "https://postil.dev/api/inference/v1".to_string(),
org: "runatlas-is".to_string(),
model: "z-ai/glm-5.2".to_string(),
Expand Down Expand Up @@ -168,4 +175,18 @@ mod tests {
assert!(message.contains("postil login"));
assert!(!message.contains("pcli_stored-token-not-a-real-secret"));
}

#[tokio::test]
async fn explicit_key_bypasses_stored_login_refresh() {
let refreshed = Arc::new(AtomicBool::new(false));
let invoked = refreshed.clone();
let resolved = resolve_explicit_or_stored(Some("explicit-key".to_string()), async move {
invoked.store(true, Ordering::SeqCst);
Ok(Some("stored-key".to_string()))
})
.await
.unwrap();
assert_eq!(resolved.as_deref(), Some("explicit-key"));
assert!(!refreshed.load(Ordering::SeqCst));
}
}
2 changes: 1 addition & 1 deletion src/attribution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ async fn run_inner(input_path: &Path, config_path: Option<&Path>) -> Result<i32>
cfg.scorer == input.model,
"atomic attribution model must equal the configured primary scorer"
);
let client = LlmClient::from_env(&cfg)?;
let client = LlmClient::from_env(&cfg).await?;
let system = system_prompt();
let user = user_prompt(&input)?;
let review = client
Expand Down
6 changes: 3 additions & 3 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@
//! `MODEL_API_KEY`, and `LLM_API_KEY` (first set wins) all take priority over
//! a stored `postil login` credential. When none of those four is set and a
//! valid, unexpired credential exists at
//! `${XDG_CONFIG_HOME:-~/.config}/postil/credentials.json`, its token becomes
//! the bearer key (see `api_key::resolve_effective`) and its `apiBase`/
//! `${XDG_CONFIG_HOME:-~/.config}/postil/credentials.json`,
//! `login::resolve_stored_token` supplies its bearer key and its `apiBase`/
//! `model` replace the values resolved above -- unless `POSTIL_API_BASE` /
//! `REVIEW_MODEL` are set, which still win, applied first in this same
//! function. An expired or unreadable stored credential is left alone here;
//! `resolve_api_key` reports it as one actionable "run `postil login` again"
//! error instead of blocking commands, like `postil config`, that never need
//! error, while `postil config` reports the stored-login state without needing
//! a live key.

use std::path::{Path, PathBuf};
Expand Down
Loading