feat(credentials): make provider substitution and runtime auth mode visible
Three guardrails around which credential pays for what. 1. Boot announces the mission-runtime auth mode, and warns when subscription auth is configured on a deployment with more than one user. A consumer subscription credential may only run the account holder's own work, and that condition is otherwise invisible -- it holds today and quietly stops holding the first time someone else signs up. Adds users::count_all (dynamic query, so the offline cache needs no regeneration). 2. Reject an ANTHROPIC_API_KEY shaped like a subscription OAuth token (sk-ant-oat...) at boot rather than failing on the first model call far from the mistake. Both credentials start sk-ant-, so the confusion is easy to make and hard to spot. 3. provider_alias_for's GLM/Kimi -> anthropic.default fallback was documented as deliberate but was silent in effect: a user picking "kimi" in the UI got an agent spending the Anthropic key, with nothing saying so. It now logs the substitution, and is_exact_provider_match() lets callers tell a real family match from a substitution so a UI can say which model will actually run. Behaviour is unchanged -- only the silence is. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
af44c92dd6
commit
ca45597c79
@@ -29,6 +29,16 @@ fn build_provider(config: &AppConfig) -> Result<Arc<dyn LlmProvider>, String> {
|
|||||||
LlmProviderKind::Anthropic => {
|
LlmProviderKind::Anthropic => {
|
||||||
let key = std::env::var("ANTHROPIC_API_KEY")
|
let key = std::env::var("ANTHROPIC_API_KEY")
|
||||||
.map_err(|_| "llm.provider = \"anthropic\" requires ANTHROPIC_API_KEY")?;
|
.map_err(|_| "llm.provider = \"anthropic\" requires ANTHROPIC_API_KEY")?;
|
||||||
|
// A subscription OAuth token pasted where an API key belongs
|
||||||
|
// authenticates nothing here and fails on the first model call,
|
||||||
|
// far from the mistake. Both start `sk-ant-`, so the confusion is
|
||||||
|
// easy to make and hard to spot.
|
||||||
|
if key.starts_with("sk-ant-oat") {
|
||||||
|
return Err("ANTHROPIC_API_KEY looks like a subscription OAuth token \
|
||||||
|
(sk-ant-oat…), not a Console API key (sk-ant-api…). The \
|
||||||
|
OAuth token belongs to the `claude` CLI, not the server."
|
||||||
|
.to_string());
|
||||||
|
}
|
||||||
Ok(Arc::new(AnthropicProvider::new(key)))
|
Ok(Arc::new(AnthropicProvider::new(key)))
|
||||||
}
|
}
|
||||||
LlmProviderKind::OpenAiCompat => {
|
LlmProviderKind::OpenAiCompat => {
|
||||||
@@ -295,6 +305,29 @@ async fn run() -> Result<(), String> {
|
|||||||
let recipes = cm_api::workflow_registry::load();
|
let recipes = cm_api::workflow_registry::load();
|
||||||
eprintln!("workflow_registry: {} recipe(s) available", recipes.len());
|
eprintln!("workflow_registry: {} recipe(s) available", recipes.len());
|
||||||
}
|
}
|
||||||
|
// Announce how mission runtimes authenticate. Subscription mode is only
|
||||||
|
// legitimate for a single-operator deployment — a consumer subscription
|
||||||
|
// credential must never serve another person's work — and the mode is
|
||||||
|
// otherwise invisible until it shows up on a bill, so state it at boot.
|
||||||
|
{
|
||||||
|
let mode = cm_api::mission_runtime::runtime_auth_mode();
|
||||||
|
eprintln!(
|
||||||
|
"mission_runtime: auth mode = {} (CLAWMATES_RUNTIME_AUTH)",
|
||||||
|
mode.as_str()
|
||||||
|
);
|
||||||
|
if mode == cm_api::mission_runtime::RuntimeAuth::Subscription {
|
||||||
|
match cm_db::repo::users::count_all(&pool).await {
|
||||||
|
Ok(n) if n > 1 => eprintln!(
|
||||||
|
"mission_runtime: WARNING — subscription auth with {n} users in this \
|
||||||
|
deployment. A consumer subscription credential may only run the \
|
||||||
|
account holder's own work; move the runtime back to \
|
||||||
|
CLAWMATES_RUNTIME_AUTH=api_key before other people use it."
|
||||||
|
),
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => eprintln!("mission_runtime: user count check skipped: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
cm_api::phase_runner::spawn(pool.clone(), runtime.clone());
|
cm_api::phase_runner::spawn(pool.clone(), runtime.clone());
|
||||||
// Per-mission runtime container sweeper (C3): tears down mission
|
// Per-mission runtime container sweeper (C3): tears down mission
|
||||||
// runtime containers 30 min after the mission reaches a terminal
|
// runtime containers 30 min after the mission reaches a terminal
|
||||||
|
|||||||
@@ -44,12 +44,43 @@ pub fn provider_alias_for(model: &str) -> &'static str {
|
|||||||
// haven't stood up `glm.default` / `moonshot.default` provider
|
// haven't stood up `glm.default` / `moonshot.default` provider
|
||||||
// rows in the runtime template. Swap to their own family aliases
|
// rows in the runtime template. Swap to their own family aliases
|
||||||
// once the compose env carries the corresponding provider config.
|
// once the compose env carries the corresponding provider config.
|
||||||
"glm" | "glm-4.6" | "glm4.6" | "glm-4.7" | "glm4.7" | "glm-5.2" | "glm5.2" | "glm5" => {
|
//
|
||||||
|
// The substitution is deliberate but was previously silent, which made
|
||||||
|
// it a billing surprise: a user picking "kimi" in the UI got an agent
|
||||||
|
// that spends the Anthropic key, with nothing anywhere saying so. Log
|
||||||
|
// it so the cost lands where someone can see it.
|
||||||
|
"glm" | "glm-4.6" | "glm4.6" | "glm-4.7" | "glm4.7" | "glm-5.2" | "glm5.2" | "glm5"
|
||||||
|
| "kimi" | "kimi-k2" | "kimi-for-coding" => {
|
||||||
|
eprintln!(
|
||||||
|
"runtime_provision: model {m:?} has no provider family configured — \
|
||||||
|
substituting anthropic.default, which spends ANTHROPIC_API_KEY"
|
||||||
|
);
|
||||||
"anthropic.default"
|
"anthropic.default"
|
||||||
}
|
}
|
||||||
"kimi" | "kimi-k2" | "kimi-for-coding" => "anthropic.default",
|
_ => {
|
||||||
_ => "anthropic.default",
|
if !m.is_empty() {
|
||||||
|
eprintln!(
|
||||||
|
"runtime_provision: unrecognised model {m:?} — defaulting to \
|
||||||
|
anthropic.default"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
"anthropic.default"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `provider_alias_for` resolved this model to its own family, or
|
||||||
|
/// substituted a different one.
|
||||||
|
///
|
||||||
|
/// Callers that surface a model choice to a user can use this to say so rather
|
||||||
|
/// than letting the substitution be discovered on an invoice. Kept alongside
|
||||||
|
/// `provider_alias_for` so the two can't disagree about what counts as a match.
|
||||||
|
pub fn is_exact_provider_match(model: &str) -> bool {
|
||||||
|
let m = model.trim().to_ascii_lowercase();
|
||||||
|
m.starts_with("claude")
|
||||||
|
|| m.starts_with("gemini")
|
||||||
|
|| m.starts_with("llama")
|
||||||
|
|| m.starts_with("groq")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Talks to a live ZeroClaw runtime's config API to provision/deprovision agents.
|
/// Talks to a live ZeroClaw runtime's config API to provision/deprovision agents.
|
||||||
@@ -307,6 +338,26 @@ impl RuntimeProvisioner {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// The GLM/Kimi substitution is intentional but must be reported as a
|
||||||
|
/// substitution, because its consequence is that a user who picked a
|
||||||
|
/// non-Anthropic model is spending the Anthropic key.
|
||||||
|
#[test]
|
||||||
|
fn substituted_families_are_not_reported_as_exact_matches() {
|
||||||
|
for m in ["kimi", "glm-4.7", "glm5", "kimi-k2", "something-unknown"] {
|
||||||
|
assert_eq!(super::provider_alias_for(m), "anthropic.default");
|
||||||
|
assert!(
|
||||||
|
!super::is_exact_provider_match(m),
|
||||||
|
"{m} resolves to anthropic.default by substitution, not by family"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for m in ["claude-sonnet-5", "gemini-2.5-flash", "groq-llama", "llama3"] {
|
||||||
|
assert!(
|
||||||
|
super::is_exact_provider_match(m),
|
||||||
|
"{m} should resolve to its own family"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// An explicit declaration must win over the role-name guess, in both
|
/// An explicit declaration must win over the role-name guess, in both
|
||||||
/// directions — including the case that motivated this: a role name the
|
/// directions — including the case that motivated this: a role name the
|
||||||
/// keyword list has never heard of, which used to land read-only and then
|
/// keyword list has never heard of, which used to land read-only and then
|
||||||
|
|||||||
@@ -92,6 +92,21 @@ pub async fn owner_of_workspace(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Members table for the Team page (§8.3), in join order.
|
/// Members table for the Team page (§8.3), in join order.
|
||||||
|
/// Total users across the whole deployment, not scoped to a workspace.
|
||||||
|
///
|
||||||
|
/// Used by the boot-time credential check: a consumer subscription credential
|
||||||
|
/// may only run the account holder's own work, so a deployment configured for
|
||||||
|
/// subscription auth with more than one user needs a warning.
|
||||||
|
/// Dynamic rather than `query!` so the offline query cache doesn't need
|
||||||
|
/// regenerating for a one-off count.
|
||||||
|
pub async fn count_all(pool: &PgPool) -> Result<i64, DbError> {
|
||||||
|
use sqlx::Row;
|
||||||
|
let row = sqlx::query("SELECT count(*) AS n FROM users")
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.try_get::<i64, _>("n").unwrap_or(0))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn list_by_workspace(
|
pub async fn list_by_workspace(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
workspace_id: WorkspaceId,
|
workspace_id: WorkspaceId,
|
||||||
|
|||||||
Reference in New Issue
Block a user