feat(credentials): make provider substitution and runtime auth mode visible
ci / gates (push) Successful in 8s
ci / rust (push) Failing after 11s
ci / frontend (push) Failing after 22s
ci / e2e (push) Skipped
ci / publish (push) Skipped

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:
Omar Sobh
2026-07-30 19:41:19 -07:00
co-authored by Claude Opus 5
parent af44c92dd6
commit ca45597c79
3 changed files with 102 additions and 3 deletions
+54 -3
View File
@@ -44,14 +44,45 @@ pub fn provider_alias_for(model: &str) -> &'static str {
// haven't stood up `glm.default` / `moonshot.default` provider
// rows in the runtime template. Swap to their own family aliases
// 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"
}
_ => {
if !m.is_empty() {
eprintln!(
"runtime_provision: unrecognised model {m:?} — defaulting to \
anthropic.default"
);
}
"anthropic.default"
}
"kimi" | "kimi-k2" | "kimi-for-coding" => "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.
pub struct RuntimeProvisioner {
http: reqwest::Client,
@@ -307,6 +338,26 @@ impl RuntimeProvisioner {
mod tests {
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
/// 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