add provider bindings MVP to goose-sdk, and add python wheel publishing (#10208)

This commit is contained in:
Jack Amadeo 2026-07-05 18:15:54 -07:00 committed by GitHub
parent 782f69f175
commit 140158c756
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 727 additions and 113 deletions

118
.github/workflows/python-sdk-wheels.yml vendored Normal file
View file

@ -0,0 +1,118 @@
name: Python SDK Wheels
on:
workflow_dispatch:
inputs:
publish:
description: "Publish wheels to PyPI after building"
required: true
default: false
type: boolean
permissions:
contents: read
id-token: write
jobs:
build-wheels:
name: Build wheel (${{ matrix.name }})
runs-on: ${{ matrix.os }}
container: ${{ matrix.container || null }}
strategy:
fail-fast: false
matrix:
include:
- name: macos-arm64
os: macos-14
- name: macos-x86_64
os: macos-13
- name: linux-x86_64
os: ubuntu-latest
container: quay.io/pypa/manylinux_2_28_x86_64@sha256:441c35fdc6ee809ff9260894f8468ab4fea8c15dc880f8700a3f81b7922c1cda
- name: windows-x86_64
os: windows-latest
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Rust
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: "3.12"
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
- name: Install Linux tools
if: runner.os == 'Linux'
shell: bash
run: |
python3 -m pip install --upgrade pip
python3 -m pip install uv
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to /usr/local/bin
- name: Install just
if: runner.os != 'Linux'
uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4
- name: Cache Cargo artifacts
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
- name: Build wheel
shell: bash
run: just --justfile crates/goose-sdk/justfile python-wheel
- name: Repair Linux wheel
if: runner.os == 'Linux'
shell: bash
run: |
python3 -m pip install auditwheel
mkdir -p crates/goose-sdk/python/wheelhouse
auditwheel repair --plat manylinux_2_28_x86_64 -w crates/goose-sdk/python/wheelhouse crates/goose-sdk/python/dist/*.whl
rm crates/goose-sdk/python/dist/*.whl
mv crates/goose-sdk/python/wheelhouse/*.whl crates/goose-sdk/python/dist/
- name: Smoke test wheel
shell: bash
run: |
python -m venv .venv-wheel-test
if [ "${{ runner.os }}" = "Windows" ]; then
python_bin=".venv-wheel-test/Scripts/python.exe"
else
python_bin=".venv-wheel-test/bin/python"
fi
UV_NO_CONFIG=1 uv pip install --default-index https://pypi.org/simple --python "$python_bin" crates/goose-sdk/python/dist/*.whl
"$python_bin" -c 'import goose; print(goose.__name__)'
- name: Upload wheel artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: goose-sdk-wheel-${{ matrix.name }}
path: crates/goose-sdk/python/dist/*.whl
if-no-files-found: error
publish:
name: Publish wheels to PyPI
needs: build-wheels
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' && inputs.publish
environment: pypi
steps:
- name: Download wheel artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: goose-sdk-wheel-*
path: dist
merge-multiple: true
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
with:
packages-dir: dist

16
Cargo.lock generated
View file

@ -5065,7 +5065,7 @@ dependencies = [
[[package]]
name = "goose-download-manager"
version = "1.41.0"
version = "0.1.0-alpha.0"
dependencies = [
"anyhow",
"once_cell",
@ -5078,7 +5078,7 @@ dependencies = [
[[package]]
name = "goose-local-inference"
version = "1.41.0"
version = "0.1.0-alpha.0"
dependencies = [
"anyhow",
"async-stream",
@ -5144,7 +5144,7 @@ dependencies = [
[[package]]
name = "goose-provider-types"
version = "1.41.0"
version = "0.1.0-alpha.0"
dependencies = [
"anyhow",
"async-stream",
@ -5174,7 +5174,7 @@ dependencies = [
[[package]]
name = "goose-providers"
version = "1.41.0"
version = "0.1.0-alpha.0"
dependencies = [
"anyhow",
"async-stream",
@ -5207,11 +5207,15 @@ dependencies = [
[[package]]
name = "goose-sdk"
version = "1.41.0"
version = "0.1.0-alpha.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",
@ -5220,7 +5224,7 @@ dependencies = [
[[package]]
name = "goose-sdk-types"
version = "1.41.0"
version = "0.1.0-alpha.0"
dependencies = [
"agent-client-protocol",
"agent-client-protocol-schema",

View file

@ -1,6 +1,6 @@
[package]
name = "goose-download-manager"
version.workspace = true
version = "0.1.0-alpha.0"
edition.workspace = true
rust-version.workspace = true
authors.workspace = true

View file

@ -1,6 +1,6 @@
[package]
name = "goose-local-inference"
version.workspace = true
version = "0.1.0-alpha.0"
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 = { path = "../goose-download-manager" }
goose-provider-types = { path = "../goose-provider-types", default-features = false }
goose-sdk-types = { path = "../goose-sdk-types", default-features = false }
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 }
hf-hub = { version = "1.0.0-rc.1", default-features = false }
include_dir = { workspace = true }
llama-cpp-2 = { workspace = true }

View file

@ -1,6 +1,6 @@
[package]
name = "goose-provider-types"
version.workspace = true
version = "0.1.0-alpha.0"
edition.workspace = true
rust-version.workspace = true
authors.workspace = true

View file

@ -1,6 +1,6 @@
[package]
name = "goose-providers"
version.workspace = true
version = "0.1.0-alpha.0"
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 = { path = "../goose-provider-types", default-features = false }
goose-local-inference = { path = "../goose-local-inference", default-features = false, optional = 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 }
reqwest = { workspace = true }
rmcp = { workspace = true, features = ["server", "macros"] }
serde = { workspace = true }

View file

@ -1,6 +1,6 @@
[package]
name = "goose-sdk-types"
version.workspace = true
version = "0.1.0-alpha.0"
edition.workspace = true
rust-version.workspace = true
authors.workspace = true

View file

@ -1,2 +1,6 @@
generated
examples/uniffi/*.jar
python/build/
python/dist/
python/src/*.egg-info/
python/src/goose/

View file

@ -1,6 +1,6 @@
[package]
name = "goose-sdk"
version.workspace = true
version = "0.1.0-alpha.0"
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
@ -19,15 +19,28 @@ 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" }
goose-sdk-types = { version = "0.1.0-alpha.0", path = "../goose-sdk-types" }
agent-client-protocol = { workspace = true, features = ["unstable"] }
agent-client-protocol-schema = { workspace = true }
uniffi = { version = "0.31", 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 }
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"] }

View file

@ -4,13 +4,23 @@ 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 `aaif_goose` / `aaif.goose`). The published surface is
currently a `ping` -> `pong` stub in `src/bindings.rs` — the scaffold for the
real implementation.
Kotlin (namespace `goose` / `io.aaif.goose`). The UniFFI surface currently lets
callers construct declarative providers from JSON and stream provider
completions.
```bash
just python # build bindings + run examples/uniffi/ping.py
just kotlin # build bindings + run examples/uniffi/Ping.kt
just python # build bindings + run examples/uniffi/provider.py
just kotlin # build bindings + run examples/uniffi/Provider.kt
```
Both print `pong: aaif.io`.
## Python package
The PyPI package is published as `goose-sdk` and imports as `goose`.
Build a local wheel from the repository root with:
```bash
just --justfile crates/goose-sdk/justfile python-wheel
```
This regenerates the UniFFI Python bindings, copies the release native library
into the package, and writes the wheel to `crates/goose-sdk/python/dist/`.

View file

@ -0,0 +1,30 @@
{
"name": "deepseek",
"engine": "openai",
"display_name": "DeepSeek",
"description": "Custom DeepSeek provider",
"api_key_env": "DEEPSEEK_API_KEY",
"base_url": "https://api.deepseek.com",
"models": [
{
"name": "deepseek-chat",
"context_limit": 128000,
"input_token_cost": null,
"output_token_cost": null,
"currency": null,
"supports_cache_control": null
},
{
"name": "deepseek-reasoner",
"context_limit": 128000,
"input_token_cost": null,
"output_token_cost": null,
"currency": null,
"supports_cache_control": null
}
],
"headers": null,
"timeout_seconds": null,
"preserves_thinking": true,
"supports_streaming": true
}

View file

@ -1,9 +0,0 @@
package aaif.example
import aaif.goose.Client
fun main() {
val client = Client()
val pong = client.ping("aaif.io")
println(pong.message)
}

View file

@ -0,0 +1,31 @@
package aaif.example
import io.aaif.goose.DeclarativeProvider
import io.aaif.goose.MessageRole
import io.aaif.goose.ProviderMessage
import io.aaif.goose.ProviderModelConfig
import java.nio.file.Paths
fun main() {
val examplesDir = Paths.get("crates/goose-sdk/examples")
val provider = DeclarativeProvider.fromJson(examplesDir.resolve("deepseek.json").toFile().readText())
val model = ProviderModelConfig(modelName = "deepseek-v4-flash")
val messages = listOf(
ProviderMessage(
role = MessageRole.USER,
text = "what is the capital of France?",
),
)
val stream = provider.stream(
model,
"You are a knowledgable geography expert",
messages,
)
while (true) {
val chunk = stream.next() ?: break
chunk.text?.let { print(it) }
chunk.usageJson?.let { println("\nusage: $it") }
}
println()
}

View file

@ -0,0 +1,53 @@
# UniFFI examples
These examples exercise the in-process Goose SDK UniFFI bindings from Python and Kotlin.
## Prerequisites
```bash
source bin/activate-hermit
export DEEPSEEK_API_KEY=...
```
## Generate bindings
Regenerate the Python and Kotlin bindings before running the examples:
```bash
just --justfile crates/goose-sdk/justfile _generate python
just --justfile crates/goose-sdk/justfile _generate kotlin
```
This writes generated bindings and the debug native library under `crates/goose-sdk/generated/`.
## Python provider example
```bash
DYLD_LIBRARY_PATH=target/debug LD_LIBRARY_PATH=target/debug \
uv run --script crates/goose-sdk/examples/uniffi/provider.py
```
## Kotlin provider example
Download JNA if it is not already present:
```bash
curl -sSL -o crates/goose-sdk/examples/uniffi/jna.jar \
https://repo1.maven.org/maven2/net/java/dev/jna/jna/5.14.0/jna-5.14.0.jar
```
Compile and run:
```bash
kotlinc -cp crates/goose-sdk/examples/uniffi/jna.jar -nowarn \
crates/goose-sdk/generated/io/aaif/goose/goose.kt \
crates/goose-sdk/examples/uniffi/Provider.kt \
-include-runtime -d crates/goose-sdk/examples/uniffi/provider.jar
java -Djna.library.path=target/debug \
--enable-native-access=ALL-UNNAMED \
-cp crates/goose-sdk/examples/uniffi/provider.jar:crates/goose-sdk/examples/uniffi/jna.jar \
aaif.example.ProviderKt
```
On Linux, use the same command; `LD_LIBRARY_PATH=target/debug` can also be set if needed. On macOS, `-Djna.library.path=target/debug` is usually enough, but `DYLD_LIBRARY_PATH=target/debug` can also be set if JNA cannot find `libgoose_sdk.dylib`.

View file

@ -1,21 +0,0 @@
"""Minimal Goose SDK demo: ping the SDK and print the pong."""
from __future__ import annotations
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent.parent / "generated"))
from aaif_goose import Client # noqa: E402
def main() -> None:
client = Client()
pong = client.ping("aaif.io")
print(pong.message)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,38 @@
#!/usr/bin/env -S uv run --script
"""Goose SDK demo: build a declarative provider and stream a completion."""
import json
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent.parent / "generated"))
from goose import ( # noqa: E402
DeclarativeProvider,
MessageRole,
ProviderMessage,
ProviderModelConfig,
)
def main() -> None:
provider = DeclarativeProvider.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?")]
stream = provider.stream(
model,
"You are a knowledgable geography expert",
messages,
)
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()

View file

@ -2,27 +2,37 @@ set shell := ["bash", "-cu"]
set working-directory := '../..'
lib_ext := if os() == "macos" { "dylib" } else if os() == "windows" { "dll" } else { "so" }
lib_dir := "./target/debug"
lib_path := lib_dir / "libgoose_sdk." + lib_ext
bindgen := "./target/debug/goose-uniffi-bindgen"
lib_prefix := if os() == "windows" { "" } else { "lib" }
lib_name := lib_prefix + "goose_sdk." + lib_ext
debug_lib_dir := "./target/debug"
release_lib_dir := "./target/release"
debug_lib_path := debug_lib_dir / lib_name
release_lib_path := release_lib_dir / lib_name
debug_bindgen := "./target/debug/goose-uniffi-bindgen"
release_bindgen := "./target/release/goose-uniffi-bindgen"
config := "./crates/goose-sdk/uniffi.toml"
gen_dir := "./crates/goose-sdk/generated"
examples_dir := "./crates/goose-sdk/examples/uniffi"
python_dir := "./crates/goose-sdk/python"
python_package_dir := python_dir / "src/goose"
default:
_default:
@just --list --justfile {{justfile()}}
_build:
cargo build -p goose-sdk --features uniffi -q
_build-release:
cargo build -p goose-sdk --features uniffi --release -q
_generate lang: _build
{{bindgen}} generate --library {{lib_path}} --config {{config}} --language {{lang}} --no-format --out-dir {{gen_dir}} 2>/dev/null
cp {{lib_path}} {{gen_dir}}/
{{debug_bindgen}} generate --library {{debug_lib_path}} --config {{config}} --language {{lang}} --no-format --out-dir {{gen_dir}} 2>/dev/null
cp {{debug_lib_path}} {{gen_dir}}/
touch {{gen_dir}}/__init__.py
python: (_generate "python")
DYLD_LIBRARY_PATH={{lib_dir}} LD_LIBRARY_PATH={{lib_dir}} \
python3 {{examples_dir}}/ping.py
DYLD_LIBRARY_PATH={{debug_lib_dir}} LD_LIBRARY_PATH={{debug_lib_dir}} \
python3 {{examples_dir}}/provider.py
kotlin: (_generate "kotlin")
@if [ ! -f {{examples_dir}}/jna.jar ]; then \
@ -30,9 +40,118 @@ kotlin: (_generate "kotlin")
https://repo1.maven.org/maven2/net/java/dev/jna/jna/5.14.0/jna-5.14.0.jar; \
fi
kotlinc -cp {{examples_dir}}/jna.jar -nowarn \
{{gen_dir}}/aaif/goose/aaif_goose.kt \
{{examples_dir}}/Ping.kt \
-include-runtime -d {{examples_dir}}/ping.jar 2>/dev/null
java -Djna.library.path={{lib_dir}} \
{{gen_dir}}/io/aaif/goose/goose.kt \
{{examples_dir}}/Provider.kt \
-include-runtime -d {{examples_dir}}/provider.jar 2>/dev/null
java -Djna.library.path={{debug_lib_dir}} \
--enable-native-access=ALL-UNNAMED \
-cp {{examples_dir}}/ping.jar:{{examples_dir}}/jna.jar aaif.example.PingKt
-cp {{examples_dir}}/provider.jar:{{examples_dir}}/jna.jar aaif.example.ProviderKt
python-bindings profile="debug":
@case "{{profile}}" in \
debug) cargo build -p goose-sdk --features uniffi -q; bindgen={{debug_bindgen}}; lib_path={{debug_lib_path}} ;; \
release) cargo build -p goose-sdk --features uniffi --release -q; bindgen={{release_bindgen}}; lib_path={{release_lib_path}} ;; \
*) echo 'profile must be debug or release' >&2; exit 1 ;; \
esac; \
rm -rf {{python_package_dir}}; \
mkdir -p {{python_package_dir}}; \
"$bindgen" generate --library "$lib_path" --config {{config}} --language python --no-format --out-dir {{python_package_dir}} 2>/dev/null; \
mv {{python_package_dir}}/goose.py {{python_package_dir}}/__init__.py; \
cp "$lib_path" {{python_package_dir}}/; \
touch {{python_package_dir}}/py.typed
python-wheel: (python-bindings "release")
rm -rf {{python_dir}}/build {{python_dir}}/dist {{python_dir}}/*.egg-info {{python_dir}}/src/*.egg-info
cd {{python_dir}} && UV_NO_CONFIG=1 PIP_CONFIG_FILE=/dev/null PIP_INDEX_URL=https://pypi.org/simple uvx --default-index https://pypi.org/simple --from build pyproject-build --wheel
python-check: python-wheel
python3 -m pip install --force-reinstall {{python_dir}}/dist/*.whl
python3 -c 'import goose; print(goose.__name__)'
python-publish repository="pypi": python-wheel
UV_NO_CONFIG=1 PIP_CONFIG_FILE=/dev/null PIP_INDEX_URL=https://pypi.org/simple uvx --default-index https://pypi.org/simple twine check {{python_dir}}/dist/*.whl
@if [ "{{repository}}" = "pypi" ]; then \
UV_NO_CONFIG=1 PIP_CONFIG_FILE=/dev/null PIP_INDEX_URL=https://pypi.org/simple uvx --default-index https://pypi.org/simple twine upload {{python_dir}}/dist/*.whl; \
else \
UV_NO_CONFIG=1 PIP_CONFIG_FILE=/dev/null PIP_INDEX_URL=https://pypi.org/simple uvx --default-index https://pypi.org/simple twine upload --repository {{repository}} {{python_dir}}/dist/*.whl; \
fi
crates-publish dry_run="true":
@set -euo pipefail; \
dry_run_flag=""; \
if [ "{{dry_run}}" = "true" ]; then \
dry_run_flag="--dry-run"; \
elif [ "{{dry_run}}" != "false" ]; then \
echo 'dry_run must be true or false' >&2; \
exit 1; \
fi; \
for crate in \
goose-provider-types \
goose-sdk-types \
goose-download-manager \
goose-local-inference \
goose-providers \
goose-sdk; do \
cargo publish -p "$crate" $dry_run_flag --allow-dirty; \
done
bump-version rust_version:
#!/usr/bin/env bash
set -euo pipefail
python3 - "{{rust_version}}" <<'PY'
import re
import sys
from pathlib import Path
rust_version = sys.argv[1]
match = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)(?:-alpha\.(\d+))?", rust_version)
if not match:
raise SystemExit("rust_version must look like 0.1.0 or 0.1.0-alpha.0")
major, minor, patch, alpha = match.groups()
python_version = f"{major}.{minor}.{patch}" if alpha is None else f"{major}.{minor}.{patch}a{alpha}"
crate_names = [
"goose-provider-types",
"goose-sdk-types",
"goose-download-manager",
"goose-local-inference",
"goose-providers",
"goose-sdk",
]
crate_paths = {name: Path("crates") / name / "Cargo.toml" for name in crate_names}
def replace_unique(path: Path, pattern: str, replacement: str) -> None:
text = path.read_text()
new_text, count = re.subn(pattern, replacement, text, count=1, flags=re.MULTILINE)
if count != 1:
raise SystemExit(f"expected one match for {pattern!r} in {path}")
path.write_text(new_text)
for path in crate_paths.values():
replace_unique(path, r'^version\s*=\s*"[^"]+"', f'version = "{rust_version}"')
dependency_names = [
"goose-provider-types",
"goose-sdk-types",
"goose-download-manager",
"goose-local-inference",
"goose-providers",
]
for path in crate_paths.values():
text = path.read_text()
for dependency in dependency_names:
text = re.sub(
rf'({re.escape(dependency)}\s*=\s*[^\n]*version\s*=\s*)"[^"]+"',
rf'\1"{rust_version}"',
text,
)
path.write_text(text)
pyproject = Path("crates/goose-sdk/python/pyproject.toml")
replace_unique(pyproject, r'^version\s*=\s*"[^"]+"', f'version = "{python_version}"')
print(f"Rust crates: {rust_version}")
print(f"Python package: {python_version}")
PY
cargo fmt --all

View file

@ -0,0 +1,15 @@
# goose-sdk
Python bindings for the Goose SDK.
This package is generated from the Rust `goose-sdk` crate using UniFFI.
## Build a local wheel
From the repository root:
```bash
just --justfile crates/goose-sdk/justfile python-wheel
```
The wheel is written to `crates/goose-sdk/python/dist/`.

View file

@ -0,0 +1,34 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "goose-sdk"
version = "0.1.0a0"
description = "Python bindings for the Goose SDK"
readme = "README.md"
requires-python = ">=3.9"
license = "Apache-2.0"
authors = [{ name = "AAIF", email = "ai-oss-tools@block.xyz" }]
keywords = ["goose", "sdk", "ai", "agent", "uniffi"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Rust",
]
[project.urls]
Homepage = "https://github.com/aaif-goose/goose"
Repository = "https://github.com/aaif-goose/goose"
Issues = "https://github.com/aaif-goose/goose/issues"
[tool.setuptools]
package-dir = { "" = "src" }
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
goose = ["*.so", "*.dylib", "*.dll", "py.typed"]

View file

@ -0,0 +1,15 @@
from setuptools import setup
from wheel.bdist_wheel import bdist_wheel
class BinaryWheel(bdist_wheel):
def finalize_options(self):
super().finalize_options()
self.root_is_pure = False
def get_tag(self):
_, _, platform_tag = super().get_tag()
return "py3", "none", platform_tag
setup(cmdclass={"bdist_wheel": BinaryWheel})

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,180 @@ 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,
#[uniffi(default = None)]
pub context_limit: Option<u64>,
#[uniffi(default = None)]
pub temperature: Option<f32>,
#[uniffi(default = None)]
pub max_tokens: Option<i32>,
#[uniffi(default = false)]
pub toolshim: bool,
#[uniffi(default = None)]
pub toolshim_model: Option<String>,
/// Provider-specific request parameters as a JSON object string.
#[uniffi(default = None)]
pub request_params_json: Option<String>,
#[uniffi(default = None)]
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,
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, &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 +203,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?");
}
}

View file

@ -5,17 +5,15 @@
//! 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};
#[cfg(feature = "uniffi")]
uniffi::setup_scaffolding!("aaif_goose");
uniffi::setup_scaffolding!("goose");
#[cfg(feature = "uniffi")]
pub mod bindings;

View file

@ -1,2 +1,2 @@
[bindings.kotlin]
package_name = "aaif.goose"
package_name = "io.aaif.goose"