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:
parent
ba76b0a683
commit
a881e6703f
140 changed files with 6746 additions and 2377 deletions
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
Loading…
Reference in a new issue