Synced from monorepo

Changes:
- Persist submitter identity for /feedback
- Let custom models use rotating tokens from named auth providers
- Template stale tool/param name literals in server-native descriptions
- Minimal mode commits thinking in full, lookups as one-liners
- Tighten durable append internals
- Nudge model to end turn on no-op bash commands
- Per-fetch signing nonce in the managed-config envelope, with a server-side replay probe
- Include working tree in startup status
This commit is contained in:
grokkybara[bot] 2026-07-20 18:06:59 +01:00
commit a881e6703f
140 changed files with 6746 additions and 2377 deletions

View file

@ -651,6 +651,25 @@ pub struct RuntimeResolutionContext<'a> {
/// CLI `--storage-mode` override. `None` = defer to env/remote/default.
pub storage_mode: Option<&'a str>,
}
/// First-party credential env vars scrubbed from a BYOK auth-provider helper's
/// environment so it can't inherit the keys Grok uses for its own first-party
/// requests. Keep in sync with every first-party credential env read across the
/// crate: `auth::manager` (`GROK_AUTH`/`GROK_AUTH_PATH`), `auth_method`
/// (`XAI_API_KEY`/legacy), and the credential-bearing `env_string(...)` reads in
/// `EndpointsConfig::default`. The `provider_helper_env_scrubs_first_party_credentials`
/// test pins this against an independent audited literal, so any change here must
/// be mirrored (and re-audited) there.
pub(crate) const FIRST_PARTY_CREDENTIAL_ENV_VARS: &[&str] = &[
crate::agent::auth_method::XAI_API_KEY_ENV_VAR,
crate::agent::auth_method::LEGACY_XAI_API_KEY_ENV_VAR,
"GROK_AUTH",
"GROK_AUTH_PATH",
"GROK_DEPLOYMENT_KEY",
"GROK_EXTRA_AUTH_KEY",
"GROK_TRACE_UPLOAD_CREDENTIALS_FILE",
"OTEL_EXPORTER_OTLP_HEADERS",
"GROK_INTERNAL_OTLP_HEADERS",
];
/// Read an env var as a trimmed string. Returns `None` if unset or empty/whitespace-only.
pub(crate) fn env_string(name: &str) -> Option<String> {
let value = std::env::var(name).ok()?;
@ -1280,10 +1299,15 @@ pub struct Config {
/// `[model.*]` overrides from config.toml. Resolve via `resolve_model_list()`.
#[serde(skip)]
pub config_models: IndexMap<String, ConfigModelOverride>,
/// Warnings from `[model.*]` parsing; surfaced by `grok inspect`.
/// Warnings from `[model.*]` and `[auth_provider.*]` parsing; surfaced by
/// `grok inspect`.
#[serde(skip)]
pub model_override_warnings: Vec<super::config_model_override_parse::ModelOverrideWarning>,
pub config_warnings: Vec<super::config_model_override_parse::ConfigWarning>,
pub grok_com_config: GrokComConfig,
/// `[auth_provider.<name>]` tables, populated by
/// [`parse_auth_providers`] from trusted config layers only.
#[serde(skip)]
pub auth_providers: IndexMap<String, crate::auth::AuthProviderConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shortcuts: Option<toml::Value>,
/// Written by the client via `config_toml_edit`; absorbed so it isn't
@ -1708,8 +1732,9 @@ impl Default for Config {
doom_loop_recovery: crate::util::config::DoomLoopRecoverySettings::default(),
auto_mode: AutoModeConfig::default(),
config_models: IndexMap::new(),
model_override_warnings: Vec::new(),
config_warnings: Vec::new(),
grok_com_config: GrokComConfig::default(),
auth_providers: IndexMap::new(),
shortcuts: None,
hints: None,
ui: UiConfig::default(),
@ -1792,6 +1817,101 @@ impl Default for Config {
cfg
}
}
/// Parse `[auth_provider.<name>]` tables leniently: a malformed entry warns
/// (surfaced by `grok inspect`) and is skipped, so it fails closed for the
/// models referencing it instead of failing the whole config.
fn parse_auth_providers(
raw_config: &toml::Value,
) -> (
IndexMap<String, crate::auth::AuthProviderConfig>,
Vec<super::config_model_override_parse::ConfigWarning>,
) {
use super::config_model_override_parse::{ConfigWarning, ConfigWarningKind};
let mut providers = IndexMap::new();
let mut warnings = Vec::new();
let Some(section) = raw_config.get("auth_provider") else {
return (providers, warnings);
};
let Some(table) = section.as_table() else {
warnings.push(ConfigWarning::auth_provider_section(
ConfigWarningKind::NotATable,
format!(
"`auth_provider` must be a table of [auth_provider.<name>] entries, got {}; \
all auth providers ignored",
section.type_str()
),
));
return (providers, warnings);
};
for (name, value) in table {
let mut unknown = Vec::new();
match serde_ignored::deserialize::<_, _, crate::auth::AuthProviderConfig>(
value.clone(),
|path| unknown.push(path.to_string()),
) {
Ok(provider) => {
for key in unknown {
warnings.push(ConfigWarning::auth_provider(
name,
Some(key.as_str()),
ConfigWarningKind::UnknownField,
"unrecognized key; field ignored".to_owned(),
));
}
if !provider.is_usable() {
warnings.push(ConfigWarning::auth_provider(
name,
Some("command"),
ConfigWarningKind::InvalidValue,
"missing or empty command; referencing models resolve \
with no credential"
.to_owned(),
));
}
let skew = crate::auth::PROVIDER_TOKEN_EXPIRY_SKEW_SECS;
if provider.token_ttl_secs.is_some_and(|ttl| ttl <= skew) {
warnings.push(ConfigWarning::auth_provider(
name,
Some("token_ttl_secs"),
ConfigWarningKind::InvalidValue,
format!(
"at or below the {skew}s refresh margin; the command will \
run before every turn"
),
));
}
if let Some(timeout) = provider.timeout_secs
&& !(1..=crate::auth::PROVIDER_TIMEOUT_CEILING_SECS).contains(&timeout)
{
let ceiling = crate::auth::PROVIDER_TIMEOUT_CEILING_SECS;
warnings.push(ConfigWarning::auth_provider(
name,
Some("timeout_secs"),
ConfigWarningKind::InvalidValue,
if timeout == 0 {
"below the 1 second minimum; clamped to 1".to_owned()
} else {
format!("above the {ceiling}s maximum; clamped to {ceiling}")
},
));
}
providers.insert(name.clone(), provider);
}
Err(error) => {
warnings.push(ConfigWarning::auth_provider(
name,
None,
ConfigWarningKind::InvalidValue,
format!(
"failed to parse ({error}); provider skipped, referencing models \
resolve with no credential"
),
));
}
}
}
(providers, warnings)
}
impl Config {
/// Reject invalid glob patterns in the model-filter lists at config load, so
/// a typo fails loudly instead of silently changing availability.
@ -1847,9 +1967,9 @@ impl Config {
let raw_config = &Self::expand_auth_alias(raw_config);
let super::config_model_override_parse::ParsedModelOverrides {
models: config_models,
warnings: model_override_warnings,
warnings: config_warnings,
} = super::config_model_override_parse::parse_model_overrides(raw_config);
super::config_model_override_parse::log_model_override_warnings(&model_override_warnings);
let (auth_providers, auth_provider_warnings) = parse_auth_providers(raw_config);
let mut base = toml::Value::try_from(Self::default()).map_err(|e| e.to_string())?;
if let toml::Value::Table(ref mut t) = base {
t.remove("model");
@ -1857,6 +1977,7 @@ impl Config {
let mut raw_without_model_sections = raw_config.clone();
if let toml::Value::Table(ref mut t) = raw_without_model_sections {
t.remove("model");
t.remove("auth_provider");
}
crate::config::deep_merge_toml(&mut base, &raw_without_model_sections);
let (mut config, user_unused) =
@ -1868,7 +1989,33 @@ impl Config {
);
}
config.config_models = config_models;
config.model_override_warnings = model_override_warnings;
config.config_warnings = config_warnings;
config.auth_providers = auth_providers;
config.config_warnings.extend(auth_provider_warnings);
let declared_provider_names: std::collections::HashSet<&str> = raw_config
.get("auth_provider")
.and_then(toml::Value::as_table)
.map(|t| t.keys().map(String::as_str).collect())
.unwrap_or_default();
for (model_key, model) in &config.config_models {
if let Some(ref name) = model.auth_provider
&& !config.auth_providers.contains_key(name)
&& !declared_provider_names.contains(name.as_str())
{
config.config_warnings.push(
super::config_model_override_parse::ConfigWarning::model(
model_key,
Some("auth_provider"),
super::config_model_override_parse::ConfigWarningKind::InvalidValue,
format!(
"references [auth_provider.{name}], which is not defined; \
the model resolves with no provider credential"
),
),
);
}
}
super::config_model_override_parse::log_config_warnings(&config.config_warnings);
if config.grok_com_config.oidc.is_none() {
config.grok_com_config.oidc = OidcAuthConfig::from_env();
}
@ -3196,11 +3343,24 @@ pub fn resolve_model_list(
let entry = model_override.apply(key, base, &cfg.endpoints);
tracing::debug!(
model_key = % key, base_url = % entry.info.base_url, has_api_key = entry
.api_key.is_some(), env_key = ? entry.env_key, had_base,
.api_key.is_some(), env_key = ? entry.env_key, auth_provider = entry
.auth_provider.as_ref().map(| p | p.name.as_str()), had_base,
"config model override applied"
);
resolved.insert(key.clone(), entry);
}
for (key, entry) in resolved.iter_mut() {
if let Some(ref mut provider) = entry.auth_provider {
let config = cfg.auth_providers.get(&provider.name);
if config.is_none() {
tracing::debug!(
model_key = % key, provider = % provider.name,
"model references an undefined [auth_provider.*] table"
);
}
provider.attach_trusted_config(config);
}
}
{
let default_cw = DEFAULT_CONTEXT_WINDOW;
let donors: std::collections::HashMap<String, (std::num::NonZeroU64, ApiBackend)> =
@ -3582,6 +3742,10 @@ pub struct ConfigModelOverride {
pub api_key: Option<String>,
/// Env var name(s) for the provider key — string or array in config.toml.
pub env_key: Option<EnvKeys>,
/// Name of a `[auth_provider.<name>]` credential helper that mints
/// this model's bearer token. Static `api_key` / `env_key` win when both
/// are set.
pub auth_provider: Option<String>,
pub api_base_url: Option<String>,
pub max_completion_tokens: Option<u32>,
pub temperature: Option<f32>,
@ -3708,10 +3872,15 @@ impl ConfigModelOverride {
if self.env_key.is_some() {
entry.env_key.clone_from(&self.env_key);
}
if let Some(ref name) = self.auth_provider {
entry.auth_provider = Some(crate::auth::AuthProviderRef::unresolved(name.clone()));
}
if self.api_base_url.is_some() {
entry.api_base_url.clone_from(&self.api_base_url);
}
if self.supported_in_api.is_none() && (self.api_key.is_some() || self.env_key.is_some()) {
if self.supported_in_api.is_none()
&& (self.api_key.is_some() || self.env_key.is_some() || self.auth_provider.is_some())
{
entry.info.supported_in_api = true;
}
entry
@ -3897,6 +4066,11 @@ pub struct ModelEntry {
pub info: ModelInfo,
pub api_key: Option<String>,
pub env_key: Option<EnvKeys>,
/// Named credential helper (`[model.<id>] auth_provider = "<name>"`),
/// resolved against `[auth_provider.<name>]` by `resolve_model_list`.
/// Config-file models only: the built-in catalog never carries one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth_provider: Option<crate::auth::AuthProviderRef>,
/// When set, `base_url` is used for session auth, `api_base_url` for API-key auth.
pub api_base_url: Option<String>,
}
@ -3909,6 +4083,7 @@ impl ModelEntry {
info,
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
}
}
@ -3920,19 +4095,31 @@ impl ModelEntry {
info: ModelInfo::from_config(entry),
api_key: entry.api_key.clone(),
env_key: entry.env_key.clone(),
auth_provider: None,
api_base_url: entry.api_base_url.clone(),
}
}
/// Non-empty `api_key`, else first non-empty resolved `env_key`.
/// `None` → fall through to session / global key.
/// `None` → fall through to session / global key. Static only: never
/// consults auth-provider tokens.
pub(crate) fn own_credential(&self) -> Option<String> {
first_own_credential(self.api_key.as_deref(), self.env_key.as_ref())
}
/// `true` when the model has a non-empty `api_key` or an `env_key` that
/// resolves to a non-empty value.
/// Probes `std::env::var` at call time — result is not stable across env changes.
/// The provider governing this model's bearer: `None` when a static
/// `api_key`/`env_key` resolves. The turn paths consult this, so a
/// shadowed provider never runs.
pub(crate) fn effective_auth_provider(&self) -> Option<&crate::auth::AuthProviderRef> {
if self.own_credential().is_some() {
return None;
}
self.auth_provider.as_ref()
}
/// `true` when the model has a non-empty `api_key`, an `env_key` that
/// resolves to a non-empty value, or a named auth provider.
/// Probes `std::env::var` at call time: result is not stable across env
/// changes. Never executes a provider command.
pub fn has_own_credentials(&self) -> bool {
self.own_credential().is_some()
self.own_credential().is_some() || self.auth_provider.is_some()
}
}
impl std::ops::Deref for ModelEntry {
@ -4306,10 +4493,8 @@ pub(crate) fn first_own_credential(
.map(str::to_owned)
.or_else(|| env_key.and_then(EnvKeys::resolve_value))
}
/// Resolve credentials for a model.
/// Priority: model api_key/env_key > session token > XAI_API_KEY.
///
/// When `env_key` lists multiple names, the first set non-empty value is used.
/// Priority: model api_key/env_key > cached auth-provider token > session
/// token > XAI_API_KEY.
pub fn resolve_credentials(model: &ModelEntry, session_key: Option<&str>) -> ResolvedCredentials {
let info = model.info();
let (api_key, base_url, auth_type) = if let Some(key) = model.own_credential() {
@ -4318,6 +4503,13 @@ pub fn resolve_credentials(model: &ModelEntry, session_key: Option<&str>) -> Res
info.base_url.clone(),
xai_chat_state::AuthType::ApiKey,
)
} else if let Some(provider) = model.auth_provider.as_ref() {
debug_assert!(model.effective_auth_provider().is_some());
(
provider.cached_token(),
info.base_url.clone(),
xai_chat_state::AuthType::ApiKey,
)
} else if let Some(key) = session_key {
(
Some(key.to_owned()),
@ -4425,23 +4617,37 @@ pub struct ModelAuthFacts {
pub byok: ModelByok,
pub auth_scheme: AuthScheme,
}
/// Resolve `model_id` to its auth facts from one effective-config load.
/// Load/parse failure → `byok = Unknown`; model absent from the catalog →
/// `NotByok`. An empty `model_id` (no sampling config yet) → `Unknown`, not
/// `NotByok`, so the gate isn't activated for an unidentified model.
pub fn resolve_model_auth_facts(model_id: &str) -> ModelAuthFacts {
/// Resolve `model_id` to its auth facts and auth-provider reference from one
/// effective-config load; both ride the same memo (see
/// `SessionActor::model_auth_memo`). Load/parse failure → `byok = Unknown`;
/// model absent from the catalog → `NotByok`. An empty `model_id` (no sampling
/// config yet) → `Unknown`, not `NotByok`, so the gate isn't activated for an
/// unidentified model.
pub fn resolve_model_auth_facts_and_provider(
model_id: &str,
) -> (ModelAuthFacts, Option<crate::auth::AuthProviderRef>) {
if model_id.is_empty() {
return ModelAuthFacts {
byok: ModelByok::Unknown,
auth_scheme: AuthScheme::default(),
};
return (
ModelAuthFacts {
byok: ModelByok::Unknown,
auth_scheme: AuthScheme::default(),
},
None,
);
}
with_resolved_model(model_id, |lookup| ModelAuthFacts {
byok: byok_from_lookup(&lookup),
auth_scheme: match lookup {
ModelLookup::Loaded(Some(e)) => e.info().auth_scheme,
_ => AuthScheme::default(),
},
with_resolved_model(model_id, |lookup| {
let facts = ModelAuthFacts {
byok: byok_from_lookup(&lookup),
auth_scheme: match lookup {
ModelLookup::Loaded(Some(e)) => e.info().auth_scheme,
_ => AuthScheme::default(),
},
};
let provider = match lookup {
ModelLookup::Loaded(Some(e)) => e.effective_auth_provider().cloned(),
_ => None,
};
(facts, provider)
})
}
fn byok_from_lookup(lookup: &ModelLookup) -> ModelByok {
@ -4502,6 +4708,13 @@ pub fn resolve_aux_model_sampling_config(
if sampler.api_key.is_some() {
return Some(sampler);
}
if entry.effective_auth_provider().is_some() {
tracing::warn!(
model = % model_id,
"aux model uses an auth provider with no cached token; the caller falls back to its session default"
);
return None;
}
}
let xai_bearer = session_key
.map(|s| s.to_owned())
@ -4545,6 +4758,7 @@ pub fn resolve_aux_model_sampling_config(
},
api_key: Some(bearer),
env_key: None,
auth_provider: None,
api_base_url: None,
};
let credentials = resolve_credentials_enforced(&entry, session_key, disable_api_key_auth);
@ -4564,18 +4778,14 @@ pub fn resolve_aux_model_sampling_config(
);
None
}
/// Finalize image-describe model + sampler config for user attachments.
/// Shared so the aux resolve happy path and the
/// `None` fallback cannot diverge between those entry points.
///
/// On aux resolve `Some`, stamp session-local fields (client id, attribution, bearer,
/// retries) onto the helper config. On `None`, fall back to the active session model and
/// full config (not forcing `image_description_model` onto the agent endpoint, which 404s
/// on BYOK / non-proxy routes for internal slugs like `grok-build`).
/// Stamp the session-local fields (client id, attribution, bearer resolver,
/// retries) from the active session onto a routed aux `SamplerConfig` so a
/// helper model keeps the session's auth/attribution. Shared by image-describe
/// and the auto-mode classifier so the two can't drift.
///
/// The resolver gate is host-based, stricter than `session_token_auth_gate`:
/// a session-token deployment on a custom `models_base_url` loses aux-sampler
/// refresh, rather than risk the session bearer on a third-party endpoint.
pub fn stamp_session_local_sampler_fields(
cfg: &mut SamplerConfig,
active_session_config: &SamplerConfig,
@ -4584,9 +4794,19 @@ pub fn stamp_session_local_sampler_fields(
) {
cfg.client_identifier = client_identifier;
cfg.attribution_callback = active_session_config.attribution_callback.clone();
cfg.bearer_resolver = active_session_config.bearer_resolver.clone();
if crate::util::is_xai_api_bearer_url(&cfg.base_url) {
cfg.bearer_resolver = active_session_config.bearer_resolver.clone();
}
cfg.max_retries = max_retries;
}
/// Finalize image-describe model + sampler config for user attachments.
/// Shared so the aux resolve happy path and the `None` fallback cannot
/// diverge between those entry points.
///
/// On aux resolve `Some`, stamp session-local fields onto the helper config.
/// On `None`, fall back to the active session model and full config (not
/// forcing `image_description_model` onto the agent endpoint, which 404s on
/// BYOK / non-proxy routes for internal slugs like `grok-build`).
pub fn finalize_image_describe_sampler_config(
resolved_aux: Option<SamplerConfig>,
active_session_config: &SamplerConfig,
@ -4768,6 +4988,7 @@ fn resolve_hidden_default_web_search_sampling_config(
},
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
};
let credentials = resolve_credentials_enforced(&entry, session_key, disable_api_key_auth);
@ -4791,6 +5012,13 @@ pub fn resolve_web_search_sampling_config(
) -> Option<SamplerConfig> {
let resolved = if let Some(entry) = find_model_by_id(models, model_id).cloned() {
let credentials = resolve_credentials_enforced(&entry, session_key, disable_api_key_auth);
if credentials.api_key.is_none() && entry.effective_auth_provider().is_some() {
tracing::warn!(
web_search_model = % model_id,
"web search model uses an auth provider with no cached token; disabling web search"
);
return None;
}
Some(sampling_config_for_model(
&entry,
credentials,
@ -5330,6 +5558,256 @@ reasoning_effort = "low"
assert_eq!(resolved.base_url, "https://vendor.example/v1");
assert_eq!(resolved.api_key.as_deref(), Some("vendor-key"));
}
/// Cold cache falls back to the session model, never the xAI proxy;
/// warm cache serves the provider token at the provider endpoint.
#[tokio::test]
async fn aux_model_with_auth_provider_never_reroutes() {
let endpoints = EndpointsConfig::default();
let provider = crate::auth::AuthProviderRef::new(
"aux-provider-test".into(),
crate::auth::AuthProviderConfig {
command: "printf aux-token".into(),
args: None,
token_ttl_secs: Some(3600),
timeout_secs: None,
},
);
let mut entry = test_model_entry("m", "https://litellm.example/v1", None, None, None);
entry.auth_provider = Some(provider.clone());
let mut catalog = IndexMap::new();
catalog.insert("proxied-aux".to_string(), entry);
assert!(
resolve_aux_model_sampling_config(
"proxied-aux",
&catalog,
&endpoints,
Some("session-jwt"),
false,
None,
None,
)
.is_none(),
"cold provider cache must not reroute the aux model through the xAI proxy"
);
let _ = provider.ensure_fresh_token(None).await;
let resolved = resolve_aux_model_sampling_config(
"proxied-aux",
&catalog,
&endpoints,
Some("session-jwt"),
false,
None,
None,
)
.expect("warm cache resolves");
assert_eq!(resolved.base_url, "https://litellm.example/v1");
assert_eq!(resolved.api_key.as_deref(), Some("aux-token"));
}
/// The session bearer resolver must never be stamped onto a third-party
/// sampler: the sampler substitutes the resolver's bearer at request
/// time.
#[test]
fn session_resolver_is_not_stamped_onto_third_party_samplers() {
#[derive(Debug)]
struct SessionResolver;
impl xai_grok_sampler::BearerResolver for SessionResolver {
fn current_bearer(&self) -> Option<String> {
Some("session-jwt".into())
}
}
let session_cfg = SamplerConfig {
bearer_resolver: Some(std::sync::Arc::new(SessionResolver)),
..SamplerConfig::default()
};
let mut third_party = SamplerConfig {
base_url: "https://litellm.corp.example/v1".into(),
..SamplerConfig::default()
};
stamp_session_local_sampler_fields(&mut third_party, &session_cfg, None, None);
assert!(
third_party.bearer_resolver.is_none(),
"a third-party endpoint must keep its resolved credential"
);
let mut first_party = SamplerConfig {
base_url: EndpointsConfig::default().resolve_inference_base_url(),
..SamplerConfig::default()
};
stamp_session_local_sampler_fields(&mut first_party, &session_cfg, None, None);
assert!(
first_party.bearer_resolver.is_some(),
"first-party aux samplers keep the session refresh behavior"
);
}
/// A cold cache disables web search rather than sending an
/// unauthenticated request.
#[tokio::test]
async fn web_search_with_auth_provider_requires_warm_cache() {
let endpoints = EndpointsConfig::default();
let provider = crate::auth::AuthProviderRef::new(
"web-search-provider-test".into(),
crate::auth::AuthProviderConfig {
command: "printf ws-token".into(),
args: None,
token_ttl_secs: Some(3600),
timeout_secs: None,
},
);
let mut entry = test_model_entry("m", "https://litellm.example/v1", None, None, None);
entry.auth_provider = Some(provider.clone());
let mut catalog = IndexMap::new();
catalog.insert("proxied-search".to_string(), entry);
assert!(
resolve_web_search_sampling_config(
"proxied-search",
&catalog,
Some("session-jwt"),
false,
None,
None,
&endpoints,
)
.is_none(),
"a cold provider cache must disable web search, not send an unauthenticated request"
);
let _ = provider.ensure_fresh_token(None).await;
let resolved = resolve_web_search_sampling_config(
"proxied-search",
&catalog,
Some("session-jwt"),
false,
None,
None,
&endpoints,
)
.expect("warm cache resolves");
assert_eq!(resolved.api_key.as_deref(), Some("ws-token"));
}
/// The lenient parser warns per problem and never fails the whole
/// config.
#[test]
fn auth_provider_parse_warnings_are_lenient_and_specific() {
use super::super::config_model_override_parse::{ConfigWarningKind, WarningTarget};
let raw_config: toml::Value = toml::from_str(
r#"
[auth_provider.good]
command = "printf ok"
[auth_provider.bad-type]
command = "printf x"
token_ttl_secs = "not-a-number"
[auth_provider.typo]
command = "printf y"
timeout_seconds = 5
[auth_provider.commandless]
token_ttl_secs = 60
[auth_provider.short-ttl]
command = "printf x"
token_ttl_secs = 60
[auth_provider.zero-timeout]
command = "printf x"
timeout_secs = 0
[auth_provider.slow]
command = "printf x"
timeout_secs = 601
[model.orphaned]
model = "m"
base_url = "https://x.example/v1"
context_window = 200000
auth_provider = "does-not-exist"
"#,
)
.unwrap();
let cfg =
Config::new_from_toml_cfg(&raw_config).expect("one bad table must not fail the config");
assert!(cfg.auth_providers.contains_key("good"));
assert!(
!cfg.auth_providers.contains_key("bad-type"),
"malformed entry is skipped (fails closed)"
);
let has_provider = |name: &str, field: Option<&str>, kind: ConfigWarningKind| {
cfg.config_warnings.iter().any(|w| {
w.kind == kind
&& matches!(
& w.target, WarningTarget::AuthProvider { name : n, field : f
}
if n == name && f.as_deref() == field
)
})
};
assert!(has_provider(
"bad-type",
None,
ConfigWarningKind::InvalidValue
));
assert!(has_provider(
"typo",
Some("timeout_seconds"),
ConfigWarningKind::UnknownField
));
assert!(has_provider(
"commandless",
Some("command"),
ConfigWarningKind::InvalidValue
));
assert!(has_provider(
"short-ttl",
Some("token_ttl_secs"),
ConfigWarningKind::InvalidValue
));
assert!(has_provider(
"zero-timeout",
Some("timeout_secs"),
ConfigWarningKind::InvalidValue
));
assert!(has_provider(
"slow",
Some("timeout_secs"),
ConfigWarningKind::InvalidValue
));
let provider_reason = |name: &str| {
cfg.config_warnings
.iter()
.find(|w| {
matches!(
& w.target, WarningTarget::AuthProvider { name : n, field : f }
if n == name && f.as_deref() == Some("timeout_secs")
)
})
.map(|w| w.reason.as_str())
.unwrap_or_default()
.to_owned()
};
assert!(provider_reason("zero-timeout").contains("clamped to 1"));
assert!(provider_reason("slow").contains("clamped to 600"));
assert!(
cfg.config_warnings.iter().any(|w| {
w.kind == ConfigWarningKind::InvalidValue
&& matches!(& w.target, WarningTarget::Model
{ field, .. }
if field.as_deref() == Some("auth_provider"))
}),
"undefined reference warns at parse time: {:?}",
cfg.config_warnings
);
let raw_config: toml::Value = toml::from_str(r#"auth_provider = "oops""#).unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config)
.expect("a non-table auth_provider must not fail the config");
assert!(cfg.auth_providers.is_empty());
assert!(
cfg.config_warnings.iter().any(|w| {
matches!(w.target, WarningTarget::AuthProviderSection)
&& w.kind == ConfigWarningKind::NotATable
}),
"non-table section warns: {:?}",
cfg.config_warnings
);
}
#[test]
fn web_search_disable_api_key_auth_swaps_first_party_key_for_session() {
let endpoints = EndpointsConfig::default();
@ -5379,6 +5857,199 @@ reasoning_effort = "low"
assert_eq!(model.info.base_url, "https://api.example.com/v1");
assert_eq!(model.api_key, Some("sk-test-key-12345".to_string()));
}
#[test]
fn parses_auth_provider_tables_and_model_reference() {
let raw_config: toml::Value = toml::from_str(
r#"
[auth_provider.litellm]
command = "/usr/local/bin/litellm-token"
args = ["--scope", "corp"]
token_ttl_secs = 3600
timeout_secs = 10
[model.proxied-claude]
model = "claude-sonnet-4-5"
base_url = "https://litellm.corp.example/v1"
context_window = 200000
auth_provider = "litellm"
"#,
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
assert_eq!(
cfg.auth_providers.get("litellm"),
Some(&crate::auth::AuthProviderConfig {
command: "/usr/local/bin/litellm-token".into(),
args: Some(vec!["--scope".into(), "corp".into()]),
token_ttl_secs: Some(3600),
timeout_secs: Some(10),
})
);
let resolved = resolve_model_list(&cfg, None);
let model = resolved.get("proxied-claude").expect("model should exist");
let provider = model
.auth_provider
.as_ref()
.expect("model should reference the provider");
assert_eq!(provider.name, "litellm");
assert_eq!(provider.config.command, "/usr/local/bin/litellm-token");
assert_eq!(provider.config.token_ttl_secs, Some(3600));
assert!(
model.has_own_credentials(),
"provider-backed models classify as BYOK (session token must not leak)"
);
assert!(
model.info.supported_in_api,
"declaring an auth provider implies supported_in_api"
);
}
/// A static key shadows a fully defined provider through the real
/// `resolve_model_list` + `attach_trusted_config` pipeline (not a
/// hand-built ref): the static key wins even with the provider cache warm.
#[tokio::test]
async fn static_key_shadows_defined_provider_through_pipeline() {
let raw_config: toml::Value = toml::from_str(
r#"
[auth_provider.understudy]
command = "printf provider-token"
token_ttl_secs = 3600
[model.dual-auth]
model = "m"
base_url = "https://switchboard.example/v1"
context_window = 200000
api_key = "sk-house-key"
auth_provider = "understudy"
"#,
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
let resolved = resolve_model_list(&cfg, None);
let model = resolved.get("dual-auth").expect("model should exist");
assert_eq!(
model.effective_auth_provider().map(|p| p.name.as_str()),
None,
"a static key shadows the provider after real resolution"
);
let provider = model.auth_provider.as_ref().unwrap().clone();
let _ = provider.ensure_fresh_token(None).await;
let creds = resolve_credentials(model, Some("session-jwt"));
assert_eq!(creds.api_key.as_deref(), Some("sk-house-key"));
assert_eq!(creds.auth_type, xai_chat_state::AuthType::ApiKey);
assert_eq!(creds.base_url, "https://switchboard.example/v1");
}
#[test]
fn undefined_auth_provider_fails_closed() {
let raw_config: toml::Value = toml::from_str(
r#"
[model.orphan]
model = "m"
base_url = "https://third-party.example/v1"
context_window = 200000
auth_provider = "nope"
"#,
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
let resolved = resolve_model_list(&cfg, None);
let model = resolved.get("orphan").expect("model should exist");
let provider = model.auth_provider.as_ref().unwrap();
assert_eq!(provider.name, "nope");
assert!(
provider.config.command.is_empty(),
"undefined provider keeps an empty command"
);
assert!(model.has_own_credentials());
let creds = resolve_credentials(model, Some("session-jwt"));
assert_eq!(creds.api_key, None);
}
#[tokio::test]
async fn resolve_credentials_serves_cached_provider_token() {
use xai_chat_state::AuthType;
let mut model = test_model_entry("m", "https://litellm.example/v1", None, None, None);
let provider = crate::auth::AuthProviderRef::new(
"resolve-creds-test".into(),
crate::auth::AuthProviderConfig {
command: "printf provider-minted-token".into(),
args: None,
token_ttl_secs: Some(3600),
timeout_secs: None,
},
);
model.auth_provider = Some(provider.clone());
let creds = resolve_credentials(&model, Some("session-jwt"));
assert_eq!(creds.api_key, None, "cold cache must not run the command");
let _ = provider.ensure_fresh_token(None).await;
let creds = resolve_credentials(&model, Some("session-jwt"));
assert_eq!(creds.api_key.as_deref(), Some("provider-minted-token"));
assert_eq!(creds.auth_type, AuthType::ApiKey);
assert_eq!(creds.base_url, "https://litellm.example/v1");
}
/// A set `env_key` shadows even a warm provider cache at resolve time, so
/// the static credential wins on the wire and the provider never governs.
#[tokio::test]
async fn set_env_key_shadows_warm_provider_at_resolve_time() {
use xai_grok_test_support::EnvGuard;
let var = "GROK_TEST_ENVKEY_SHADOW";
let _guard = EnvGuard::set(var, "env-token");
let mut model = test_model_entry("m", "https://litellm.example/v1", None, Some(var), None);
let provider = crate::auth::AuthProviderRef::new(
"env-shadow-test".into(),
crate::auth::AuthProviderConfig {
command: "printf provider-token".into(),
args: None,
token_ttl_secs: Some(3600),
timeout_secs: None,
},
);
model.auth_provider = Some(provider.clone());
let _ = provider.ensure_fresh_token(None).await;
assert_eq!(
model.effective_auth_provider().map(|p| p.name.as_str()),
None,
"a resolvable env_key shadows the provider"
);
let creds = resolve_credentials(&model, Some("session-jwt"));
assert_eq!(
creds.api_key.as_deref(),
Some("env-token"),
"a set env_key must win over a warm provider cache"
);
}
/// A catalog deserialized from bytes cannot smuggle a runnable command.
#[test]
fn prefetched_entry_provider_config_comes_from_trusted_tables_only() {
let mut entry = test_model_entry("m", "https://cache.example/v1", None, None, None);
let smuggled: crate::auth::AuthProviderRef = serde_json::from_str(
r#"{"name": "cache-smuggle-test", "config": {"command": "evil"}}"#,
)
.unwrap();
entry.auth_provider = Some(smuggled);
let mut prefetched = IndexMap::new();
prefetched.insert("cached-model".to_string(), entry);
let cfg = Config::default();
let resolved = resolve_model_list(&cfg, Some(prefetched.clone()));
let provider = resolved["cached-model"].auth_provider.as_ref().unwrap();
assert_eq!(
resolve_credentials(&resolved["cached-model"], Some("session-jwt")).api_key,
None,
"an unusable provider fails closed"
);
assert_eq!(provider.config, crate::auth::AuthProviderConfig::default());
let mut cfg = Config::default();
cfg.auth_providers.insert(
"cache-smuggle-test".to_string(),
crate::auth::AuthProviderConfig {
command: "printf local".to_string(),
args: None,
token_ttl_secs: None,
timeout_secs: None,
},
);
let resolved = resolve_model_list(&cfg, Some(prefetched));
let provider = resolved["cached-model"].auth_provider.as_ref().unwrap();
assert_eq!(provider.config.command, "printf local");
}
fn test_model_entry(
model: &str,
base_url: &str,
@ -5421,6 +6092,7 @@ reasoning_effort = "low"
},
api_key: api_key.map(|s| s.to_string()),
env_key: env_key.map(EnvKeys::single),
auth_provider: None,
api_base_url: api_base_url.map(|s| s.to_string()),
}
}
@ -5957,7 +6629,10 @@ reasoning_effort = "low"
}
#[test]
fn resolve_model_auth_facts_empty_model_id_is_unknown() {
assert_eq!(resolve_model_auth_facts("").byok, ModelByok::Unknown);
assert_eq!(
resolve_model_auth_facts_and_provider("").0.byok,
ModelByok::Unknown
);
}
#[test]
fn user_override_adds_api_key_to_default_model() {
@ -10608,6 +11283,7 @@ default = "grok-4.5"
},
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
}
}

View file

@ -1,5 +1,8 @@
//! Resilient parsing for `[model.<id>]` TOML overrides.
//!
//! It also defines [`ConfigWarning`] and [`WarningTarget`], the shared warning
//! vocabulary; the `[auth_provider.*]` parser in `config.rs` emits them too.
//!
//! A model entry must survive a bad field: warn and skip the field, never
//! drop the model (managed configs must not lose catalog entries).
//!
@ -9,7 +12,7 @@
//! fail to parse on their own are pruned (one warning each) and the table is
//! parsed again. Non-table values are dropped with a warning.
//!
//! Warnings are retained on `Config::model_override_warnings` and surfaced by
//! Warnings are retained on `Config::config_warnings` and surfaced by
//! `grok inspect`.
use indexmap::IndexMap;
@ -17,10 +20,10 @@ use serde::Serialize;
use super::config::ConfigModelOverride;
/// Category for a [`ModelOverrideWarning`].
/// Category for a [`ConfigWarning`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum ModelOverrideWarningKind {
pub enum ConfigWarningKind {
/// Field name not recognized; field ignored.
UnknownField,
/// Value failed to parse; field skipped.
@ -29,29 +32,127 @@ pub enum ModelOverrideWarningKind {
DuplicateAlias,
/// Entry value is not a TOML table; entry dropped.
NotATable,
/// Fields are individually valid but conflict (e.g. `auth_provider`
/// shadowed by `api_key`/`env_key`); all fields kept, one is inert.
ConflictingFields,
/// Entry failed to parse even after skipping invalid fields; the model
/// keeps an empty override.
UnparseableEntry,
}
/// One skipped field or dropped entry from `[model.*]` parsing.
/// What a [`ConfigWarning`] is about. Serialize-only: `grok inspect --json`
/// emits it, nothing deserializes it back.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
#[serde(tag = "target", rename_all = "camelCase")]
pub enum WarningTarget {
/// The `[model]` section as a whole (e.g. not a table).
ModelSection,
/// A `[model.<key>]` entry; `field` names a key when the warning is
/// field-specific.
Model {
key: String,
#[serde(skip_serializing_if = "Option::is_none")]
field: Option<String>,
},
/// The `[auth_provider]` section as a whole.
AuthProviderSection,
/// An `[auth_provider.<name>]` table; `field` names a key when the
/// warning is field-specific.
AuthProvider {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
field: Option<String>,
},
}
impl WarningTarget {
/// The config path, e.g. `model."grok-4.5"` or `auth_provider."litellm"`.
pub(crate) fn label(&self) -> String {
match self {
Self::ModelSection => "model".to_owned(),
Self::Model { key, .. } => format!("model.\"{key}\""),
Self::AuthProviderSection => "auth_provider".to_owned(),
Self::AuthProvider { name, .. } => format!("auth_provider.\"{name}\""),
}
}
pub(crate) fn field(&self) -> Option<&str> {
match self {
Self::Model { field, .. } | Self::AuthProvider { field, .. } => field.as_deref(),
Self::ModelSection | Self::AuthProviderSection => None,
}
}
}
/// One skipped field or dropped entry from config parsing.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelOverrideWarning {
/// `None` when the warning is about the `[model]` section itself.
#[serde(skip_serializing_if = "Option::is_none")]
pub model_key: Option<String>,
/// `None` for warnings about the entry as a whole.
#[serde(skip_serializing_if = "Option::is_none")]
pub field: Option<String>,
pub kind: ModelOverrideWarningKind,
pub struct ConfigWarning {
#[serde(flatten)]
pub target: WarningTarget,
pub kind: ConfigWarningKind,
pub reason: String,
}
/// Result of [`parse_model_overrides`].
impl ConfigWarning {
pub(crate) fn model(
key: &str,
field: Option<&str>,
kind: ConfigWarningKind,
reason: String,
) -> Self {
let target = WarningTarget::Model {
key: key.to_owned(),
field: field.map(str::to_owned),
};
Self {
target,
kind,
reason,
}
}
pub(crate) fn model_section(kind: ConfigWarningKind, reason: String) -> Self {
Self {
target: WarningTarget::ModelSection,
kind,
reason,
}
}
pub(crate) fn auth_provider(
name: &str,
field: Option<&str>,
kind: ConfigWarningKind,
reason: String,
) -> Self {
let target = WarningTarget::AuthProvider {
name: name.to_owned(),
field: field.map(str::to_owned),
};
Self {
target,
kind,
reason,
}
}
pub(crate) fn auth_provider_section(kind: ConfigWarningKind, reason: String) -> Self {
Self {
target: WarningTarget::AuthProviderSection,
kind,
reason,
}
}
pub(crate) fn field(&self) -> Option<&str> {
self.target.field()
}
}
pub(crate) struct ParsedModelOverrides {
pub models: IndexMap<String, ConfigModelOverride>,
pub warnings: Vec<ModelOverrideWarning>,
pub warnings: Vec<ConfigWarning>,
}
/// Parses every `[model.<id>]` entry in `raw_config`, returning the overrides
@ -63,28 +164,26 @@ pub(crate) fn parse_model_overrides(raw_config: &toml::Value) -> ParsedModelOver
return ParsedModelOverrides { models, warnings };
};
let Some(table) = section.as_table() else {
warnings.push(ModelOverrideWarning {
model_key: None,
field: None,
kind: ModelOverrideWarningKind::NotATable,
reason: format!(
warnings.push(ConfigWarning::model_section(
ConfigWarningKind::NotATable,
format!(
"`model` must be a table of [model.<id>] entries, got {}; all model overrides ignored",
section.type_str()
),
});
));
return ParsedModelOverrides { models, warnings };
};
for (model_key, value) in table {
let Some(entry_table) = value.as_table() else {
warnings.push(ModelOverrideWarning {
model_key: Some(model_key.clone()),
field: None,
kind: ModelOverrideWarningKind::NotATable,
reason: format!(
warnings.push(ConfigWarning::model(
model_key,
None,
ConfigWarningKind::NotATable,
format!(
"expected a table like [model.\"{model_key}\"], got {}; entry dropped",
value.type_str()
),
});
));
continue;
};
let (entry, entry_warnings) = parse_model_override_table(model_key, entry_table.clone());
@ -96,7 +195,7 @@ pub(crate) fn parse_model_overrides(raw_config: &toml::Value) -> ParsedModelOver
/// Logs the warnings when they differ from the previous parse, so a
/// persistently broken config logs once per process instead of once per parse.
pub(crate) fn log_model_override_warnings(warnings: &[ModelOverrideWarning]) {
pub(crate) fn log_config_warnings(warnings: &[ConfigWarning]) {
use std::hash::{Hash as _, Hasher as _};
use std::sync::atomic::{AtomicU64, Ordering};
@ -115,8 +214,8 @@ pub(crate) fn log_model_override_warnings(warnings: &[ModelOverrideWarning]) {
for warning in warnings {
tracing::warn!(
model = warning.model_key.as_deref().unwrap_or("(section)"),
field = warning.field.as_deref().unwrap_or("(entry)"),
path = %warning.target.label(),
field = warning.field().unwrap_or("(entry)"),
kind = ?warning.kind,
reason = %warning.reason,
"model_override: skipped invalid config"
@ -133,13 +232,13 @@ pub(crate) fn log_model_override_warnings(warnings: &[ModelOverrideWarning]) {
fn parse_model_override_table(
model_key: &str,
mut table: toml::map::Map<String, toml::Value>,
) -> (ConfigModelOverride, Vec<ModelOverrideWarning>) {
) -> (ConfigModelOverride, Vec<ConfigWarning>) {
let mut warnings = Vec::new();
dedupe_aliases(model_key, &mut table, &mut warnings);
// Unknown-field warnings come from whichever parse produces the returned
// entry, so both paths report them identically.
match deserialize_with_unknown_fields(table.clone()) {
let (entry, mut warnings) = match deserialize_with_unknown_fields(table.clone()) {
Ok((entry, unknown)) => {
warnings.extend(unknown_field_warnings(model_key, unknown));
(entry, warnings)
@ -155,19 +254,57 @@ fn parse_model_override_table(
// Reachable only when fields conflict jointly, e.g. an
// alias pair missing from `ALIASES`. Keep the model
// rather than dropping it.
warnings.push(ModelOverrideWarning {
model_key: Some(model_key.to_owned()),
field: None,
kind: ModelOverrideWarningKind::UnparseableEntry,
reason: format!(
warnings.push(ConfigWarning::model(
model_key,
None,
ConfigWarningKind::UnparseableEntry,
format!(
"failed to parse after skipping invalid fields ({error}); using empty override"
),
});
));
(ConfigModelOverride::default(), warnings)
}
}
}
};
if entry.auth_provider.is_some() {
// A non-empty `api_key` always shadows; an `env_key` only shadows when
// its variable resolves at runtime, which parse time can't know. Warn
// accordingly so the message matches what actually happens.
let has_static_api_key = entry
.api_key
.as_deref()
.map(str::trim)
.is_some_and(|k| !k.is_empty());
if has_static_api_key {
warnings.push(ConfigWarning::model(
model_key,
Some("auth_provider"),
ConfigWarningKind::ConflictingFields,
"auth_provider is shadowed by api_key on this model; the static \
key always takes precedence, so the provider never runs"
.to_owned(),
));
} else if entry
.env_key
.as_ref()
.and_then(crate::agent::config::EnvKeys::primary)
.is_some()
{
warnings.push(ConfigWarning::model(
model_key,
Some("auth_provider"),
ConfigWarningKind::ConflictingFields,
"auth_provider may be shadowed by env_key on this model; env_key \
takes precedence when its variable resolves to a value, \
otherwise the provider runs"
.to_owned(),
));
}
}
(entry, warnings)
}
/// `(canonical, legacy)` key pairs that serde rejects as duplicate fields
@ -181,7 +318,7 @@ const ALIASES: &[(&str, &str)] = &[("compactions_remaining", "send_compactions_r
fn dedupe_aliases(
model_key: &str,
table: &mut toml::map::Map<String, toml::Value>,
warnings: &mut Vec<ModelOverrideWarning>,
warnings: &mut Vec<ConfigWarning>,
) {
for &(canonical, legacy) in ALIASES {
if !(table.contains_key(canonical) && table.contains_key(legacy)) {
@ -190,21 +327,21 @@ fn dedupe_aliases(
match field_parse_error(canonical, &table[canonical]) {
None => {
table.remove(legacy);
warnings.push(ModelOverrideWarning {
model_key: Some(model_key.to_owned()),
field: Some(legacy.to_owned()),
kind: ModelOverrideWarningKind::DuplicateAlias,
reason: format!("legacy alias of {canonical}; skipped in favor of {canonical}"),
});
warnings.push(ConfigWarning::model(
model_key,
Some(legacy),
ConfigWarningKind::DuplicateAlias,
format!("legacy alias of {canonical}; skipped in favor of {canonical}"),
));
}
Some(error) => {
table.remove(canonical);
warnings.push(ModelOverrideWarning {
model_key: Some(model_key.to_owned()),
field: Some(canonical.to_owned()),
kind: ModelOverrideWarningKind::InvalidValue,
reason: format!("{error}; skipped in favor of {legacy}"),
});
warnings.push(ConfigWarning::model(
model_key,
Some(canonical),
ConfigWarningKind::InvalidValue,
format!("{error}; skipped in favor of {legacy}"),
));
}
}
}
@ -222,14 +359,16 @@ fn deserialize_with_unknown_fields(
Ok((entry, unknown))
}
fn unknown_field_warnings(model_key: &str, unknown: Vec<String>) -> Vec<ModelOverrideWarning> {
fn unknown_field_warnings(model_key: &str, unknown: Vec<String>) -> Vec<ConfigWarning> {
unknown
.into_iter()
.map(|field| ModelOverrideWarning {
model_key: Some(model_key.to_owned()),
field: Some(field),
kind: ModelOverrideWarningKind::UnknownField,
reason: "unknown field".to_owned(),
.map(|field| {
ConfigWarning::model(
model_key,
Some(field.as_str()),
ConfigWarningKind::UnknownField,
"unknown field".to_owned(),
)
})
.collect()
}
@ -239,17 +378,17 @@ fn unknown_field_warnings(model_key: &str, unknown: Vec<String>) -> Vec<ModelOve
fn prune_invalid_fields(
model_key: &str,
table: &mut toml::map::Map<String, toml::Value>,
warnings: &mut Vec<ModelOverrideWarning>,
warnings: &mut Vec<ConfigWarning>,
) {
table.retain(|field, value| match field_parse_error(field, value) {
None => true,
Some(error) => {
warnings.push(ModelOverrideWarning {
model_key: Some(model_key.to_owned()),
field: Some(field.to_owned()),
kind: ModelOverrideWarningKind::InvalidValue,
reason: error.to_string(),
});
warnings.push(ConfigWarning::model(
model_key,
Some(field),
ConfigWarningKind::InvalidValue,
error.to_string(),
));
false
}
});
@ -277,12 +416,7 @@ mod tests {
crate::agent::config::Config::new_from_toml_cfg(&raw).expect("config should parse")
}
fn parse_raw(
toml_str: &str,
) -> (
IndexMap<String, ConfigModelOverride>,
Vec<ModelOverrideWarning>,
) {
fn parse_raw(toml_str: &str) -> (IndexMap<String, ConfigModelOverride>, Vec<ConfigWarning>) {
let raw: toml::Value = toml::from_str(toml_str).unwrap();
let ParsedModelOverrides { models, warnings } = parse_model_overrides(&raw);
(models, warnings)
@ -307,9 +441,9 @@ mod tests {
model.compactions_remaining,
Some(CompactionsRemaining::Fixed(1))
);
assert!(cfg.model_override_warnings.iter().any(|w| {
w.kind == ModelOverrideWarningKind::DuplicateAlias
&& w.field.as_deref() == Some("send_compactions_remaining")
assert!(cfg.config_warnings.iter().any(|w| {
w.kind == ConfigWarningKind::DuplicateAlias
&& w.field() == Some("send_compactions_remaining")
}));
let resolved = crate::agent::config::resolve_model_list(&cfg, None);
assert!(resolved.contains_key("grok-4.5"));
@ -329,7 +463,7 @@ mod tests {
model.compactions_remaining,
Some(CompactionsRemaining::Fixed(2))
);
assert!(cfg.model_override_warnings.is_empty());
assert!(cfg.config_warnings.is_empty());
}
#[test]
@ -348,9 +482,8 @@ mod tests {
.expect("grok-4.5 must remain in catalog");
assert_eq!(model.model.as_deref(), Some("grok-4.5"));
assert!(model.reasoning_effort.is_none());
assert!(cfg.model_override_warnings.iter().any(|w| {
w.kind == ModelOverrideWarningKind::InvalidValue
&& w.field.as_deref() == Some("reasoning_effort")
assert!(cfg.config_warnings.iter().any(|w| {
w.kind == ConfigWarningKind::InvalidValue && w.field() == Some("reasoning_effort")
}));
}
@ -372,12 +505,12 @@ mod tests {
);
assert_eq!(
warnings,
vec![ModelOverrideWarning {
model_key: Some("grok-4.5".to_owned()),
field: Some("future_field".to_owned()),
kind: ModelOverrideWarningKind::UnknownField,
reason: "unknown field".to_owned(),
}]
vec![ConfigWarning::model(
"grok-4.5",
Some("future_field"),
ConfigWarningKind::UnknownField,
"unknown field".to_owned(),
)]
);
}
@ -389,7 +522,7 @@ mod tests {
let (_, warnings) = parse_raw(toml_str);
warnings
.into_iter()
.filter(|w| w.kind == ModelOverrideWarningKind::UnknownField)
.filter(|w| w.kind == ConfigWarningKind::UnknownField)
.collect::<Vec<_>>()
};
let fast = unknown_of(
@ -407,7 +540,7 @@ mod tests {
);
assert_eq!(fast, slow);
assert_eq!(fast.len(), 1);
assert_eq!(fast[0].field.as_deref(), Some("temprature"));
assert_eq!(fast[0].field(), Some("temprature"));
}
#[test]
@ -428,8 +561,7 @@ mod tests {
);
assert!(entry.temperature.is_none());
assert!(warnings.iter().any(|w| {
w.kind == ModelOverrideWarningKind::InvalidValue
&& w.field.as_deref() == Some("temperature")
w.kind == ConfigWarningKind::InvalidValue && w.field() == Some("temperature")
}));
// All fields invalid: the model stays, with an empty override.
@ -447,7 +579,7 @@ mod tests {
assert!(
warnings
.iter()
.all(|w| w.kind == ModelOverrideWarningKind::InvalidValue)
.all(|w| w.kind == ConfigWarningKind::InvalidValue)
);
}
@ -466,8 +598,8 @@ mod tests {
Some(CompactionsRemaining::Fixed(2))
);
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].kind, ModelOverrideWarningKind::InvalidValue);
assert_eq!(warnings[0].field.as_deref(), Some("compactions_remaining"));
assert_eq!(warnings[0].kind, ConfigWarningKind::InvalidValue);
assert_eq!(warnings[0].field(), Some("compactions_remaining"));
}
#[test]
@ -475,9 +607,8 @@ mod tests {
let (models, warnings) = parse_raw(r#"model = "grok-4""#);
assert!(models.is_empty());
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].kind, ModelOverrideWarningKind::NotATable);
assert_eq!(warnings[0].model_key, None);
assert_eq!(warnings[0].field, None);
assert_eq!(warnings[0].kind, ConfigWarningKind::NotATable);
assert!(matches!(warnings[0].target, WarningTarget::ModelSection));
}
#[test]
@ -490,9 +621,12 @@ mod tests {
);
assert!(models.is_empty(), "a scalar cannot define a model");
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].kind, ModelOverrideWarningKind::NotATable);
assert_eq!(warnings[0].model_key.as_deref(), Some("oops"));
assert_eq!(warnings[0].field, None);
assert_eq!(warnings[0].kind, ConfigWarningKind::NotATable);
assert!(matches!(
&warnings[0].target,
WarningTarget::Model { key, field: None }
if key == "oops"
));
}
/// Exhaustive literal (no `..`): a new struct field is a compile error
@ -505,6 +639,7 @@ mod tests {
description: Some("desc".into()),
api_key: Some("key".into()),
env_key: Some(crate::agent::config::EnvKeys::single("ENV_KEY")),
auth_provider: Some("corp-gateway".into()),
api_base_url: Some("https://api.example.com".into()),
max_completion_tokens: Some(1024),
temperature: Some(0.5),
@ -541,10 +676,7 @@ mod tests {
fn parse_single_entry(
entry: toml::map::Map<String, toml::Value>,
) -> (
IndexMap<String, ConfigModelOverride>,
Vec<ModelOverrideWarning>,
) {
) -> (IndexMap<String, ConfigModelOverride>, Vec<ConfigWarning>) {
let mut model_table = toml::map::Map::new();
model_table.insert("m".to_owned(), toml::Value::Table(entry));
let mut root = toml::map::Map::new();
@ -555,14 +687,73 @@ mod tests {
}
#[test]
fn fully_populated_override_round_trips_without_warnings() {
fn fully_populated_override_round_trips_with_only_the_shadowing_warning() {
let serialized = toml::Value::try_from(fully_populated_override()).unwrap();
let (models, warnings) = parse_single_entry(serialized.as_table().unwrap().clone());
assert_eq!(warnings, Vec::new(), "no field may be skipped or unknown");
// The exhaustive literal deliberately sets `api_key`, `env_key`, AND
// `auth_provider`: the one legal-but-warned combination. Any other
// warning (skipped/unknown field) still fails the guard.
let unexpected: Vec<_> = warnings
.iter()
.filter(|w| w.kind != ConfigWarningKind::ConflictingFields)
.collect();
assert_eq!(unexpected, Vec::<&ConfigWarning>::new());
assert_eq!(warnings.len(), 1);
let reparsed = toml::Value::try_from(models.get("m").unwrap()).unwrap();
assert_eq!(reparsed, serialized, "round-trip must be lossless");
}
/// `auth_provider` alongside `api_key`/`env_key` warns (static keys
/// win in `resolve_credentials`, so the provider never runs) but keeps
/// both fields.
#[test]
fn auth_provider_shadowed_by_static_key_warns() {
let mut entry = toml::map::Map::new();
entry.insert("api_key".to_owned(), toml::Value::String("sk-x".into()));
entry.insert(
"auth_provider".to_owned(),
toml::Value::String("corp".into()),
);
let (models, warnings) = parse_single_entry(entry);
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].kind, ConfigWarningKind::ConflictingFields);
assert_eq!(warnings[0].field(), Some("auth_provider"));
let parsed = models.get("m").unwrap();
assert_eq!(parsed.api_key.as_deref(), Some("sk-x"));
assert_eq!(parsed.auth_provider.as_deref(), Some("corp"));
// Provider alone: no warning.
let mut entry = toml::map::Map::new();
entry.insert(
"auth_provider".to_owned(),
toml::Value::String("corp".into()),
);
let (_, warnings) = parse_single_entry(entry);
assert_eq!(warnings, Vec::new());
// env_key is only a conditional shadow: warn, but as "may be shadowed".
let mut entry = toml::map::Map::new();
entry.insert("env_key".to_owned(), toml::Value::String("MY_KEY".into()));
entry.insert(
"auth_provider".to_owned(),
toml::Value::String("corp".into()),
);
let (_, warnings) = parse_single_entry(entry);
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].kind, ConfigWarningKind::ConflictingFields);
assert!(warnings[0].reason.contains("may be shadowed"));
// An empty api_key does not shadow, so it must not warn.
let mut entry = toml::map::Map::new();
entry.insert("api_key".to_owned(), toml::Value::String(" ".into()));
entry.insert(
"auth_provider".to_owned(),
toml::Value::String("corp".into()),
);
let (_, warnings) = parse_single_entry(entry);
assert_eq!(warnings, Vec::new());
}
/// Drift guard: every `#[serde(alias)]` on [`ConfigModelOverride`] must
/// have a matching `ALIASES` pair, and vice versa. An unregistered alias
/// would send both-keys configs to the empty-override fallback.
@ -632,8 +823,8 @@ mod tests {
"canonical value must be retained"
);
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].kind, ModelOverrideWarningKind::DuplicateAlias);
assert_eq!(warnings[0].field.as_deref(), Some(legacy));
assert_eq!(warnings[0].kind, ConfigWarningKind::DuplicateAlias);
assert_eq!(warnings[0].field(), Some(legacy));
}
}
}

View file

@ -1388,6 +1388,7 @@ fn build_prefetched_map(
info,
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: m.api_base_url.clone().or(api_base_url_override.clone()),
};
map.insert(key, entry);
@ -2015,6 +2016,7 @@ mod tests {
info: config::ModelInfo::fallback("fp-model"),
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
};
flagged.info.show_model_fingerprint = true;
@ -2027,6 +2029,7 @@ mod tests {
info: config::ModelInfo::fallback("plain-model"),
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
},
);
@ -2037,6 +2040,7 @@ mod tests {
info: config::ModelInfo::fallback("enterprise-slug"),
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
};
custom.info.show_model_fingerprint = true;
@ -2207,6 +2211,7 @@ mod tests {
info: config::ModelInfo::fallback("test-model"),
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
},
);
@ -2261,6 +2266,7 @@ mod tests {
info: config::ModelInfo::fallback("reasoning-model"),
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
};
reasoning_entry.info.supports_reasoning_effort = true;
@ -2283,6 +2289,7 @@ mod tests {
info: config::ModelInfo::fallback("plain-model"),
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
};
prefetched.insert("plain-model".to_string(), plain_entry);
@ -2310,6 +2317,7 @@ mod tests {
info: config::ModelInfo::fallback("grok-4.5"),
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
};
no_none.info.supports_reasoning_effort = true;
@ -2328,6 +2336,7 @@ mod tests {
info: config::ModelInfo::fallback("legacy-none"),
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
};
with_none.info.supports_reasoning_effort = true;
@ -2434,6 +2443,7 @@ mod tests {
info: config::ModelInfo::fallback("reasoning-model"),
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
};
reasoning_entry.info.supports_reasoning_effort = true;
@ -2443,6 +2453,7 @@ mod tests {
info: config::ModelInfo::fallback("plain-model"),
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
};
prefetched.insert("plain-model".to_string(), plain_entry);
@ -2485,6 +2496,7 @@ mod tests {
info: config::ModelInfo::fallback(model_id),
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
}
}
@ -3268,6 +3280,7 @@ mod tests {
info: config::ModelInfo::fallback("static-one"),
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
},
);
@ -3295,6 +3308,7 @@ mod tests {
info: config::ModelInfo::fallback("oauth-only"),
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
};
oauth_only.info.supported_in_api = false;
@ -3304,6 +3318,7 @@ mod tests {
info: config::ModelInfo::fallback("public-model"),
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
};
catalog.insert("public-model".to_string(), public);

View file

@ -58,10 +58,12 @@ impl MvpAgent {
client_version,
) {
Some(mut cfg) => {
cfg.client_identifier = primary.client_identifier.clone();
cfg.attribution_callback = primary.attribution_callback.clone();
cfg.bearer_resolver = primary.bearer_resolver.clone();
cfg.max_retries = primary.max_retries;
crate::agent::config::stamp_session_local_sampler_fields(
&mut cfg,
primary,
primary.client_identifier.clone(),
primary.max_retries,
);
cfg
}
None => {

View file

@ -2114,6 +2114,7 @@ fn find_model_by_id_prefers_key_then_falls_back_to_slug() {
},
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
};
let mut models = indexmap::IndexMap::new();

View file

@ -509,24 +509,25 @@ where
let mut keepalive = tokio::time::interval(Duration::from_secs(KEEPALIVE_INTERVAL_SECS));
loop {
tokio::select! {
_ = cancel_write.cancelled() => break, msg_opt = from_agent_rx.recv() =>
{ match msg_opt { Some(msg) => { if
tracing::enabled!(tracing::Level::DEBUG) { if let Ok(json_val) =
serde_json::from_str::< serde_json::Value > (& msg) { let method =
json_val.get("method").and_then(| m | m.as_str()); let line_to_print =
match method { Some("session/update") => { let params = json_val
.get("params").unwrap_or(& serde_json::Value::Null);
format!("acp_outbound::session/update::{params}") } Some(m) =>
format!("acp_outbound::{m}"), None => "acp_outbound::response"
.to_string(), }; debug!("{line_to_print}"); } else {
debug!("acp_outbound::response"); } } if ! msg.is_empty() && let Err(e) =
ws_outbound.send(Message::Text(Utf8Bytes::from(msg))). await {
warn!(error = ? e, "failed to send to WS"); break; } } None => {
info!("Agent outbound channel closed"); break; } } } _ = keepalive.tick()
=> { tprintln!("ws::keep_alive_tick"); if let Err(e) = ws_outbound
.send(Message::Ping(Vec::new().into())). await {
tprintln!("ws::keep_alive::error::{:?}", & e); break; } }
}
_ = cancel_write.cancelled() => break, msg_opt = from_agent_rx.recv() =>
{ match msg_opt { Some(msg) => { if
tracing::enabled!(tracing::Level::DEBUG) { if let Ok(json_val) =
serde_json::from_str::< serde_json::Value > (& msg) { let method =
json_val.get("method").and_then(| m | m.as_str()); let line_to_print =
match method { Some("session/update") => { let params = json_val
.get("params").unwrap_or(& serde_json::Value::Null);
format!("acp_outbound::session/update::{params}") } Some(m) =>
format!("acp_outbound::{m}"), None => "acp_outbound::response"
.to_string(), }; debug!("{line_to_print}"); } else {
debug!("acp_outbound::response"); } }
if ! msg.is_empty() && let Err(e) =
ws_outbound.send(Message::Text(Utf8Bytes::from(msg))). await {
warn!(error = ? e, "failed to send to WS"); break; } } None => {
info!("Agent outbound channel closed"); break; } } } _ = keepalive.tick()
=> { tprintln!("ws::keep_alive_tick"); if let Err(e) = ws_outbound
.send(Message::Ping(Vec::new().into())). await {
tprintln!("ws::keep_alive::error::{:?}", & e); break; } }
}
}
anyhow::Ok(())
};

View file

@ -1115,7 +1115,8 @@ async fn cancel_with_outcome_returns_variant_for_active_finished_unknown() {
);
assert!(
matches!(coordinator.cancel_with_outcome("sub-done"),
SubagentCancelOutcome::AlreadyFinished { status } if status == "completed")
SubagentCancelOutcome::AlreadyFinished { status }
if status == "completed")
);
assert!(
matches!(coordinator.cancel_with_outcome("nonexistent"),
@ -1850,7 +1851,8 @@ fn resume_vs_fork_helper_shapes_differ() {
assert!(
! matches!(resumed.conversation.get(1), Some(ConversationItem::User(u)) if u
.content.iter().any(| p | matches!(p,
xai_grok_sampling_types::conversation::ContentPart::Text { text } if text
xai_grok_sampling_types::conversation::ContentPart::Text { text }
if text
.contains("<background_context>"))))
);
}
@ -1912,7 +1914,8 @@ fn verbatim_fork_keeps_items_byte_for_byte_when_small() {
.any(|i| {
matches!(
i, ConversationItem::User(u) if u.content.iter().any(| p |
matches!(p, ContentPart::Text { text } if text.contains(needle)))
matches!(p, ContentPart::Text { text }
if text.contains(needle)))
)
})
};
@ -1954,7 +1957,8 @@ fn verbatim_fork_falls_back_to_summary_on_incomplete_tail() {
assert_eq!(ctx.prefix_len, Some(2));
assert!(
ctx.conversation.iter().any(| i | { matches!(i, ConversationItem::User(u) if u
.content.iter().any(| p | matches!(p, ContentPart::Text { text } if text
.content.iter().any(| p | matches!(p, ContentPart::Text { text }
if text
.contains("<background_context>")))) }),
"summarized fallback must produce a background_context blob"
);
@ -1995,7 +1999,8 @@ fn verbatim_fork_falls_back_to_summary_when_oversize() {
.any(|i| {
matches!(
i, ConversationItem::User(u) if u.content.iter().any(| p | matches!(p,
ContentPart::Text { text } if text.contains("<background_context>")))
ContentPart::Text { text }
if text.contains("<background_context>")))
)
});
assert!(has_blob, "oversize fallback must produce a background_context blob");
@ -3305,6 +3310,7 @@ fn test_model_entry(model_id: &str) -> crate::agent::config::ModelEntry {
},
api_key: None,
env_key: None,
auth_provider: None,
api_base_url: None,
}
}

View file

@ -274,7 +274,8 @@ fn compaction_preserves_inherited_prefix() {
.any(|p| {
matches!(
p, xai_grok_sampling_types::conversation::ContentPart::Text {
text } if text.contains("<background_context>")
text }
if text.contains("<background_context>")
)
})
} else {
@ -2870,6 +2871,51 @@ async fn resolve_subagent_agent_definition_unknown_model_falls_through_to_inheri
assert_eq!(config.model, "grok-4.5");
assert_eq!(model_id.0.as_ref(), "grok-4.5");
}
/// Spawn-time credentials are cache-only: a cold spawn has no key,
/// never the parent session key.
#[tokio::test]
async fn subagent_override_provider_model_spawns_cache_only_credentials() {
use xai_grok_agent::config::ModelOverride;
let dir = tempfile::tempdir().unwrap();
let provider = crate::auth::test_counting_provider(
"test-subagent-spawn",
dir.path(),
);
let mut entry = test_model_entry("proxied-model");
entry.info.base_url = "https://gateway.example/v1".to_string();
entry.auth_provider = Some(provider.clone());
let mut models = indexmap::IndexMap::new();
models.insert("proxied".to_string(), entry);
let mut ctx = ctx_with_toggle(HashMap::new());
ctx.sampling_config.model = "grok-4.5".to_string();
ctx.model_id = acp::ModelId::new("grok-4.5");
ctx.available_models = models;
ctx.auth = Some(crate::auth::GrokAuth {
key: "parent-session-jwt".to_string(),
..Default::default()
});
ctx.subagent_model_overrides.insert("explore".to_string(), "proxied".to_string());
let (config, model_id) = resolve_subagent_sampling_config(
"explore",
&ModelOverride::Inherit,
&ctx,
)
.await;
assert_eq!(model_id.0.as_ref(), "proxied");
assert_eq!(
config.api_key, None,
"a cold cache spawns with no key, never the parent session key"
);
provider.ensure_fresh_token(None).await.rotated().unwrap();
let (config, _) = resolve_subagent_sampling_config(
"explore",
&ModelOverride::Inherit,
&ctx,
)
.await;
assert_eq!(config.api_key.as_deref(), Some("tok-1"));
assert_eq!(config.base_url, "https://gateway.example/v1");
}
#[test]
fn key_prefix_truncates_to_8_chars() {
let key = Some("eyJ0eXAiOiJhbGciOiJSUzI1NiJ9".to_string());