A provider example

This commit is contained in:
Jack Amadeo
2026-06-25 16:58:10 -04:00
parent a3b4166d4e
commit 6e178b992b
5 changed files with 271 additions and 44 deletions
Generated
+4
View File
@@ -5142,7 +5142,11 @@ version = "1.40.0"
dependencies = [
"agent-client-protocol",
"agent-client-protocol-schema",
"anyhow",
"futures",
"goose-providers",
"goose-sdk-types",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tokio-util",
+14 -1
View File
@@ -19,7 +19,15 @@ required-features = ["uniffi"]
[features]
default = []
uniffi = ["dep:uniffi", "dep:thiserror"]
uniffi = [
"dep:uniffi",
"dep:thiserror",
"dep:anyhow",
"dep:goose-providers",
"dep:futures",
"dep:serde_json",
"dep:tokio",
]
[dependencies]
goose-sdk-types = { path = "../goose-sdk-types" }
@@ -28,6 +36,11 @@ agent-client-protocol-schema = { workspace = true }
uniffi = { version = "0.31", features = ["cli"], optional = true }
thiserror = { version = "2", optional = true }
goose-providers = { version = "1.39.0", 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 }
[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "process", "io-std", "io-util"] }
@@ -0,0 +1,52 @@
"""Goose SDK demo: build a declarative provider and stream a completion."""
from __future__ import annotations
import json
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent.parent / "generated"))
from aaif_goose import ( # noqa: E402
DeclarativeProvider,
MessageRole,
ProviderMessage,
ProviderModelConfig,
)
def main() -> None:
provider_json = (HERE.parent.parent.parent / "goose-providers" / "examples" / "deepseek.json").read_text()
provider = DeclarativeProvider.from_json(provider_json)
model = ProviderModelConfig(
model_name="deepseek-v4-flash",
context_limit=None,
temperature=None,
max_tokens=None,
toolshim=False,
toolshim_model=None,
request_params_json=None,
reasoning=None,
)
messages = [ProviderMessage(role=MessageRole.USER, text="what is the capital of France?")]
stream = provider.stream(
model,
"",
"You are a knowledgable geography expert",
messages,
)
print(f"{provider.name()}:")
while chunk := stream.next():
if chunk.text:
print(chunk.text, end="")
if chunk.usage_json:
usage = json.loads(chunk.usage_json)
print(f"\nusage: {usage}")
print()
if __name__ == "__main__":
main()
+197 -37
View File
@@ -1,14 +1,18 @@
//! In-process uniffi bindings for the Goose SDK.
//!
//! This is the published API surface exposed to Python and Kotlin. Right now it
//! is a minimal `ping` -> `pong` round-trip that proves the uniffi
//! infrastructure end to end without depending on the `goose` core crate.
//!
//! To build the real SDK, add `goose` (and whatever else you need) as
//! dependencies and replace the [`Client`] methods below with the actual
//! agent surface.
//! This is the API surface exposed to Python and Kotlin. It currently focuses
//! on declarative providers: consumers can construct a provider from JSON and
//! stream completions from it.
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use futures::StreamExt;
use goose_providers::{
base::{MessageStream, Provider},
conversation::message::Message,
declarative::EnvKeyResolver,
model::ModelConfig,
};
/// Errors surfaced across the uniffi boundary.
#[derive(Debug, thiserror::Error, uniffi::Error)]
@@ -17,36 +21,178 @@ pub enum GooseError {
Generic(String),
}
/// A reply to a [`Client::ping`] call.
#[derive(Debug, Clone, uniffi::Record)]
pub struct Pong {
/// Echo of the message that was pinged.
pub message: String,
impl From<anyhow::Error> for GooseError {
fn from(error: anyhow::Error) -> Self {
Self::Generic(error.to_string())
}
}
/// The top-level entry point for the Goose SDK.
///
/// This is the object that consuming languages instantiate. Today it only knows
/// how to answer a ping; extend it with the real agent API.
impl From<goose_providers::errors::ProviderError> for GooseError {
fn from(error: goose_providers::errors::ProviderError) -> Self {
Self::Generic(error.to_string())
}
}
impl From<serde_json::Error> for GooseError {
fn from(error: serde_json::Error) -> Self {
Self::Generic(error.to_string())
}
}
/// A text message passed to a provider.
#[derive(Debug, Clone, uniffi::Record)]
pub struct ProviderMessage {
pub role: MessageRole,
pub text: String,
}
/// Supported message roles for provider requests and streamed responses.
#[derive(Debug, Clone, uniffi::Enum)]
pub enum MessageRole {
User,
Assistant,
}
impl ProviderMessage {
fn to_goose_message(&self) -> Message {
match self.role {
MessageRole::User => Message::user().with_text(&self.text),
MessageRole::Assistant => Message::assistant().with_text(&self.text),
}
}
}
/// Model selection and optional generation settings for a provider request.
#[derive(Debug, Clone, uniffi::Record)]
pub struct ProviderModelConfig {
pub model_name: String,
pub context_limit: Option<u64>,
pub temperature: Option<f32>,
pub max_tokens: Option<i32>,
pub toolshim: bool,
pub toolshim_model: Option<String>,
/// Provider-specific request parameters as a JSON object string.
pub request_params_json: Option<String>,
pub reasoning: Option<bool>,
}
impl ProviderModelConfig {
fn to_goose_model_config(&self) -> Result<ModelConfig, GooseError> {
let mut config = ModelConfig::new(&self.model_name)
.with_context_limit(self.context_limit.map(|limit| limit as usize))
.with_temperature(self.temperature)
.with_max_tokens(self.max_tokens)
.with_toolshim(self.toolshim)
.with_toolshim_model(self.toolshim_model.clone());
if let Some(request_params_json) = &self.request_params_json {
let request_params = serde_json::from_str(request_params_json)?;
config = config.with_merged_request_params(request_params);
}
config.reasoning = self.reasoning;
Ok(config)
}
}
/// One item yielded by a provider stream.
#[derive(Debug, Clone, uniffi::Record)]
pub struct ProviderStreamChunk {
/// The concatenated text content in this message chunk, if one was emitted.
pub text: Option<String>,
/// Full Goose message JSON for callers that need non-text content such as tool requests.
pub message_json: Option<String>,
/// Provider usage JSON when the provider emits usage metadata.
pub usage_json: Option<String>,
}
/// A declarative Goose provider constructed from provider JSON.
#[derive(uniffi::Object)]
pub struct Client {}
pub struct DeclarativeProvider {
provider: Box<dyn Provider>,
runtime: Arc<tokio::runtime::Runtime>,
}
#[uniffi::export]
impl Client {
impl DeclarativeProvider {
/// Construct a declarative provider using the process environment to resolve
/// configured API key environment variables.
#[uniffi::constructor]
pub fn new() -> Arc<Self> {
Arc::new(Self {})
pub fn from_json(json: String) -> Result<Arc<Self>, GooseError> {
let provider = goose_providers::declarative::from_json(&json, None, EnvKeyResolver {})?;
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.map_err(|error| GooseError::Generic(error.to_string()))?;
Ok(Arc::new(Self {
provider,
runtime: Arc::new(runtime),
}))
}
/// Round-trip a message through the SDK. Returns a [`Pong`] echoing the
/// supplied `message`, prefixed with `pong: `.
pub fn ping(&self, message: String) -> Result<Pong, GooseError> {
if message.is_empty() {
return Err(GooseError::Generic("message must not be empty".into()));
}
Ok(Pong {
message: format!("pong: {message}"),
})
pub fn name(&self) -> String {
self.provider.get_name().to_string()
}
/// Start a streaming completion request. Tools are not yet exposed over the
/// uniffi boundary, so this calls providers with an empty tool list.
pub fn stream(
&self,
model: ProviderModelConfig,
session_id: String,
system: String,
messages: Vec<ProviderMessage>,
) -> Result<Arc<DeclarativeProviderStream>, GooseError> {
let model = model.to_goose_model_config()?;
let messages = messages
.iter()
.map(ProviderMessage::to_goose_message)
.collect::<Vec<_>>();
let stream = self.runtime.block_on(self.provider.stream(
&model,
&session_id,
&system,
&messages,
&[],
))?;
Ok(Arc::new(DeclarativeProviderStream {
stream: Mutex::new(stream),
runtime: Arc::clone(&self.runtime),
}))
}
}
/// A blocking iterator over provider stream chunks.
#[derive(uniffi::Object)]
pub struct DeclarativeProviderStream {
stream: Mutex<MessageStream>,
runtime: Arc<tokio::runtime::Runtime>,
}
#[uniffi::export]
impl DeclarativeProviderStream {
/// Return the next stream chunk, or `None` when the stream is exhausted.
pub fn next(&self) -> Result<Option<ProviderStreamChunk>, GooseError> {
let mut stream = self
.stream
.lock()
.map_err(|_| GooseError::Generic("provider stream lock poisoned".to_string()))?;
let Some((message, usage)) = self.runtime.block_on(stream.next()).transpose()? else {
return Ok(None);
};
let text = message.as_ref().map(Message::as_concat_text);
let message_json = message.as_ref().map(serde_json::to_string).transpose()?;
let usage_json = usage.as_ref().map(serde_json::to_string).transpose()?;
Ok(Some(ProviderStreamChunk {
text,
message_json,
usage_json,
}))
}
}
@@ -55,15 +201,29 @@ mod tests {
use super::*;
#[test]
fn ping_returns_pong() {
let client = Client::new();
let pong = client.ping("aaif.io".into()).expect("ping should succeed");
assert_eq!(pong.message, "pong: aaif.io");
fn model_config_rejects_invalid_request_params_json() {
let config = ProviderModelConfig {
model_name: "test".to_string(),
context_limit: None,
temperature: None,
max_tokens: None,
toolshim: false,
toolshim_model: None,
request_params_json: Some("not json".to_string()),
reasoning: None,
};
assert!(config.to_goose_model_config().is_err());
}
#[test]
fn empty_ping_errors() {
let client = Client::new();
assert!(client.ping(String::new()).is_err());
fn provider_message_converts_user_text() {
let message = ProviderMessage {
role: MessageRole::User,
text: "what is the capital of France?".to_string(),
}
.to_goose_message();
assert_eq!(message.as_concat_text(), "what is the capital of France?");
}
}
+4 -6
View File
@@ -5,12 +5,10 @@
//! that talks to `goose acp` over stdio.
//!
//! With `--features uniffi` the crate additionally compiles as a
//! `cdylib`/`staticlib` and exposes a small in-process API to Python and Kotlin
//! via [uniffi-rs](https://github.com/mozilla/uniffi-rs).
//!
//! The published uniffi surface is intentionally a single `ping` -> `pong`
//! round-trip. It exists as a working scaffold for adding the real Goose SDK
//! API: replace [`bindings`] with the actual implementation.
//! `cdylib`/`staticlib` and exposes an in-process API to Python and Kotlin via
//! [uniffi-rs](https://github.com/mozilla/uniffi-rs). The current uniffi surface
//! lets callers construct declarative providers from JSON and stream provider
//! completions.
pub use goose_sdk_types::{custom_notifications, custom_requests};