enhance the uniffi API layer (#10427)

This commit is contained in:
Jack Amadeo
2026-07-22 14:48:30 -04:00
committed by GitHub
parent f0fecbc32d
commit e43ed3a6fd
43 changed files with 1207 additions and 158 deletions
Generated
+8 -6
View File
@@ -5108,7 +5108,7 @@ dependencies = [
[[package]]
name = "goose-download-manager"
version = "0.1.0-alpha.0"
version = "0.1.0-alpha.5"
dependencies = [
"anyhow",
"once_cell",
@@ -5120,7 +5120,7 @@ dependencies = [
[[package]]
name = "goose-local-inference"
version = "0.1.0-alpha.0"
version = "0.1.0-alpha.5"
dependencies = [
"anyhow",
"async-stream",
@@ -5186,7 +5186,7 @@ dependencies = [
[[package]]
name = "goose-provider-types"
version = "0.1.0-alpha.0"
version = "0.1.0-alpha.5"
dependencies = [
"anyhow",
"async-stream",
@@ -5215,7 +5215,7 @@ dependencies = [
[[package]]
name = "goose-providers"
version = "0.1.0-alpha.0"
version = "0.1.0-alpha.5"
dependencies = [
"anyhow",
"async-stream",
@@ -5247,14 +5247,16 @@ dependencies = [
[[package]]
name = "goose-sdk"
version = "0.1.0-alpha.0"
version = "0.1.0-alpha.5"
dependencies = [
"agent-client-protocol",
"agent-client-protocol-schema",
"anyhow",
"base64 0.22.1",
"futures",
"goose-providers",
"goose-sdk-types",
"rmcp",
"serde_json",
"thiserror 2.0.18",
"tokio",
@@ -5264,7 +5266,7 @@ dependencies = [
[[package]]
name = "goose-sdk-types"
version = "0.1.0-alpha.0"
version = "0.1.0-alpha.5"
dependencies = [
"agent-client-protocol",
"agent-client-protocol-schema",
+1
View File
@@ -2584,6 +2584,7 @@ mod tests {
serde_json::json!(["output-128k-2025-02-19"]),
)])),
reasoning: Some(false),
request_headers: None,
};
let switched =
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "goose-download-manager"
version = "0.1.0-alpha.0"
version = "0.1.0-alpha.5"
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
+4 -4
View File
@@ -1,6 +1,6 @@
[package]
name = "goose-local-inference"
version = "0.1.0-alpha.0"
version = "0.1.0-alpha.5"
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
@@ -27,9 +27,9 @@ etcetera = { workspace = true }
encoding_rs = { version = "0.8.35", default-features = false }
fs2 = { workspace = true }
futures = { workspace = true }
goose-download-manager = { version = "0.1.0-alpha.0", path = "../goose-download-manager" }
goose-provider-types = { version = "0.1.0-alpha.0", path = "../goose-provider-types", default-features = false }
goose-sdk-types = { version = "0.1.0-alpha.0", path = "../goose-sdk-types", default-features = false }
goose-download-manager = { version = "0.1.0-alpha.5", path = "../goose-download-manager" }
goose-provider-types = { version = "0.1.0-alpha.5", path = "../goose-provider-types", default-features = false }
goose-sdk-types = { version = "0.1.0-alpha.5", path = "../goose-sdk-types", default-features = false }
hf-hub = { version = "1.0.0-rc.1", default-features = false }
include_dir = { workspace = true }
llama-cpp-2 = { workspace = true }
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "goose-provider-types"
version = "0.1.0-alpha.0"
version = "0.1.0-alpha.5"
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
+28 -4
View File
@@ -358,13 +358,21 @@ pub async fn collect_stream(
}
}
match final_message {
Some(msg) => {
let usage = final_usage
match (final_message, final_usage) {
(Some(msg), usage) => {
let usage = usage
.unwrap_or_else(|| ProviderUsage::new("unknown".to_string(), Usage::default()));
Ok((msg, usage))
}
None => Err(ProviderError::ExecutionError(
(None, Some(usage)) => Ok((
Message::new(
rmcp::model::Role::Assistant,
chrono::Utc::now().timestamp(),
Vec::new(),
),
usage,
)),
(None, None) => Err(ProviderError::ExecutionError(
"Stream yielded no message".to_string(),
)),
}
@@ -662,6 +670,22 @@ mod tests {
assert_eq!(usage.model, "unknown");
}
#[tokio::test]
async fn test_collect_stream_usage_only_yields_empty_message() {
let usage = ProviderUsage::new("claude-sonnet-4".to_string(), Usage::default());
let stream = futures::stream::once(async move { Ok((None, Some(usage))) });
let (msg, usage) = collect_stream(Box::pin(stream)).await.unwrap();
assert!(msg.content.is_empty());
assert_eq!(usage.model, "claude-sonnet-4");
}
#[tokio::test]
async fn test_collect_stream_no_message_no_usage_errors() {
let stream = futures::stream::empty();
let result = collect_stream(Box::pin(stream)).await;
assert!(matches!(result, Err(ProviderError::ExecutionError(_))));
}
#[tokio::test]
async fn test_collect_stream_preserves_text_audience_boundaries() {
use futures::stream;
@@ -1071,6 +1071,7 @@ mod tests {
toolshim_model: None,
request_params: None,
reasoning: None,
request_headers: None,
};
let request = create_request(&model_config, "system", &[], &[], &ImageFormat::OpenAi)?;
let obj = request.as_object().unwrap();
@@ -1105,6 +1106,7 @@ mod tests {
toolshim_model: None,
request_params: Some(params),
reasoning: None,
request_headers: None,
};
let request = create_request(&model_config, "system", &[], &[], &ImageFormat::OpenAi)?;
assert_eq!(request["reasoning_effort"], "high");
@@ -1124,6 +1126,7 @@ mod tests {
toolshim_model: None,
request_params: Some(params),
reasoning: None,
request_headers: None,
};
let request = create_request(&model_config, "system", &[], &[], &ImageFormat::OpenAi)?;
assert_eq!(request["reasoning_effort"], "low");
@@ -1144,6 +1147,7 @@ mod tests {
toolshim_model: None,
request_params: Some(params),
reasoning: None,
request_headers: None,
};
let request = create_request(&model_config, "system", &[], &[], &ImageFormat::OpenAi)?;
assert_eq!(request["reasoning_effort"], "high");
@@ -1162,6 +1166,7 @@ mod tests {
toolshim_model: None,
request_params: None,
reasoning: None,
request_headers: None,
};
let request = create_request(&model_config, "system", &[], &[], &ImageFormat::OpenAi)?;
assert_eq!(request["model"], "o3");
@@ -1180,6 +1185,7 @@ mod tests {
toolshim_model: None,
request_params: None,
reasoning: None,
request_headers: None,
};
let request = create_request(&model_config, "system", &[], &[], &ImageFormat::OpenAi)?;
assert_eq!(request["model"], "o3");
@@ -1198,6 +1204,7 @@ mod tests {
toolshim_model: None,
request_params: None,
reasoning: None,
request_headers: None,
};
let request = create_request(&model_config, "system", &[], &[], &ImageFormat::OpenAi)?;
assert_eq!(request["model"], "databricks-gpt-5.4");
@@ -1687,6 +1694,7 @@ mod tests {
toolshim_model: None,
request_params: None,
reasoning: None,
request_headers: None,
};
let messages = vec![
@@ -1739,6 +1747,7 @@ mod tests {
toolshim_model: None,
request_params: None,
reasoning: None,
request_headers: None,
};
let messages = vec![Message::user().with_text("Hello")];
@@ -1330,6 +1330,7 @@ mod tests {
toolshim_model: None,
request_params: None,
reasoning: None,
request_headers: None,
};
let messages = vec![
@@ -1421,6 +1422,7 @@ mod tests {
toolshim_model: None,
request_params: None,
reasoning: None,
request_headers: None,
};
let tool = Tool::new(
@@ -1464,6 +1466,7 @@ mod tests {
toolshim_model: None,
request_params: None,
reasoning: None,
request_headers: None,
};
let result =
@@ -1544,6 +1547,7 @@ mod tests {
toolshim_model: None,
request_params: None,
reasoning: None,
request_headers: None,
};
let result =
@@ -1594,6 +1598,7 @@ mod tests {
toolshim_model: None,
request_params: None,
reasoning: None,
request_headers: None,
};
let result = create_responses_request(&model_config, "You are helpful.", &[], &[]).unwrap();
@@ -1619,6 +1624,7 @@ mod tests {
serde_json::json!(true),
)])),
reasoning: None,
request_headers: None,
};
let result = create_responses_request(&model_config, "", &[], &[]).unwrap();
@@ -1643,6 +1649,7 @@ mod tests {
toolshim_model: None,
request_params: None,
reasoning: None,
request_headers: None,
};
let result =
@@ -1689,6 +1696,7 @@ mod tests {
toolshim_model: None,
request_params: None,
reasoning: None,
request_headers: None,
};
let result = create_responses_request(&model_config, "", &messages, &[]).unwrap();
@@ -1725,6 +1733,7 @@ mod tests {
toolshim_model: None,
request_params: None,
reasoning: None,
request_headers: None,
};
let result = create_responses_request(&model_config, "", &messages, &[]).unwrap();
@@ -1756,6 +1765,7 @@ mod tests {
toolshim_model: None,
request_params: None,
reasoning: None,
request_headers: None,
};
let result = create_responses_request(&model_config, "", &messages, &[]).unwrap();
@@ -1786,6 +1796,7 @@ mod tests {
toolshim_model: None,
request_params: None,
reasoning: None,
request_headers: None,
};
let result = create_responses_request(&model_config, "", &messages, &[]).unwrap();
@@ -1820,6 +1831,7 @@ mod tests {
toolshim_model: None,
request_params: None,
reasoning: None,
request_headers: None,
};
let result = create_responses_request(&model_config, "", &messages, &[]).unwrap();
@@ -1856,6 +1868,7 @@ mod tests {
toolshim_model: None,
request_params: None,
reasoning: None,
request_headers: None,
};
let result = create_responses_request(&model_config, "", &messages, &[]).unwrap();
@@ -1881,6 +1894,7 @@ mod tests {
toolshim_model: None,
request_params: None,
reasoning: None,
request_headers: None,
};
let result = create_responses_request(&model_config, "", &messages, &[]).unwrap();
@@ -1912,6 +1926,7 @@ mod tests {
toolshim_model: None,
request_params: None,
reasoning: None,
request_headers: None,
};
let result = create_responses_request(&model_config, "", &messages, &[]).unwrap();
@@ -1943,6 +1958,7 @@ mod tests {
toolshim_model: None,
request_params: None,
reasoning: None,
request_headers: None,
};
let result = create_responses_request(&model_config, "", &messages, &[]).unwrap();
@@ -2085,6 +2101,7 @@ mod tests {
toolshim_model: None,
request_params: None,
reasoning: None,
request_headers: None,
};
let result = create_responses_request(&model_config, "", &messages, &[]).unwrap();
@@ -2125,6 +2142,7 @@ mod tests {
toolshim_model: None,
request_params: None,
reasoning: None,
request_headers: None,
};
let result = create_responses_request(&model_config, "", &messages, &[]).unwrap();
@@ -2162,6 +2180,7 @@ mod tests {
toolshim_model: None,
request_params: None,
reasoning: None,
request_headers: None,
};
let result = create_responses_request(&model_config, "", &messages, &[]).unwrap();
@@ -2199,6 +2218,7 @@ mod tests {
toolshim_model: None,
request_params: None,
reasoning: None,
request_headers: None,
};
let result = create_responses_request(&model_config, "", &messages, &[]).unwrap();
+30
View File
@@ -33,6 +33,10 @@ pub struct ModelConfig {
pub request_params: Option<HashMap<String, Value>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning: Option<bool>,
/// Per-request HTTP headers attached to outgoing provider calls.
/// Never serialized into request bodies.
#[serde(skip)]
pub request_headers: Option<HashMap<String, String>>,
}
impl<'de> Deserialize<'de> for ModelConfig {
@@ -64,6 +68,7 @@ impl<'de> Deserialize<'de> for ModelConfig {
toolshim_model: raw.toolshim_model,
request_params: raw.request_params,
reasoning: raw.reasoning,
request_headers: None,
};
config.normalize_effort_suffix();
Ok(config)
@@ -81,6 +86,7 @@ impl ModelConfig {
toolshim_model: None,
request_params: None,
reasoning: None,
request_headers: None,
};
config.normalize_effort_suffix();
config
@@ -162,6 +168,11 @@ impl ModelConfig {
self
}
pub fn with_request_headers(mut self, headers: Option<HashMap<String, String>>) -> Self {
self.request_headers = headers;
self
}
pub fn with_merged_request_params(mut self, params: HashMap<String, Value>) -> Self {
match self.request_params.as_mut() {
Some(existing) => {
@@ -300,6 +311,25 @@ impl ModelConfig {
mod tests {
use super::*;
#[test]
fn request_headers_never_serialize_into_bodies() {
let config = ModelConfig::new("test-model").with_request_headers(Some(HashMap::from([(
"queue_threshold".to_string(),
"500".to_string(),
)])));
let serialized = serde_json::to_value(&config).unwrap();
assert!(serialized.get("request_headers").is_none());
assert_eq!(
config
.request_headers
.as_ref()
.unwrap()
.get("queue_threshold"),
Some(&"500".to_string())
);
}
mod thinking_effort_tests {
use super::*;
+3 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "goose-providers"
version = "0.1.0-alpha.0"
version = "0.1.0-alpha.5"
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
@@ -35,8 +35,8 @@ anyhow = { workspace = true }
async-stream = { workspace = true }
chrono = { workspace = true }
futures = { workspace = true }
goose-provider-types = { version = "0.1.0-alpha.0", path = "../goose-provider-types", default-features = false }
goose-local-inference = { version = "0.1.0-alpha.0", path = "../goose-local-inference", default-features = false, optional = true }
goose-provider-types = { version = "0.1.0-alpha.5", path = "../goose-provider-types", default-features = false }
goose-local-inference = { version = "0.1.0-alpha.5", path = "../goose-local-inference", default-features = false, optional = true }
reqwest = { workspace = true }
rmcp = { workspace = true, features = ["server", "macros"] }
serde = { workspace = true }
+4 -1
View File
@@ -282,7 +282,10 @@ impl Provider for AnthropicProvider {
let response = self
.with_retry(|| async {
let request = self.api_client.request("v1/messages");
let request = self
.api_client
.request("v1/messages")
.model_headers(model_config)?;
let resp = request.response_post(&payload).await?;
handle_status(resp).await
})
+72
View File
@@ -408,6 +408,17 @@ impl<'a> ApiRequestBuilder<'a> {
self
}
/// Apply per-request headers from a model config, overriding any static
/// client headers on key collision.
pub fn model_headers(self, model_config: &crate::model::ModelConfig) -> Result<Self> {
match &model_config.request_headers {
Some(headers) => headers
.iter()
.try_fold(self, |builder, (key, value)| builder.header(key, value)),
None => Ok(self),
}
}
pub async fn api_post(self, payload: &Value) -> Result<ApiResponse> {
let response = self.response_post(payload).await?;
ApiResponse::from_response(response).await
@@ -598,6 +609,67 @@ ShGoCNbfNS+COlPMRAujyDlATZcLs9p4tA==
mod tests {
use super::*;
#[test]
fn test_model_headers_applied_and_override_static_headers() {
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async {
let client = ApiClient::new_with_tls(
"http://localhost:8080".to_string(),
AuthMethod::NoAuth,
None,
)
.unwrap()
.with_header("x-static", "static-value")
.unwrap()
.with_header("queue_threshold", "1000")
.unwrap();
let model_config = crate::model::ModelConfig::new("test-model").with_request_headers(
Some(std::collections::HashMap::from([
("queue_threshold".to_string(), "500".to_string()),
("Idempotency-Key".to_string(), "abc-123".to_string()),
])),
);
let request = client
.request("/test")
.model_headers(&model_config)
.unwrap()
.send_request(|url, client| client.get(url))
.await
.unwrap();
let headers = request.build().unwrap().headers().clone();
let get = |name: &str| {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.map(str::to_string)
};
assert_eq!(get("queue_threshold"), Some("500".to_string()));
assert_eq!(get("Idempotency-Key"), Some("abc-123".to_string()));
});
}
#[test]
fn test_model_headers_rejects_invalid_header_name() {
let client = ApiClient::new_with_tls(
"http://localhost:8080".to_string(),
AuthMethod::NoAuth,
None,
)
.unwrap();
let model_config = crate::model::ModelConfig::new("test-model").with_request_headers(Some(
std::collections::HashMap::from([("bad header name".to_string(), "value".to_string())]),
));
assert!(client
.request("/test")
.model_headers(&model_config)
.is_err());
}
#[test]
fn test_request_builder_decorator() {
let runtime = tokio::runtime::Runtime::new().unwrap();
+18 -3
View File
@@ -595,7 +595,12 @@ impl Provider for DatabricksProvider {
let response = self
.with_retry(|| async {
let payload_clone = payload.clone();
let resp = self.api_client.response_post(&path, &payload_clone).await?;
let resp = self
.api_client
.request(&path)
.model_headers(model_config)?
.response_post(&payload_clone)
.await?;
handle_status(resp).await
})
.await
@@ -655,7 +660,12 @@ impl Provider for DatabricksProvider {
let mut log = start_log(model_config, &payload)?;
let response = self
.with_retry(|| async {
let resp = self.api_client.response_post(&path, &payload).await?;
let resp = self
.api_client
.request(&path)
.model_headers(model_config)?
.response_post(&payload)
.await?;
if !resp.status().is_success() {
let status = resp.status();
let url = sanitize_url(resp.url().as_str());
@@ -672,7 +682,12 @@ impl Provider for DatabricksProvider {
Err(e) if e.to_string().contains("stream_options") => {
payload.as_object_mut().unwrap().remove("stream_options");
self.with_retry(|| async {
let resp = self.api_client.response_post(&path, &payload).await?;
let resp = self
.api_client
.request(&path)
.model_headers(model_config)?
.response_post(&payload)
.await?;
if !resp.status().is_success() {
let status = resp.status();
let url = sanitize_url(resp.url().as_str());
+9 -3
View File
@@ -205,7 +205,9 @@ impl DatabricksV2Provider {
.with_retry(|| async {
let resp = self
.api_client
.response_post("ai-gateway/openai/v1/responses", &payload)
.request("ai-gateway/openai/v1/responses")
.model_headers(model_config)?
.response_post(&payload)
.await?;
handle_status(resp).await
})
@@ -241,7 +243,9 @@ impl DatabricksV2Provider {
.with_retry(|| async {
let resp = self
.api_client
.response_post("ai-gateway/mlflow/v1/chat/completions", &payload)
.request("ai-gateway/mlflow/v1/chat/completions")
.model_headers(model_config)?
.response_post(&payload)
.await?;
handle_status(resp).await
})
@@ -275,7 +279,9 @@ impl DatabricksV2Provider {
.with_retry(|| async {
let resp = self
.api_client
.response_post("ai-gateway/anthropic/v1/messages", &payload)
.request("ai-gateway/anthropic/v1/messages")
.model_headers(model_config)?
.response_post(&payload)
.await?;
handle_status(resp).await
})
+12 -4
View File
@@ -93,11 +93,19 @@ impl GoogleProvider {
async fn post_stream(
&self,
model_name: &str,
model_config: &ModelConfig,
payload: &Value,
) -> Result<reqwest::Response, ProviderError> {
let path = format!("v1beta/models/{}:streamGenerateContent?alt=sse", model_name);
let response = self.api_client.response_post(&path, payload).await?;
let path = format!(
"v1beta/models/{}:streamGenerateContent?alt=sse",
model_config.model_name
);
let response = self
.api_client
.request(&path)
.model_headers(model_config)?
.response_post(payload)
.await?;
handle_status(response).await
}
}
@@ -181,7 +189,7 @@ impl Provider for GoogleProvider {
let mut log = start_log(model_config, &payload)?;
let response = self
.with_retry(|| async { self.post_stream(&model_config.model_name, &payload).await })
.with_retry(|| async { self.post_stream(model_config, &payload).await })
.await
.inspect_err(|e| {
let _ = log.error(e);
+3 -1
View File
@@ -426,7 +426,9 @@ impl Provider for OllamaProvider {
.with_retry(|| async {
let resp = self
.api_client
.response_post("v1/chat/completions", &payload)
.request("v1/chat/completions")
.model_headers(model_config)?
.response_post(&payload)
.await?;
handle_status(resp).await
})
+10 -9
View File
@@ -656,14 +656,13 @@ impl Provider for OpenAiProvider {
let payload_clone = payload.clone();
let resp = self
.api_client
.response_post(
&Self::map_base_path(
&self.base_path,
"responses",
OPEN_AI_DEFAULT_RESPONSES_PATH,
),
&payload_clone,
)
.request(&Self::map_base_path(
&self.base_path,
"responses",
OPEN_AI_DEFAULT_RESPONSES_PATH,
))
.model_headers(model_config)?
.response_post(&payload_clone)
.await?;
handle_status(resp).await
})
@@ -721,7 +720,9 @@ impl Provider for OpenAiProvider {
.with_retry(|| async {
let resp = self
.api_client
.response_post(&self.base_path, &payload)
.request(&self.base_path)
.model_headers(model_config)?
.response_post(&payload)
.await?;
handle_status(resp).await
})
@@ -123,7 +123,9 @@ impl Provider for OpenAiCompatibleProvider {
.with_retry(|| async {
let resp = self
.api_client
.response_post(&completions_path, &payload)
.request(&completions_path)
.model_headers(model_config)?
.response_post(&payload)
.await?;
handle_status(resp).await
})
+9 -3
View File
@@ -91,10 +91,16 @@ impl SnowflakeProvider {
})
}
async fn post(&self, payload: &Value) -> Result<Value, ProviderError> {
async fn post(
&self,
model_config: &ModelConfig,
payload: &Value,
) -> Result<Value, ProviderError> {
let response = self
.api_client
.response_post("api/v2/cortex/inference:complete", payload)
.request("api/v2/cortex/inference:complete")
.model_headers(model_config)?
.response_post(payload)
.await?;
let status = response.status();
@@ -317,7 +323,7 @@ impl Provider for SnowflakeProvider {
let response = self
.with_retry(|| async {
let payload_clone = payload.clone();
self.post(&payload_clone).await
self.post(model_config, &payload_clone).await
})
.await?;
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "goose-sdk-types"
version = "0.1.0-alpha.0"
version = "0.1.0-alpha.5"
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
+7 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "goose-sdk"
version = "0.1.0-alpha.0"
version = "0.1.0-alpha.5"
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
@@ -27,20 +27,24 @@ uniffi = [
"dep:futures",
"dep:serde_json",
"dep:tokio",
"dep:rmcp",
"dep:base64",
]
[dependencies]
goose-sdk-types = { version = "0.1.0-alpha.0", path = "../goose-sdk-types" }
goose-sdk-types = { version = "0.1.0-alpha.5", path = "../goose-sdk-types" }
agent-client-protocol = { workspace = true, features = ["unstable"] }
agent-client-protocol-schema = { workspace = true }
uniffi = { version = "0.32", features = ["cli"], optional = true }
thiserror = { version = "2", optional = true }
goose-providers = { version = "0.1.0-alpha.0", path = "../goose-providers", features = ["rustls-tls"], optional = true }
goose-providers = { version = "0.1.0-alpha.5", path = "../goose-providers", features = ["rustls-tls"], optional = true }
futures = { workspace = true, optional = true }
serde_json = { workspace = true, optional = true }
tokio = { workspace = true, features = ["rt-multi-thread", "sync"], optional = true }
anyhow = { workspace = true, optional = true }
rmcp = { workspace = true, optional = true }
base64 = { workspace = true, optional = true }
[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "process", "io-std", "io-util"] }
+3 -3
View File
@@ -4,9 +4,9 @@ The bindings layer for Goose. It houses the shared types used for both ACP and
SDK access, and exposes a cross-language version of the Goose API.
With `--features uniffi` the crate compiles to native bindings for Python and
Kotlin (namespace `goose` / `io.github.aaif_goose`). The UniFFI surface currently lets
callers construct declarative providers from JSON and stream provider
completions.
Kotlin (namespace `goose` / `io.github.aaif_goose`). The UniFFI surface lets
callers construct providers, stream provider completions, perform non-streaming
completion, and pass rich message/tool content across the FFI boundary.
```bash
just python # build bindings + run examples/uniffi/provider.py
@@ -1,6 +1,8 @@
import io.github.aaif_goose.MessageContent
import io.github.aaif_goose.MessageRole
import io.github.aaif_goose.ProviderMessage
import io.github.aaif_goose.ProviderModelConfig
import io.github.aaif_goose.StreamChunk
import io.github.aaif_goose.streamFlow
import io.github.aaif_goose.providers.openai.defaultModel
import io.github.aaif_goose.providers.openai.provider as openAiProvider
@@ -17,7 +19,11 @@ fun main() = runBlocking {
val messages = listOf(
ProviderMessage(
role = MessageRole.USER,
text = "What is the capital of France? Answer in one sentence.",
content = listOf(
MessageContent.Text(
text = "What is the capital of France? Answer in one sentence.",
),
),
),
)
@@ -28,8 +34,12 @@ fun main() = runBlocking {
messages,
)
.collect { chunk ->
chunk.text?.let { print(it) }
chunk.usageJson?.let { println("\nusage: $it") }
when (chunk) {
is StreamChunk.TextChunk -> print(chunk.text)
is StreamChunk.EndChunk -> chunk.usage?.let { println("\nusage: $it") }
is StreamChunk.ErrorChunk -> System.err.println("\nerror: ${chunk.error.message}")
is StreamChunk.ToolChunk -> Unit
}
}
println()
}
+15 -7
View File
@@ -1,7 +1,6 @@
#!/usr/bin/env -S uv run --script
"""Goose SDK demo: build a declarative provider and stream a completion."""
import asyncio
import json
import sys
from pathlib import Path
@@ -9,9 +8,11 @@ HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent.parent / "generated"))
from goose import ( # noqa: E402
MessageContent,
MessageRole,
ProviderMessage,
ProviderModelConfig,
StreamChunk,
declarative_provider_from_json,
)
@@ -19,19 +20,26 @@ from goose import ( # noqa: E402
async def main() -> None:
provider = declarative_provider_from_json((HERE.parent / "deepseek.json").read_text())
model = ProviderModelConfig(model_name="deepseek-v4-flash")
messages = [ProviderMessage(role=MessageRole.USER, text="what is the capital of France?")]
messages = [
ProviderMessage(
role=MessageRole.USER,
content=[MessageContent.Text(text="what is the capital of France?")],
)
]
stream = await provider.stream(
model,
"You are a knowledgable geography expert",
messages,
[],
)
while chunk := await stream.next():
if chunk.text:
while chunk := await stream.next_chunk():
if isinstance(chunk, StreamChunk.TextChunk):
print(chunk.text, end="")
if chunk.usage_json:
usage = json.loads(chunk.usage_json)
print(f"\nusage: {usage}")
elif isinstance(chunk, StreamChunk.EndChunk) and chunk.usage:
print(f"\nusage: {chunk.usage}")
elif isinstance(chunk, StreamChunk.ErrorChunk):
print(f"\nerror: {chunk.error.message}", file=sys.stderr)
print()
+6
View File
@@ -77,9 +77,15 @@ maven-bindings profile="release":
esac; \
crates/goose-sdk/scripts/prepare-maven-package.sh "$lib_path"
maven-clean:
rm -rf {{maven_dir}}/build {{maven_kotlin_dir}} {{maven_resources_dir}}
maven-package: (maven-bindings "release")
cd {{maven_dir}} && ./gradlew --no-daemon publishToMavenLocal
maven-package-clean: maven-clean (maven-bindings "release")
cd {{maven_dir}} && ./gradlew --no-daemon publishToMavenLocal
maven-publish: (maven-bindings "release")
cd {{maven_dir}} && ./gradlew --no-daemon publishAndReleaseToMavenCentral
+4 -1
View File
@@ -5,7 +5,10 @@ as the Maven artifact `io.github.aaif-goose:gdk`.
The artifact version is read from `crates/goose-sdk/Cargo.toml`, so it stays in
lockstep with the Rust crate version. The jar includes the generated Kotlin API
and native libraries under JNA platform resource directories.
and native libraries under JNA platform resource directories. Packaging supports
`darwin-aarch64`, `darwin-x86-64`, `linux-x86-64`, `linux-aarch64`, and
`win32-x86-64` resource prefixes; CI is responsible for assembling every native
library into the final published jar.
Build locally from the repository root:
-4
View File
@@ -42,10 +42,6 @@ tasks.jar {
}
}
tasks.withType<GenerateModuleMetadata>().configureEach {
dependsOn(tasks.named("plainJavadocJar"))
}
mavenPublishing {
publishToMavenCentral(automaticRelease = true)
if (providers.gradleProperty("signingInMemoryKey").isPresent) {
@@ -7,9 +7,16 @@ public fun Provider.streamFlow(
model: ProviderModelConfig,
system: String,
messages: List<ProviderMessage>,
): Flow<ProviderStreamChunk> = flow {
val stream = stream(model, system, messages)
tools: List<ProviderTool> = emptyList(),
): Flow<StreamChunk> = flow {
val stream = stream(model, system, messages, tools)
while (true) {
emit(stream.next() ?: break)
emit(stream.nextChunk() ?: break)
}
}
public suspend fun Provider.complete(
model: ProviderModelConfig,
system: String,
messages: List<ProviderMessage>,
): ProviderCompletion = complete(model, system, messages, emptyList())
@@ -0,0 +1,9 @@
package io.github.aaif_goose.providers.anthropic
public fun provider(
apiKey: String,
baseUrl: String? = null,
betaHeaders: List<String> = emptyList(),
): io.github.aaif_goose.Provider = io.github.aaif_goose.anthropicProvider(apiKey, baseUrl, betaHeaders)
public fun defaultModel(): String = io.github.aaif_goose.anthropicDefaultModel()
@@ -0,0 +1,5 @@
package io.github.aaif_goose.providers.groq
public fun provider(apiKey: String): io.github.aaif_goose.Provider = io.github.aaif_goose.groqProvider(apiKey)
public fun defaultModel(): String = io.github.aaif_goose.groqDefaultModel()
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "goose-sdk"
version = "0.1.0a0"
version = "0.1.0a5"
description = "Python bindings for the Goose SDK"
readme = "README.md"
requires-python = ">=3.9"
@@ -5,6 +5,7 @@ case "$(uname -s)-$(uname -m)" in
Darwin-arm64) echo "darwin-aarch64" ;;
Darwin-x86_64) echo "darwin-x86-64" ;;
Linux-x86_64) echo "linux-x86-64" ;;
Linux-aarch64|Linux-arm64) echo "linux-aarch64" ;;
MINGW64_NT-*-x86_64|MSYS_NT-*-x86_64|CYGWIN_NT-*-x86_64) echo "win32-x86-64" ;;
*) echo "unsupported platform: $(uname -s)-$(uname -m)" >&2; exit 1 ;;
esac
@@ -10,7 +10,7 @@ native_lib="$1"
resource_prefix="${2:-$(crates/goose-sdk/scripts/maven-resource-prefix.sh)}"
case "$resource_prefix" in
darwin-aarch64|linux-x86-64|win32-x86-64) ;;
darwin-aarch64|darwin-x86-64|linux-x86-64|linux-aarch64|win32-x86-64) ;;
*) echo "unsupported JNA resource prefix: $resource_prefix" >&2; exit 1 ;;
esac
File diff suppressed because it is too large Load Diff
+1
View File
@@ -710,6 +710,7 @@ mod tests {
toolshim_model: None,
request_params: None,
reasoning: None,
request_headers: None,
},
max_tool_responses: None,
}
+2
View File
@@ -98,6 +98,7 @@ pub async fn get_fast_model(
match fast_model_name {
Some(name) if name != model_config.model_name => {
model_config_from_user_config(provider_name, name)
.map(|config| config.with_request_headers(model_config.request_headers.clone()))
}
_ => Ok(model_config.clone()),
}
@@ -179,6 +180,7 @@ fn base_model_config_from_user_config(model_name: &str) -> Result<ModelConfig> {
toolshim_model: get_goose_toolshim_model(config)?,
request_params: None,
reasoning: None,
request_headers: None,
};
model.normalize_effort_suffix();
Ok(model)
+1
View File
@@ -941,6 +941,7 @@ mod tests {
toolshim_model: None,
request_params: None,
reasoning: None,
request_headers: None,
},
)
}
+9 -2
View File
@@ -279,6 +279,7 @@ impl GcpVertexAIProvider {
async fn send_request_with_retry(
&self,
model: &ModelConfig,
url: Url,
payload: &Value,
) -> Result<reqwest::Response, ProviderError> {
@@ -314,12 +315,18 @@ impl GcpVertexAIProvider {
}
};
let request = self
let mut request = self
.client
.post(url.clone())
.json(payload)
.header("Authorization", auth_header);
if let Some(headers) = &model.request_headers {
for (key, value) in headers {
request = request.header(key, value);
}
}
let response = (self.request_builder)(request)
.map_err(|e| ProviderError::ExecutionError(e.to_string()))?
.send()
@@ -400,7 +407,7 @@ impl GcpVertexAIProvider {
.build_request_url(model, context.provider(), location, true)
.map_err(|e| ProviderError::RequestFailed(e.to_string()))?;
self.send_request_with_retry(url, payload).await
self.send_request_with_retry(model, url, payload).await
}
async fn post_stream(
+7 -1
View File
@@ -261,6 +261,7 @@ impl GithubCopilotProvider {
async fn post(
&self,
model_config: &ModelConfig,
path: &str,
is_user_initiated: bool,
payload: &mut Value,
@@ -279,7 +280,9 @@ impl GithubCopilotProvider {
.with_headers(headers)?;
api_client
.response_post(path, payload)
.request(path)
.model_headers(model_config)?
.response_post(payload)
.await
.map_err(|e| e.into())
}
@@ -413,6 +416,7 @@ impl GithubCopilotProvider {
let mut payload_clone = payload.clone();
let resp = self
.post(
model_config,
"responses",
is_user_initiated,
&mut payload_clone,
@@ -459,6 +463,7 @@ impl GithubCopilotProvider {
let mut payload_clone = payload.clone();
let resp = self
.post(
model_config,
"chat/completions",
is_user_initiated,
&mut payload_clone,
@@ -488,6 +493,7 @@ impl GithubCopilotProvider {
.with_retry(|| async {
let mut payload_clone = payload.clone();
self.post(
model_config,
"chat/completions",
is_user_initiated,
&mut payload_clone,
+9 -3
View File
@@ -136,10 +136,16 @@ impl LiteLLMProvider {
Ok(models)
}
async fn post(&self, payload: &Value) -> Result<Value, ProviderError> {
async fn post(
&self,
model_config: &ModelConfig,
payload: &Value,
) -> Result<Value, ProviderError> {
let response = self
.api_client
.response_post(&self.base_path, payload)
.request(&self.base_path)
.model_headers(model_config)?
.response_post(payload)
.await?;
handle_response_openai_compat(response).await
}
@@ -248,7 +254,7 @@ impl Provider for LiteLLMProvider {
let response = self
.with_retry(|| async {
let payload_clone = payload.clone();
self.post(&payload_clone).await
self.post(model_config, &payload_clone).await
})
.await?;
+3 -1
View File
@@ -196,7 +196,9 @@ impl Provider for NanoGptProvider {
.with_retry(|| async {
let resp = self
.api_client
.response_post("chat/completions", &payload)
.request("chat/completions")
.model_headers(model_config)?
.response_post(&payload)
.await?;
handle_status(resp).await
})
+4 -1
View File
@@ -335,7 +335,9 @@ impl Provider for OpenRouterProvider {
.with_retry(|| async {
let resp = self
.api_client
.response_post("api/v1/chat/completions", &payload)
.request("api/v1/chat/completions")
.model_headers(model_config)?
.response_post(&payload)
.await?;
handle_status(resp).await
})
@@ -369,6 +371,7 @@ mod tests {
toolshim_model: None,
request_params: None,
reasoning: None,
request_headers: None,
}
}
+3 -1
View File
@@ -152,7 +152,9 @@ impl Provider for TetrateProvider {
.with_retry(|| async {
let resp = self
.api_client
.response_post("v1/chat/completions", &payload)
.request("v1/chat/completions")
.model_headers(model_config)?
.response_post(&payload)
.await?;
let resp = handle_status(resp)
.await