Synced from monorepo
Synced from monorepo Changes: - Workspace server: report `/ready` as failed with dwell on hub connect failure - Refresh OIDC token for the Grok agent in the shell - ACP terminal output recorder - Cross-platform provider auth commands in the shell - Default `/resume` to Grok sessions with a hint for hidden external sessions - Resume sessions by title with `--resume` - Limit app-builder archive size - Data-driven tag labels for slash commands - Doctor fixes for tmux - Custom provider gateways and subprocess environment policy in the shell - `/tutorial` — opt-in onboarding tour of Grok Build - Soft and required CLI version checks in the shell - Privacy banner env overrides survive live settings updates - Add remote flag to override the image-edit model - Return profile fields from auth info even when the access token is expired - Add edit control on queued prompt rows - Keep fail-closed policy when clearing orphans with no team - Setting to disable the Ctrl+Space/F8 voice shortcut - Pass `--raw` to pw-record so Linux dictation works on older PipeWire - Validate git URLs when adding marketplace entries - Stop shipping stale tool-doc parameter and tool names - Re-point dashboard attach after `/fork` only when the parent was attached - Surface Grok Computer media-generation results as file-path chunks - Clear web background-task tray on kill and keep the task description - Show privacy upsell banner in agent view until acted on - Add tools-server client callback surface - Protect persistent global hook sources Source-Revision: 95d84f443eddcbed6cbfd6eed22e2eafe6b3939d
This commit is contained in:
parent
a5727c5960
commit
69f0ba880a
286 changed files with 22939 additions and 9624 deletions
|
|
@ -90,6 +90,8 @@ mod tests {
|
|||
api_backend: ApiBackend::ChatCompletions,
|
||||
auth_scheme: Default::default(),
|
||||
extra_headers: IndexMap::new(),
|
||||
query_params: IndexMap::new(),
|
||||
env_http_headers: IndexMap::new(),
|
||||
context_window: 8192,
|
||||
force_http1: false,
|
||||
max_retries: None,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
use eventsource_stream::Eventsource;
|
||||
use futures_util::StreamExt;
|
||||
use futures_util::stream::BoxStream;
|
||||
use indexmap::IndexMap;
|
||||
use reqwest::header::{
|
||||
ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue, USER_AGENT,
|
||||
};
|
||||
|
|
@ -272,9 +273,38 @@ struct StreamOptions {
|
|||
include_usage: bool,
|
||||
}
|
||||
|
||||
/// Resolve `env_http_headers` (`header -> env var`) into `headers` via `getenv`, skipping unset/blank/invalid entries and trimming values.
|
||||
fn apply_env_http_headers(
|
||||
env_http_headers: &IndexMap<String, String>,
|
||||
getenv: impl Fn(&str) -> Option<String>,
|
||||
headers: &mut HeaderMap,
|
||||
) {
|
||||
for (key, env_var) in env_http_headers {
|
||||
let Some(value) = getenv(env_var) else {
|
||||
continue;
|
||||
};
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (Ok(name), Ok(header_value)) = (
|
||||
HeaderName::try_from(key.as_str()),
|
||||
HeaderValue::from_str(value),
|
||||
) else {
|
||||
tracing::warn!(
|
||||
header = %key,
|
||||
env_var = %env_var,
|
||||
"skipping env_http_header with an invalid header name or value"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
headers.insert(name, header_value);
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP client for sampling. Cheap to clone; carries an `Arc`-backed
|
||||
/// `reqwest::Client` and the default headers/request-defaults computed
|
||||
/// from a [`SamplerConfig`] at construction time.
|
||||
/// `reqwest::Client` and the default headers/request-defaults computed from a
|
||||
/// [`SamplerConfig`] at construction time.
|
||||
#[derive(Clone)]
|
||||
pub struct SamplingClient {
|
||||
http: reqwest::Client,
|
||||
|
|
@ -290,6 +320,8 @@ pub struct SamplingClient {
|
|||
bearer_resolver: Option<crate::config::SharedBearerResolver>,
|
||||
/// Per-request header injection (OTel traceparent).
|
||||
header_injector: Option<crate::config::SharedHeaderInjector>,
|
||||
/// Endpoint URL builder, resolved once from `base_url` + `query_params`.
|
||||
endpoint: EndpointTemplate,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SamplingClient {
|
||||
|
|
@ -318,6 +350,74 @@ struct ClientDefaults {
|
|||
doom_loop_recovery: Option<xai_grok_sampling_types::DoomLoopRecoveryPolicy>,
|
||||
}
|
||||
|
||||
/// Endpoint URL builder, resolved once at client construction so each request
|
||||
/// only appends its path.
|
||||
#[derive(Clone, Debug)]
|
||||
enum EndpointTemplate {
|
||||
/// No query params and no query on the base URL (or an unparseable base):
|
||||
/// append the path to the base verbatim.
|
||||
Plain(String),
|
||||
/// Query params configured: `{prefix}/{path}{suffix}`. `suffix` starts with
|
||||
/// `?` and folds any base-URL params, with a configured key winning over the
|
||||
/// same key in `base_url` (percent-encoded, no duplicates).
|
||||
WithQuery { prefix: String, suffix: String },
|
||||
}
|
||||
|
||||
impl EndpointTemplate {
|
||||
fn new(base_url: &str, query_params: &IndexMap<String, String>) -> Self {
|
||||
let base = base_url.trim_end_matches('/').to_string();
|
||||
// The fast path is safe only when there is nothing to fold: no configured
|
||||
// params and no query already on the base (which would otherwise land
|
||||
// before the appended path).
|
||||
if query_params.is_empty() && !base.contains('?') {
|
||||
return Self::Plain(base);
|
||||
}
|
||||
let mut url = match reqwest::Url::parse(&base) {
|
||||
Ok(url) => url,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
url = %base,
|
||||
%error,
|
||||
"failed to parse base URL for endpoint; sending without folded query"
|
||||
);
|
||||
return Self::Plain(base);
|
||||
}
|
||||
};
|
||||
let overridden: std::collections::HashSet<&str> =
|
||||
query_params.keys().map(String::as_str).collect();
|
||||
let kept: Vec<(String, String)> = url
|
||||
.query_pairs()
|
||||
.filter(|(k, _)| !overridden.contains(k.as_ref()))
|
||||
.map(|(k, v)| (k.into_owned(), v.into_owned()))
|
||||
.collect();
|
||||
let prefix = {
|
||||
let mut prefix_url = url.clone();
|
||||
prefix_url.set_query(None);
|
||||
prefix_url.as_str().trim_end_matches('/').to_string()
|
||||
};
|
||||
{
|
||||
let mut pairs = url.query_pairs_mut();
|
||||
pairs.clear();
|
||||
for (key, value) in &kept {
|
||||
pairs.append_pair(key, value);
|
||||
}
|
||||
for (key, value) in query_params {
|
||||
pairs.append_pair(key, value);
|
||||
}
|
||||
}
|
||||
let suffix = url.query().map(|q| format!("?{q}")).unwrap_or_default();
|
||||
Self::WithQuery { prefix, suffix }
|
||||
}
|
||||
|
||||
fn url_for_path(&self, path: &str) -> String {
|
||||
let path = path.trim_start_matches('/');
|
||||
match self {
|
||||
Self::Plain(base) => format!("{base}/{path}"),
|
||||
Self::WithQuery { prefix, suffix } => format!("{prefix}/{path}{suffix}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// User-Agent helpers
|
||||
// =============================================================================
|
||||
|
|
@ -444,6 +544,14 @@ impl SamplingClient {
|
|||
headers.insert(header_name, header_value);
|
||||
}
|
||||
|
||||
// Resolve here, not into `extra_headers`, so an env-sourced secret stays
|
||||
// out of persisted state.
|
||||
apply_env_http_headers(
|
||||
&config.env_http_headers,
|
||||
|var| std::env::var(var).ok(),
|
||||
&mut headers,
|
||||
);
|
||||
|
||||
// Add x-grok-client-version header for version gating at the proxy.
|
||||
if let Some(client_version) = config.client_version.as_ref()
|
||||
&& let Ok(header_value) = HeaderValue::from_str(client_version)
|
||||
|
|
@ -530,6 +638,8 @@ impl SamplingClient {
|
|||
doom_loop_recovery: config.doom_loop_recovery,
|
||||
};
|
||||
|
||||
let endpoint = EndpointTemplate::new(&config.base_url, &config.query_params);
|
||||
|
||||
Ok(Self {
|
||||
http,
|
||||
default_headers: headers,
|
||||
|
|
@ -538,6 +648,7 @@ impl SamplingClient {
|
|||
attribution_callback: config.attribution_callback,
|
||||
bearer_resolver: config.bearer_resolver,
|
||||
header_injector: config.header_injector,
|
||||
endpoint,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -701,9 +812,7 @@ impl SamplingClient {
|
|||
}
|
||||
|
||||
fn endpoint(&self, path: &str) -> String {
|
||||
let base = self.base_url.trim_end_matches('/');
|
||||
let path = path.trim_start_matches('/');
|
||||
format!("{base}/{path}")
|
||||
self.endpoint.url_for_path(path)
|
||||
}
|
||||
|
||||
fn apply_defaults(&self, mut request: ChatCompletionRequest) -> Result<ChatCompletionRequest> {
|
||||
|
|
@ -1907,6 +2016,8 @@ mod tests {
|
|||
api_backend: ApiBackend::ChatCompletions,
|
||||
auth_scheme: AuthScheme::Bearer,
|
||||
extra_headers: IndexMap::new(),
|
||||
query_params: IndexMap::new(),
|
||||
env_http_headers: IndexMap::new(),
|
||||
context_window: 8192,
|
||||
force_http1: false,
|
||||
max_retries: None,
|
||||
|
|
@ -2079,6 +2190,56 @@ mod tests {
|
|||
let _client = SamplingClient::new(cfg).expect("client with extra headers should construct");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_env_http_headers_resolves_trims_skips_and_overrides() {
|
||||
let mut map = IndexMap::new();
|
||||
map.insert("x-tenant-token".to_string(), "TENANT".to_string());
|
||||
map.insert("x-blank".to_string(), "BLANK".to_string());
|
||||
map.insert("x-missing".to_string(), "MISSING".to_string());
|
||||
map.insert("x-override".to_string(), "OVERRIDE".to_string());
|
||||
map.insert("x invalid".to_string(), "INVALID".to_string());
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
HeaderName::from_static("x-override"),
|
||||
HeaderValue::from_static("static"),
|
||||
);
|
||||
|
||||
apply_env_http_headers(
|
||||
&map,
|
||||
|var| match var {
|
||||
// Leading space + trailing newline exercises trimming.
|
||||
"TENANT" => Some(" tenant-secret\n".to_string()),
|
||||
"BLANK" => Some(" ".to_string()),
|
||||
"OVERRIDE" => Some("from-env".to_string()),
|
||||
"INVALID" => Some("value".to_string()),
|
||||
_ => None,
|
||||
},
|
||||
&mut headers,
|
||||
);
|
||||
|
||||
assert_eq!(headers.get("x-tenant-token").unwrap(), "tenant-secret");
|
||||
assert!(headers.get("x-blank").is_none());
|
||||
assert!(headers.get("x-missing").is_none());
|
||||
// A resolved env value overrides an existing header of the same name.
|
||||
assert_eq!(headers.get("x-override").unwrap(), "from-env");
|
||||
// An invalid header name is skipped rather than panicking.
|
||||
assert!(headers.get("x invalid").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_appends_path_before_a_base_url_query_without_configured_params() {
|
||||
let template =
|
||||
EndpointTemplate::new("https://gateway.example/v1?api-version=x", &IndexMap::new());
|
||||
let url = template.url_for_path("responses");
|
||||
assert!(
|
||||
url.starts_with("https://gateway.example/v1/responses?"),
|
||||
"url: {url}"
|
||||
);
|
||||
assert!(url.contains("api-version=x"), "url: {url}");
|
||||
assert!(!url.contains("x/responses"), "url: {url}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn messages_plus_anthropic_api_key_uses_x_api_key_and_not_authorization() {
|
||||
let cfg = SamplerConfig {
|
||||
|
|
|
|||
|
|
@ -60,6 +60,13 @@ pub struct SamplerConfig {
|
|||
/// the URL to derive headers; callers (the session) inject proxy auth
|
||||
/// and other access headers here before constructing the config.
|
||||
pub extra_headers: IndexMap<String, String>,
|
||||
/// Query parameters folded into every request URL (percent-encoded).
|
||||
#[serde(default)]
|
||||
pub query_params: IndexMap<String, String>,
|
||||
/// Header name to environment variable, resolved into request headers at
|
||||
/// client build and never persisted.
|
||||
#[serde(default)]
|
||||
pub env_http_headers: IndexMap<String, String>,
|
||||
/// Total context window size in tokens. The sampler does not enforce
|
||||
/// it; it is informational metadata used by the session for compaction
|
||||
/// decisions.
|
||||
|
|
@ -140,6 +147,8 @@ impl Default for SamplerConfig {
|
|||
api_backend: ApiBackend::default(),
|
||||
auth_scheme: AuthScheme::default(),
|
||||
extra_headers: IndexMap::new(),
|
||||
query_params: IndexMap::new(),
|
||||
env_http_headers: IndexMap::new(),
|
||||
context_window: 0,
|
||||
force_http1: false,
|
||||
max_retries: None,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
//! Checks that provider `query_params` and `env_http_headers` reach the
|
||||
//! outgoing request.
|
||||
|
||||
mod support;
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use axum::Router;
|
||||
use axum::http::{HeaderMap, Uri};
|
||||
use axum::routing::post;
|
||||
use tokio::net::TcpListener;
|
||||
use xai_grok_sampler::SamplingClient;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn request_carries_query_params_and_env_http_headers() {
|
||||
// A unique name avoids clashing with other tests that read the process
|
||||
// environment; the surrounding whitespace exercises value trimming.
|
||||
let env_var = "XAI_SAMPLER_TEST_TENANT_TOKEN";
|
||||
unsafe { std::env::set_var(env_var, " tenant-secret\n") };
|
||||
|
||||
let captured: Arc<Mutex<Option<(String, HeaderMap)>>> = Arc::new(Mutex::new(None));
|
||||
let sink = Arc::clone(&captured);
|
||||
let app = Router::new().route(
|
||||
"/v1/chat/completions",
|
||||
post(move |uri: Uri, headers: HeaderMap| {
|
||||
let sink = Arc::clone(&sink);
|
||||
async move {
|
||||
*sink.lock().unwrap() =
|
||||
Some((uri.query().unwrap_or_default().to_string(), headers));
|
||||
"{}"
|
||||
}
|
||||
}),
|
||||
);
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, app).await;
|
||||
});
|
||||
|
||||
// The base URL already carries `api-version`; a configured value must
|
||||
// replace it (not duplicate), keep unrelated keys, and percent-encode.
|
||||
let base_url = format!("http://{addr}/v1?api-version=old&keep=1");
|
||||
let mut cfg = support::test_config(&base_url, "test-key");
|
||||
cfg.query_params
|
||||
.insert("api-version".into(), "2026-07-22".into());
|
||||
cfg.query_params.insert("tenant".into(), "a b".into());
|
||||
cfg.env_http_headers
|
||||
.insert("x-tenant-token".into(), env_var.into());
|
||||
|
||||
let client = SamplingClient::new(cfg).expect("client builds");
|
||||
support::send_one(&client).await;
|
||||
unsafe { std::env::remove_var(env_var) };
|
||||
|
||||
let (query, headers) = captured.lock().unwrap().take().expect("request captured");
|
||||
assert_eq!(query.matches("api-version=").count(), 1, "query: {query}");
|
||||
assert!(query.contains("api-version=2026-07-22"), "query: {query}");
|
||||
assert!(!query.contains("api-version=old"), "query: {query}");
|
||||
assert!(query.contains("keep=1"), "query: {query}");
|
||||
assert!(
|
||||
query.contains("tenant=a%20b") || query.contains("tenant=a+b"),
|
||||
"query: {query}"
|
||||
);
|
||||
assert_eq!(headers.get("x-tenant-token").unwrap(), "tenant-secret");
|
||||
}
|
||||
|
|
@ -79,6 +79,8 @@ fn test_config(base_url: String, model: &str) -> SamplerConfig {
|
|||
api_backend: ApiBackend::ChatCompletions,
|
||||
auth_scheme: Default::default(),
|
||||
extra_headers: IndexMap::new(),
|
||||
query_params: IndexMap::new(),
|
||||
env_http_headers: IndexMap::new(),
|
||||
context_window: 128_000,
|
||||
force_http1: false,
|
||||
// Keep retries minimal so tests don't take forever.
|
||||
|
|
|
|||
Loading…
Reference in a new issue