Skip to content
1 change: 0 additions & 1 deletion crates/goose-cli/src/commands/configure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1813,7 +1813,6 @@ pub async fn handle_openrouter_auth() -> anyhow::Result<()> {
let test_result = provider
.complete(
&model_config,
"",
"You are goose, an AI assistant.",
&[Message::user().with_text("Say 'Configuration test successful!'")],
&[],
Expand Down
10 changes: 6 additions & 4 deletions crates/goose-cli/src/commands/info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,12 @@ async fn check_provider(

let test_msg = Message::user().with_text("Say 'ok'");
let start = std::time::Instant::now();
provider_client
.complete(&model_config, "check", "", &[test_msg], &[])
.await
.map_err(ProviderCheckError::ProviderRequest)?;
goose::session_context::with_session_id(
Some("check".to_string()),
provider_client.complete(&model_config, "", &[test_msg], &[]),
)
.await
.map_err(ProviderCheckError::ProviderRequest)?;

Ok(ProviderCheckSuccess {
provider,
Expand Down
25 changes: 11 additions & 14 deletions crates/goose-cli/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,15 +226,16 @@ pub async fn classify_planner_response(
);

let message = Message::user().with_text(&prompt);
let (result, _usage) = provider
.complete(
let (result, _usage) = goose::session_context::with_session_id(
Some(session_id.to_string()),
provider.complete(
&model_config,
session_id,
"Reply only with the classification label: \"plan\" or \"clarifying questions\"",
&[message],
&[],
)
.await?;
),
)
.await?;

let predicted = result.as_concat_text();
if predicted.to_lowercase().contains("plan") {
Expand Down Expand Up @@ -1060,15 +1061,11 @@ impl CliSession {
) -> Result<(), anyhow::Error> {
let plan_prompt = self.agent.get_plan_prompt(&self.session_id).await?;
output::show_thinking();
let (plan_response, _usage) = reasoner
.complete(
&model_config,
&self.session_id,
&plan_prompt,
plan_messages.messages(),
&[],
)
.await?;
let (plan_response, _usage) = goose::session_context::with_session_id(
Some(self.session_id.clone()),
reasoner.complete(&model_config, &plan_prompt, plan_messages.messages(), &[]),
)
.await?;
output::render_message(&plan_response, self.debug);
output::hide_thinking();
let planner_response_type = classify_planner_response(
Expand Down
34 changes: 15 additions & 19 deletions crates/goose-providers/examples/streaming.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,19 @@ use goose_providers::{
openai::OpenAiProvider,
};

async fn stream(provider: impl Provider, model: ModelConfig) -> Result<()> {
let system = "You are a knowledgable geography expert";
let messages = [Message::user().with_text("what is the capital of France?")];

let mut stream = provider.stream(&model, system, &messages, &[]).await?;

while let Some((Some(msg), _)) = stream.next().await.transpose()? {
print!("{}", msg.as_concat_text());
}
println!();
Ok(())
}

#[tokio::main]
async fn main() -> Result<()> {
let key = env::var("OPENAI_API_KEY").map_err(|_| anyhow::anyhow!("need an OpenAI key"))?;
Expand All @@ -18,26 +31,9 @@ async fn main() -> Result<()> {
AuthMethod::BearerToken(key),
Some(Default::default()),
)?;
let provider = OpenAiProvider::new(api_client);

let system = "You are a knowledgable geography expert";
let messages = [Message::user().with_text("what is the capital of France?")];

let provider = OpenAiProvider::new(api_client);
let model = ModelConfig::new("gpt-5.4-mini");
let mut stream = provider
.stream(
&model,
"", // session-id
system,
&messages,
&[],
)
.await?;

while let Some((Some(msg), _)) = stream.next().await.transpose()? {
print!("{}", msg.as_concat_text());
}
println!();

Ok(())
stream(provider, model).await
}
5 changes: 2 additions & 3 deletions crates/goose-providers/src/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ impl AnthropicProviderBuilder {

impl AnthropicProvider {
async fn fetch_models_from_api(&self) -> Result<Vec<String>, ProviderError> {
let response = self.api_client.request(None, "v1/models").api_get().await?;
let response = self.api_client.request("v1/models").api_get().await?;

if response.status == StatusCode::NOT_FOUND {
let msg = response
Expand Down Expand Up @@ -241,7 +241,6 @@ impl Provider for AnthropicProvider {
async fn stream(
&self,
model_config: &ModelConfig,
session_id: &str,
system: &str,
messages: &[Message],
tools: &[Tool],
Expand All @@ -263,7 +262,7 @@ impl Provider for AnthropicProvider {

let response = self
.with_retry(|| async {
let request = self.api_client.request(Some(session_id), "v1/messages");
let request = self.api_client.request("v1/messages");
let resp = request.response_post(&payload).await?;
handle_status(resp).await
})
Expand Down
91 changes: 36 additions & 55 deletions crates/goose-providers/src/api_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@ use std::fmt;
#[cfg(any(feature = "rustls-tls", feature = "native-tls"))]
use std::fs::read_to_string;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;

const DEFAULT_PROVIDER_TIMEOUT_SECS: u64 = 600;
const SESSION_ID_HEADER: &str = "agent-session-id";

pub type RequestBuilderDecorator =
Arc<dyn Fn(reqwest::RequestBuilder) -> Result<reqwest::RequestBuilder> + Send + Sync>;

pub struct ApiClient {
client: Client,
Expand All @@ -24,6 +27,7 @@ pub struct ApiClient {
default_query: Vec<(String, String)>,
timeout: Duration,
tls_config: Option<TlsConfig>,
request_builder: Option<RequestBuilderDecorator>,
}

pub enum AuthMethod {
Expand Down Expand Up @@ -225,7 +229,6 @@ pub struct ApiRequestBuilder<'a> {
client: &'a ApiClient,
path: &'a str,
headers: HeaderMap,
session_id: Option<&'a str>,
}

impl ApiClient {
Expand Down Expand Up @@ -264,6 +267,7 @@ impl ApiClient {
default_query: Vec::new(),
timeout,
tls_config,
request_builder: None,
})
}

Expand Down Expand Up @@ -339,44 +343,33 @@ impl ApiClient {
Ok(self)
}

/// - `session_id`: Use `None` only for configuration or pre-session tasks.
pub fn request<'a>(
&'a self,
session_id: Option<&'a str>,
path: &'a str,
) -> ApiRequestBuilder<'a> {
pub fn with_request_builder(mut self, request_builder: RequestBuilderDecorator) -> Self {
self.request_builder = Some(request_builder);
self
}

pub fn request<'a>(&'a self, path: &'a str) -> ApiRequestBuilder<'a> {
ApiRequestBuilder {
client: self,
session_id: session_id.filter(|id| !id.is_empty()),
path,
headers: HeaderMap::new(),
}
}

pub async fn api_post(
&self,
session_id: Option<&str>,
path: &str,
payload: &Value,
) -> Result<ApiResponse> {
self.request(session_id, path).api_post(payload).await
pub async fn api_post(&self, path: &str, payload: &Value) -> Result<ApiResponse> {
self.request(path).api_post(payload).await
}

pub async fn response_post(
&self,
session_id: Option<&str>,
path: &str,
payload: &Value,
) -> Result<Response> {
self.request(session_id, path).response_post(payload).await
pub async fn response_post(&self, path: &str, payload: &Value) -> Result<Response> {
self.request(path).response_post(payload).await
}

pub async fn api_get(&self, session_id: Option<&str>, path: &str) -> Result<ApiResponse> {
self.request(session_id, path).api_get().await
pub async fn api_get(&self, path: &str) -> Result<ApiResponse> {
self.request(path).api_get().await
}

pub async fn response_get(&self, session_id: Option<&str>, path: &str) -> Result<Response> {
self.request(session_id, path).response_get().await
pub async fn response_get(&self, path: &str) -> Result<Response> {
self.request(path).response_get().await
}

fn build_url(&self, path: &str) -> Result<url::Url> {
Expand Down Expand Up @@ -445,17 +438,14 @@ impl<'a> ApiRequestBuilder<'a> {
F: FnOnce(url::Url, &Client) -> reqwest::RequestBuilder,
{
let url = self.client.build_url(self.path)?;
let mut headers = self.headers.clone();
headers.remove(SESSION_ID_HEADER);
if let Some(session_id) = self.session_id {
let header_name = HeaderName::from_static(SESSION_ID_HEADER);
let header_value = HeaderValue::from_str(session_id)?;
headers.insert(header_name, header_value);
}

let headers = self.headers.clone();
let mut request = request_builder(url, &self.client.client);
request = request.headers(headers);

if let Some(decorator) = &self.client.request_builder {
request = decorator(request)?;
}

request = match &self.client.auth {
AuthMethod::NoAuth => request,
AuthMethod::BearerToken(token) => {
Expand Down Expand Up @@ -607,41 +597,32 @@ ShGoCNbfNS+COlPMRAujyDlATZcLs9p4tA==
#[cfg(test)]
mod tests {
use super::*;
use test_case::test_case;

#[test_case(Some("test-session_id-456"), None, Some("test-session_id-456"); "header set")]
#[test_case(Some("new-session"), Some(("Agent-Session-Id", "old-session")), Some("new-session"); "replaces existing")]
#[test_case(None, Some(("Agent-Session-Id", "old-session")), None; "removes existing on none")]
#[test_case(Some(""), Some(("agent-session-id", "old-session")), None; "removes existing on empty")]
fn test_session_id_header(
session_id: Option<&str>,
existing_header: Option<(&str, &str)>,
expected: Option<&str>,
) {

#[test]
fn test_request_builder_decorator() {
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async {
let client = ApiClient::new_with_tls(
"http://localhost:8080".to_string(),
AuthMethod::BearerToken("test-token".to_string()),
None,
)
.unwrap();
.unwrap()
.with_request_builder(Arc::new(|request| {
Ok(request.header("test-my-session-id", "test-session_id-456"))
}));

let mut builder = client.request(session_id, "/test");
if let Some((key, value)) = existing_header {
builder = builder.header(key, value).unwrap();
}
let request = builder
let request = client
.request("/test")
.send_request(|url, client| client.get(url))
.await
.unwrap();

let headers = request.build().unwrap().headers().clone();

let actual = headers
.get(SESSION_ID_HEADER)
.get("test-my-session-id")
.and_then(|value| value.to_str().ok());
assert_eq!(actual, expected);
assert_eq!(actual, Some("test-session_id-456"));
});
}
}
15 changes: 1 addition & 14 deletions crates/goose-providers/src/base.rs
Original file line number Diff line number Diff line change
Expand Up @@ -383,35 +383,22 @@ pub trait Provider: Send + Sync {
fn get_name(&self) -> &str;

/// Primary streaming method that all providers must implement.
///
/// Note: Do not add `#[instrument]` here — the call sites (`complete` and
/// `stream_response_from_provider`) create the telemetry span so that
/// `session.id` is set once rather than in every provider.
async fn stream(
&self,
model_config: &ModelConfig,
session_id: &str,
system: &str,
messages: &[Message],
tools: &[Tool],
) -> Result<MessageStream, ProviderError>;

/// Complete with a specific model config.
#[tracing::instrument(
skip(self, model_config, session_id, system, messages, tools),
fields(session.id = %session_id, gen_ai.request.model = %model_config.model_name)
)]
async fn complete(
&self,
model_config: &ModelConfig,
session_id: &str,
system: &str,
messages: &[Message],
tools: &[Tool],
) -> Result<(Message, ProviderUsage), ProviderError> {
let stream = self
.stream(model_config, session_id, system, messages, tools)
.await?;
let stream = self.stream(model_config, system, messages, tools).await?;
collect_stream(stream).await
}

Expand Down
Loading
Loading