From 376a9e54b6b719fbffd26f3cc59cf149939762aa Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Tue, 14 Jul 2026 12:55:57 -0400 Subject: [PATCH] add build and maven publish for kotlin ffi (#10375) --- .github/workflows/maven-sdk.yml | 173 +++++++++++++++ .gitignore | 11 + crates/goose-sdk/README.md | 17 +- crates/goose-sdk/examples/uniffi/Provider.kt | 31 --- crates/goose-sdk/examples/uniffi/README.md | 39 ++-- .../examples/uniffi/kotlin/README.md | 31 +++ .../examples/uniffi/kotlin/build.gradle.kts | 34 +++ .../uniffi/kotlin/settings.gradle.kts | 16 ++ .../uniffi/kotlin/src/main/kotlin/Main.kt | 35 +++ crates/goose-sdk/examples/uniffi/provider.py | 13 +- crates/goose-sdk/justfile | 32 ++- crates/goose-sdk/maven/README.md | 29 +++ crates/goose-sdk/maven/build.gradle.kts | 86 +++++++ crates/goose-sdk/maven/gradlew | 10 + crates/goose-sdk/maven/settings.gradle.kts | 15 ++ .../github/aaif_goose/NativeLibraryLoader.kt | 45 ++++ .../io/github/aaif_goose/ProviderFlow.kt | 15 ++ .../providers/databricks/Databricks.kt | 6 + .../aaif_goose/providers/openai/OpenAi.kt | 5 + .../scripts/maven-resource-prefix.sh | 10 + .../scripts/prepare-maven-package.sh | 57 +++++ crates/goose-sdk/src/bindings.rs | 209 +++++++++++++----- crates/goose-sdk/uniffi.toml | 2 +- 23 files changed, 796 insertions(+), 125 deletions(-) create mode 100644 .github/workflows/maven-sdk.yml delete mode 100644 crates/goose-sdk/examples/uniffi/Provider.kt create mode 100644 crates/goose-sdk/examples/uniffi/kotlin/README.md create mode 100644 crates/goose-sdk/examples/uniffi/kotlin/build.gradle.kts create mode 100644 crates/goose-sdk/examples/uniffi/kotlin/settings.gradle.kts create mode 100644 crates/goose-sdk/examples/uniffi/kotlin/src/main/kotlin/Main.kt create mode 100644 crates/goose-sdk/maven/README.md create mode 100644 crates/goose-sdk/maven/build.gradle.kts create mode 100755 crates/goose-sdk/maven/gradlew create mode 100644 crates/goose-sdk/maven/settings.gradle.kts create mode 100644 crates/goose-sdk/maven/src/support/kotlin/io/github/aaif_goose/NativeLibraryLoader.kt create mode 100644 crates/goose-sdk/maven/src/support/kotlin/io/github/aaif_goose/ProviderFlow.kt create mode 100644 crates/goose-sdk/maven/src/support/kotlin/io/github/aaif_goose/providers/databricks/Databricks.kt create mode 100644 crates/goose-sdk/maven/src/support/kotlin/io/github/aaif_goose/providers/openai/OpenAi.kt create mode 100755 crates/goose-sdk/scripts/maven-resource-prefix.sh create mode 100755 crates/goose-sdk/scripts/prepare-maven-package.sh diff --git a/.github/workflows/maven-sdk.yml b/.github/workflows/maven-sdk.yml new file mode 100644 index 000000000..f96739ef9 --- /dev/null +++ b/.github/workflows/maven-sdk.yml @@ -0,0 +1,173 @@ +name: Maven SDK + +on: + workflow_dispatch: + inputs: + publish: + description: "Publish gdk to Maven Central after building" + required: true + default: false + type: boolean + +permissions: + contents: read + +jobs: + build-native: + name: Build native library (${{ matrix.name }}) + runs-on: ${{ matrix.os }} + container: ${{ matrix.container || null }} + strategy: + fail-fast: false + matrix: + include: + - name: darwin-aarch64 + os: macos-14 + resource-prefix: darwin-aarch64 + - name: linux-x86-64 + os: ubuntu-latest + container: quay.io/pypa/manylinux_2_28_x86_64@sha256:441c35fdc6ee809ff9260894f8468ab4fea8c15dc880f8700a3f81b7922c1cda + resource-prefix: linux-x86-64 + - name: win32-x86-64 + os: windows-latest + resource-prefix: win32-x86-64 + + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - name: Set up Rust + uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1 + + - name: Cache Cargo artifacts + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + + - name: Build native library + shell: bash + run: cargo build -p goose-sdk --features uniffi --release + + - name: Stage native library + shell: bash + run: | + case "${{ runner.os }}" in + Linux) lib="target/release/libgoose_sdk.so" ;; + macOS) lib="target/release/libgoose_sdk.dylib" ;; + Windows) lib="target/release/goose_sdk.dll" ;; + *) echo "unsupported runner OS: ${{ runner.os }}" >&2; exit 1 ;; + esac + mkdir -p "maven-native/${{ matrix.resource-prefix }}" + cp "$lib" "maven-native/${{ matrix.resource-prefix }}/" + + - name: Upload native library artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: goose-sdk-native-${{ matrix.name }} + path: maven-native/** + if-no-files-found: error + + package: + name: Package Maven artifact + needs: build-native + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - name: Set up Rust + uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1 + + - name: Set up Java + uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 + with: + distribution: temurin + java-version: "17" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4 + + - name: Download native library artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + pattern: goose-sdk-native-* + path: native-artifacts + merge-multiple: true + + - name: Generate Kotlin bindings + shell: bash + run: | + cargo build -p goose-sdk --features uniffi --release + crates/goose-sdk/scripts/prepare-maven-package.sh \ + target/release/libgoose_sdk.so \ + linux-x86-64 + + - name: Restore downloaded native libraries + shell: bash + run: cp -R native-artifacts/* crates/goose-sdk/maven/src/main/resources/ + + - name: Build Maven publication + working-directory: crates/goose-sdk/maven + run: gradle --no-daemon publishToMavenLocal + + - name: Stage Maven local artifacts + shell: bash + run: | + version=$(grep -m1 '^version =' crates/goose-sdk/Cargo.toml | sed -E 's/version = "([^"]+)"/\1/') + mkdir -p maven-artifacts + cp -R "$HOME/.m2/repository/io/github/aaif-goose/gdk/$version" maven-artifacts/ + + - name: Upload Maven artifact bundle + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: goose-sdk-maven + path: maven-artifacts/** + if-no-files-found: error + + publish: + name: Publish to Maven Central + needs: package + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' && inputs.publish + environment: maven-central + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - name: Set up Rust + uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1 + + - name: Set up Java + uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 + with: + distribution: temurin + java-version: "17" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4 + + - name: Download native library artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + pattern: goose-sdk-native-* + path: native-artifacts + merge-multiple: true + + - name: Generate Kotlin bindings + shell: bash + run: | + cargo build -p goose-sdk --features uniffi --release + crates/goose-sdk/scripts/prepare-maven-package.sh \ + target/release/libgoose_sdk.so \ + linux-x86-64 + + - name: Restore downloaded native libraries + shell: bash + run: cp -R native-artifacts/* crates/goose-sdk/maven/src/main/resources/ + + - name: Publish to Maven Central + working-directory: crates/goose-sdk/maven + env: + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_TOKEN_USERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_TOKEN_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.MAVEN_SIGNING_KEY }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.MAVEN_SIGNING_PASSWORD }} + run: gradle --no-daemon publishAndReleaseToMavenCentral diff --git a/.gitignore b/.gitignore index fb5d12886..c25c7a8dc 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,14 @@ result # Goose self-test artifacts gooseselftest/ .tasks/ + +# Generated goose-sdk Maven bindings and native libraries +/crates/goose-sdk/maven/src/main/ +/crates/goose-sdk/maven/.gradle/ +/crates/goose-sdk/maven/.kotlin/ +/crates/goose-sdk/maven/build/ + +# Generated goose-sdk Kotlin example build artifacts +/crates/goose-sdk/examples/uniffi/kotlin/.gradle/ +/crates/goose-sdk/examples/uniffi/kotlin/.kotlin/ +/crates/goose-sdk/examples/uniffi/kotlin/build/ diff --git a/crates/goose-sdk/README.md b/crates/goose-sdk/README.md index e10329d35..1649f8eb5 100644 --- a/crates/goose-sdk/README.md +++ b/crates/goose-sdk/README.md @@ -4,13 +4,13 @@ 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.aaif.goose`). The UniFFI surface currently lets +Kotlin (namespace `goose` / `io.github.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/provider.py -just kotlin # build bindings + run examples/uniffi/Provider.kt +just kotlin # build the Maven artifact + run examples/uniffi/kotlin ``` ## Python package @@ -24,3 +24,16 @@ 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/`. + +## Maven package + +The Maven Central artifact is published as `io.github.aaif-goose:gdk` and uses +the Rust crate version from `crates/goose-sdk/Cargo.toml`. + +```bash +just --justfile crates/goose-sdk/justfile maven-package +``` + +This regenerates the UniFFI Kotlin bindings and packages them with the native +library in a JVM jar. CI builds the native libraries for supported platforms and +can optionally publish the combined artifact to Maven Central. diff --git a/crates/goose-sdk/examples/uniffi/Provider.kt b/crates/goose-sdk/examples/uniffi/Provider.kt deleted file mode 100644 index 6e23e2f04..000000000 --- a/crates/goose-sdk/examples/uniffi/Provider.kt +++ /dev/null @@ -1,31 +0,0 @@ -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() -} diff --git a/crates/goose-sdk/examples/uniffi/README.md b/crates/goose-sdk/examples/uniffi/README.md index 9b426e6a8..95c3fee7b 100644 --- a/crates/goose-sdk/examples/uniffi/README.md +++ b/crates/goose-sdk/examples/uniffi/README.md @@ -6,16 +6,26 @@ These examples exercise the in-process Goose SDK UniFFI bindings from Python and ```bash source bin/activate-hermit +``` + +The Python example uses the declarative DeepSeek provider: + +```bash export DEEPSEEK_API_KEY=... ``` +The Kotlin/JVM Maven smoke test uses the native OpenAI provider: + +```bash +export OPENAI_API_KEY=... +``` + ## Generate bindings -Regenerate the Python and Kotlin bindings before running the examples: +Regenerate the Python bindings before running the Python example: ```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/`. @@ -27,27 +37,22 @@ DYLD_LIBRARY_PATH=target/debug LD_LIBRARY_PATH=target/debug \ uv run --script crates/goose-sdk/examples/uniffi/provider.py ``` -## Kotlin provider example +## Kotlin/JVM Maven smoke test -Download JNA if it is not already present: +Build the local Maven artifact and run the downstream smoke test app: ```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 +just --justfile crates/goose-sdk/justfile maven-package +cd crates/goose-sdk/examples/uniffi/kotlin +gradle --no-daemon run ``` -Compile and run: +Or run the same flow through the goose-sdk justfile: ```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 +just --justfile crates/goose-sdk/justfile kotlin ``` -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`. +The Kotlin example consumes the local Maven artifact `io.github.aaif-goose:gdk` from `mavenLocal()` and imports the generated package namespace `io.github.aaif_goose`. + +On newer JDKs, the example enables native access with `--enable-native-access=ALL-UNNAMED` because the SDK uses JNA to load the bundled native library. diff --git a/crates/goose-sdk/examples/uniffi/kotlin/README.md b/crates/goose-sdk/examples/uniffi/kotlin/README.md new file mode 100644 index 000000000..7c0cca239 --- /dev/null +++ b/crates/goose-sdk/examples/uniffi/kotlin/README.md @@ -0,0 +1,31 @@ +# Kotlin/JVM GDK smoke test + +This is a small downstream Kotlin/JVM app that consumes the Maven artifact +`io.github.aaif-goose:gdk` from `mavenLocal()`. + +From the repository root, first build and publish the Maven artifact locally: + +```bash +source bin/activate-hermit +just --justfile crates/goose-sdk/justfile maven-package +``` + +Then run the smoke test: + +```bash +cd crates/goose-sdk/examples/uniffi/kotlin +gradle --no-daemon run +``` + +Set `DATABRICKS_HOST` and `DATABRICKS_TOKEN` before running the example. +`DATABRICKS_HOST` should be the Databricks workspace URL, for example +`https://dbc-xxxxxxxx-xxxx.cloud.databricks.com`. The example uses the native +GDK `DatabricksProvider`, not the declarative JSON provider. The expected output +is a streamed completion from Databricks followed by optional usage metadata. +The important failure to watch for is `UnsatisfiedLinkError` or a missing native +library resource, which would mean the bundled native library was not loaded +correctly. + +The example sets `--enable-native-access=ALL-UNNAMED` because JNA loads the +bundled Goose native library. Newer JDKs warn when native access is not enabled +explicitly, and future JDKs may require it. diff --git a/crates/goose-sdk/examples/uniffi/kotlin/build.gradle.kts b/crates/goose-sdk/examples/uniffi/kotlin/build.gradle.kts new file mode 100644 index 000000000..0a86abcab --- /dev/null +++ b/crates/goose-sdk/examples/uniffi/kotlin/build.gradle.kts @@ -0,0 +1,34 @@ +plugins { + kotlin("jvm") version "2.2.21" + application +} + +fun gooseSdkVersion(): String { + val cargoToml = file("../../../Cargo.toml").readText() + return Regex("(?m)^version\\s*=\\s*\"([^\"]+)\"") + .find(cargoToml) + ?.groupValues + ?.get(1) + ?: error("Could not find goose-sdk version in ../../../Cargo.toml") +} + +dependencies { + implementation("io.github.aaif-goose:gdk:${gooseSdkVersion()}") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } +} + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + +application { + mainClass.set("MainKt") + applicationDefaultJvmArgs = listOf("--enable-native-access=ALL-UNNAMED") +} diff --git a/crates/goose-sdk/examples/uniffi/kotlin/settings.gradle.kts b/crates/goose-sdk/examples/uniffi/kotlin/settings.gradle.kts new file mode 100644 index 000000000..b20e2e185 --- /dev/null +++ b/crates/goose-sdk/examples/uniffi/kotlin/settings.gradle.kts @@ -0,0 +1,16 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenLocal() + mavenCentral() + } +} + +rootProject.name = "goose-sdk-kotlin-smoke-test" diff --git a/crates/goose-sdk/examples/uniffi/kotlin/src/main/kotlin/Main.kt b/crates/goose-sdk/examples/uniffi/kotlin/src/main/kotlin/Main.kt new file mode 100644 index 000000000..7b2401d71 --- /dev/null +++ b/crates/goose-sdk/examples/uniffi/kotlin/src/main/kotlin/Main.kt @@ -0,0 +1,35 @@ +import io.github.aaif_goose.MessageRole +import io.github.aaif_goose.ProviderMessage +import io.github.aaif_goose.ProviderModelConfig +import io.github.aaif_goose.streamFlow +import io.github.aaif_goose.providers.openai.defaultModel +import io.github.aaif_goose.providers.openai.provider as openAiProvider +import kotlinx.coroutines.runBlocking + +fun main() = runBlocking { + val apiKey = System.getenv("OPENAI_API_KEY") + require(!apiKey.isNullOrBlank()) { + "Set OPENAI_API_KEY before running this example." + } + + val provider = openAiProvider(apiKey) + val model = ProviderModelConfig(modelName = defaultModel()) + val messages = listOf( + ProviderMessage( + role = MessageRole.USER, + text = "What is the capital of France? Answer in one sentence.", + ), + ) + + provider + .streamFlow( + model, + "You are a knowledgeable geography expert.", + messages, + ) + .collect { chunk -> + chunk.text?.let { print(it) } + chunk.usageJson?.let { println("\nusage: $it") } + } + println() +} diff --git a/crates/goose-sdk/examples/uniffi/provider.py b/crates/goose-sdk/examples/uniffi/provider.py index 0112f0b4b..87f9886b9 100755 --- a/crates/goose-sdk/examples/uniffi/provider.py +++ b/crates/goose-sdk/examples/uniffi/provider.py @@ -1,5 +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 @@ -8,24 +9,24 @@ HERE = Path(__file__).resolve().parent sys.path.insert(0, str(HERE.parent.parent / "generated")) from goose import ( # noqa: E402 - DeclarativeProvider, MessageRole, ProviderMessage, ProviderModelConfig, + declarative_provider_from_json, ) -def main() -> None: - provider = DeclarativeProvider.from_json((HERE.parent / "deepseek.json").read_text()) +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?")] - stream = provider.stream( + stream = await provider.stream( model, "You are a knowledgable geography expert", messages, ) - while chunk := stream.next(): + while chunk := await stream.next(): if chunk.text: print(chunk.text, end="") if chunk.usage_json: @@ -35,4 +36,4 @@ def main() -> None: if __name__ == "__main__": - main() + asyncio.run(main()) diff --git a/crates/goose-sdk/justfile b/crates/goose-sdk/justfile index 407b48ef0..fe49ac8d3 100644 --- a/crates/goose-sdk/justfile +++ b/crates/goose-sdk/justfile @@ -15,6 +15,9 @@ 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" +maven_dir := "./crates/goose-sdk/maven" +maven_resources_dir := maven_dir / "src/main/resources" +maven_kotlin_dir := maven_dir / "src/main/kotlin" _default: @just --list --justfile {{justfile()}} @@ -34,18 +37,8 @@ python: (_generate "python") 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 \ - curl -sSL -o {{examples_dir}}/jna.jar \ - 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}}/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}}/provider.jar:{{examples_dir}}/jna.jar aaif.example.ProviderKt +kotlin: maven-package + cd {{examples_dir}}/kotlin && gradle --no-daemon run python-bindings profile="debug": @case "{{profile}}" in \ @@ -76,6 +69,20 @@ 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 upload --repository {{repository}} {{python_dir}}/dist/*.whl; \ fi +maven-bindings profile="release": + @case "{{profile}}" in \ + debug) cargo build -p goose-sdk --features uniffi -q; lib_path={{debug_lib_path}} ;; \ + release) cargo build -p goose-sdk --features uniffi --release -q; lib_path={{release_lib_path}} ;; \ + *) echo 'profile must be debug or release' >&2; exit 1 ;; \ + esac; \ + crates/goose-sdk/scripts/prepare-maven-package.sh "$lib_path" + +maven-package: (maven-bindings "release") + cd {{maven_dir}} && ./gradlew --no-daemon publishToMavenLocal + +maven-publish: (maven-bindings "release") + cd {{maven_dir}} && ./gradlew --no-daemon publishAndReleaseToMavenCentral + crates-publish dry_run="true": @set -euo pipefail; \ dry_run_flag=""; \ @@ -153,5 +160,6 @@ bump-version rust_version: print(f"Rust crates: {rust_version}") print(f"Python package: {python_version}") + print(f"Maven artifact: {rust_version}") PY cargo fmt --all diff --git a/crates/goose-sdk/maven/README.md b/crates/goose-sdk/maven/README.md new file mode 100644 index 000000000..0f3a91312 --- /dev/null +++ b/crates/goose-sdk/maven/README.md @@ -0,0 +1,29 @@ +# Goose SDK Maven package + +This project packages the UniFFI-generated Kotlin/JVM bindings for `goose-sdk` +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. + +Build locally from the repository root: + +```bash +just --justfile crates/goose-sdk/justfile maven-package +``` + +Publish to Maven Central from the repository root: + +```bash +just --justfile crates/goose-sdk/justfile maven-publish +``` + +Publishing requires the standard Gradle properties used by +`com.vanniktech.maven.publish` for Maven Central credentials and in-memory PGP +signing, for example via environment variables: + +- `ORG_GRADLE_PROJECT_mavenCentralUsername` +- `ORG_GRADLE_PROJECT_mavenCentralPassword` +- `ORG_GRADLE_PROJECT_signingInMemoryKey` +- `ORG_GRADLE_PROJECT_signingInMemoryKeyPassword` diff --git a/crates/goose-sdk/maven/build.gradle.kts b/crates/goose-sdk/maven/build.gradle.kts new file mode 100644 index 000000000..97448236f --- /dev/null +++ b/crates/goose-sdk/maven/build.gradle.kts @@ -0,0 +1,86 @@ +plugins { + kotlin("jvm") version "2.2.21" + `java-library` + id("com.vanniktech.maven.publish") version "0.34.0" +} + +group = "io.github.aaif-goose" +version = gooseSdkVersion() + +fun gooseSdkVersion(): String { + val cargoToml = file("../Cargo.toml").readText() + return Regex("(?m)^version\\s*=\\s*\"([^\"]+)\"") + .find(cargoToml) + ?.groupValues + ?.get(1) + ?: error("Could not find goose-sdk version in ../Cargo.toml") +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } +} + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + withSourcesJar() +} + +dependencies { + api("net.java.dev.jna:jna:5.14.0") + api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") +} + +tasks.jar { + manifest { + attributes( + "Implementation-Title" to "Goose SDK", + "Implementation-Version" to project.version, + ) + } +} + +tasks.withType().configureEach { + dependsOn(tasks.named("plainJavadocJar")) +} + +mavenPublishing { + publishToMavenCentral(automaticRelease = true) + if (providers.gradleProperty("signingInMemoryKey").isPresent) { + signAllPublications() + } + + coordinates( + groupId = "io.github.aaif-goose", + artifactId = "gdk", + version = project.version.toString(), + ) + + pom { + name.set("Goose GDK") + description.set("Kotlin/JVM bindings for the Goose SDK") + inceptionYear.set("2026") + url.set("https://github.com/aaif-goose/goose") + licenses { + license { + name.set("Apache License, Version 2.0") + url.set("https://www.apache.org/licenses/LICENSE-2.0") + distribution.set("repo") + } + } + developers { + developer { + id.set("aaif") + name.set("Agentic AI Foundation") + email.set("ai-oss-tools@block.xyz") + } + } + scm { + connection.set("scm:git:https://github.com/aaif-goose/goose.git") + developerConnection.set("scm:git:ssh://git@github.com/aaif-goose/goose.git") + url.set("https://github.com/aaif-goose/goose") + } + } +} diff --git a/crates/goose-sdk/maven/gradlew b/crates/goose-sdk/maven/gradlew new file mode 100755 index 000000000..c42a357e4 --- /dev/null +++ b/crates/goose-sdk/maven/gradlew @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +if command -v gradle >/dev/null 2>&1; then + exec gradle "$@" +fi + +echo "gradle is required to build the goose-sdk Maven package" >&2 +echo "Install gradle or run inside the Hermit environment." >&2 +exit 1 diff --git a/crates/goose-sdk/maven/settings.gradle.kts b/crates/goose-sdk/maven/settings.gradle.kts new file mode 100644 index 000000000..e1289af9b --- /dev/null +++ b/crates/goose-sdk/maven/settings.gradle.kts @@ -0,0 +1,15 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenCentral() + } +} + +rootProject.name = "goose-sdk-maven" diff --git a/crates/goose-sdk/maven/src/support/kotlin/io/github/aaif_goose/NativeLibraryLoader.kt b/crates/goose-sdk/maven/src/support/kotlin/io/github/aaif_goose/NativeLibraryLoader.kt new file mode 100644 index 000000000..a4958f655 --- /dev/null +++ b/crates/goose-sdk/maven/src/support/kotlin/io/github/aaif_goose/NativeLibraryLoader.kt @@ -0,0 +1,45 @@ +package io.github.aaif_goose + +import com.sun.jna.Platform +import java.nio.file.Files + +internal object NativeLibraryLoader { + init { + val componentName = "goose" + if (System.getProperty("uniffi.component.$componentName.libraryOverride") == null) { + val resource = nativeResourcePath() + val stream = NativeLibraryLoader::class.java.classLoader.getResourceAsStream(resource) + ?: error("Goose SDK native library resource not found: $resource") + val library = Files.createTempFile("goose-sdk-", nativeLibraryFileName()).toFile() + library.deleteOnExit() + stream.use { input -> library.outputStream().use { output -> input.copyTo(output) } } + System.setProperty("uniffi.component.$componentName.libraryOverride", library.absolutePath) + } + } + + fun ensureLoaded() = Unit + + private fun nativeResourcePath(): String = "${jnaResourcePrefix()}/${nativeLibraryFileName()}" + + private fun nativeLibraryFileName(): String = when (osName()) { + "darwin" -> "libgoose_sdk.dylib" + "linux" -> "libgoose_sdk.so" + "win32" -> "goose_sdk.dll" + else -> error("Unsupported OS: ${System.getProperty("os.name")}") + } + + private fun jnaResourcePrefix(): String = "${osName()}-${archName()}" + + private fun osName(): String = when { + System.getProperty("os.name").startsWith("Mac OS X") -> "darwin" + System.getProperty("os.name").startsWith("Linux") -> "linux" + System.getProperty("os.name").startsWith("Windows") -> "win32" + else -> System.getProperty("os.name").lowercase().replace(Regex("\\s+"), "-") + } + + private fun archName(): String = when { + Platform.isARM() && Platform.is64Bit() -> "aarch64" + Platform.isIntel() && Platform.is64Bit() -> "x86-64" + else -> System.getProperty("os.arch") + } +} diff --git a/crates/goose-sdk/maven/src/support/kotlin/io/github/aaif_goose/ProviderFlow.kt b/crates/goose-sdk/maven/src/support/kotlin/io/github/aaif_goose/ProviderFlow.kt new file mode 100644 index 000000000..d52b61929 --- /dev/null +++ b/crates/goose-sdk/maven/src/support/kotlin/io/github/aaif_goose/ProviderFlow.kt @@ -0,0 +1,15 @@ +package io.github.aaif_goose + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +public fun Provider.streamFlow( + model: ProviderModelConfig, + system: String, + messages: List, +): Flow = flow { + val stream = stream(model, system, messages) + while (true) { + emit(stream.next() ?: break) + } +} diff --git a/crates/goose-sdk/maven/src/support/kotlin/io/github/aaif_goose/providers/databricks/Databricks.kt b/crates/goose-sdk/maven/src/support/kotlin/io/github/aaif_goose/providers/databricks/Databricks.kt new file mode 100644 index 000000000..1fe49dc4e --- /dev/null +++ b/crates/goose-sdk/maven/src/support/kotlin/io/github/aaif_goose/providers/databricks/Databricks.kt @@ -0,0 +1,6 @@ +package io.github.aaif_goose.providers.databricks + +public fun provider(host: String, token: String): io.github.aaif_goose.Provider = + io.github.aaif_goose.databricksProvider(host, token) + +public fun defaultModel(): String = io.github.aaif_goose.databricksDefaultModel() diff --git a/crates/goose-sdk/maven/src/support/kotlin/io/github/aaif_goose/providers/openai/OpenAi.kt b/crates/goose-sdk/maven/src/support/kotlin/io/github/aaif_goose/providers/openai/OpenAi.kt new file mode 100644 index 000000000..9a52638fb --- /dev/null +++ b/crates/goose-sdk/maven/src/support/kotlin/io/github/aaif_goose/providers/openai/OpenAi.kt @@ -0,0 +1,5 @@ +package io.github.aaif_goose.providers.openai + +public fun provider(apiKey: String): io.github.aaif_goose.Provider = io.github.aaif_goose.openaiProvider(apiKey) + +public fun defaultModel(): String = io.github.aaif_goose.openaiDefaultModel() diff --git a/crates/goose-sdk/scripts/maven-resource-prefix.sh b/crates/goose-sdk/scripts/maven-resource-prefix.sh new file mode 100755 index 000000000..fa8ece254 --- /dev/null +++ b/crates/goose-sdk/scripts/maven-resource-prefix.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +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" ;; + 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 diff --git a/crates/goose-sdk/scripts/prepare-maven-package.sh b/crates/goose-sdk/scripts/prepare-maven-package.sh new file mode 100755 index 000000000..f99f316a2 --- /dev/null +++ b/crates/goose-sdk/scripts/prepare-maven-package.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -lt 1 ]; then + echo "usage: $0 [jna-resource-prefix]" >&2 + exit 1 +fi + +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) ;; + *) echo "unsupported JNA resource prefix: $resource_prefix" >&2; exit 1 ;; +esac + +if [ ! -f "$native_lib" ]; then + echo "native library not found: $native_lib" >&2 + exit 1 +fi + +bindgen="target/release/goose-uniffi-bindgen" +if [ ! -x "$bindgen" ]; then + cargo build -p goose-sdk --features uniffi --release -q +fi + +maven_dir="crates/goose-sdk/maven" +kotlin_dir="$maven_dir/src/main/kotlin" +support_kotlin_dir="$maven_dir/src/support/kotlin" +resources_dir="$maven_dir/src/main/resources" + +rm -rf "$kotlin_dir/io/github/aaif_goose" "$resources_dir/$resource_prefix" +mkdir -p "$kotlin_dir" "$resources_dir/$resource_prefix" "$resources_dir/META-INF" +cp LICENSE "$resources_dir/META-INF/LICENSE" + +"$bindgen" generate \ + --library "$native_lib" \ + --config crates/goose-sdk/uniffi.toml \ + --language kotlin \ + --no-format \ + --out-dir "$kotlin_dir" 2>/dev/null + +python3 - "$kotlin_dir/io/github/aaif_goose/goose.kt" <<'PY' +import sys +from pathlib import Path + +path = Path(sys.argv[1]) +text = path.read_text() +needle = 'private fun findLibraryName(componentName: String): String {\n' +replacement = needle + ' NativeLibraryLoader.ensureLoaded()\n' +if needle not in text: + raise SystemExit('could not find findLibraryName in generated Kotlin bindings') +path.write_text(text.replace(needle, replacement, 1)) +PY + +cp -R "$support_kotlin_dir"/. "$kotlin_dir"/ +cp "$native_lib" "$resources_dir/$resource_prefix/" diff --git a/crates/goose-sdk/src/bindings.rs b/crates/goose-sdk/src/bindings.rs index b44dd6601..f6728344d 100644 --- a/crates/goose-sdk/src/bindings.rs +++ b/crates/goose-sdk/src/bindings.rs @@ -4,14 +4,18 @@ //! on declarative providers: consumers can construct a provider from JSON and //! stream completions from it. -use std::sync::{Arc, Mutex}; +use std::{future::Future, sync::Arc, sync::OnceLock}; use futures::StreamExt; use goose_providers::{ - base::{MessageStream, Provider}, + api_client::{ApiClient, AuthMethod}, + base::{MessageStream, Provider as GooseProvider}, conversation::message::Message, + databricks::DatabricksProvider as GooseDatabricksProvider, + databricks_auth::DatabricksAuth, declarative::EnvKeyResolver, model::ModelConfig, + openai::OpenAiProviderBuilder, }; /// Errors surfaced across the uniffi boundary. @@ -113,88 +117,181 @@ pub struct ProviderStreamChunk { pub usage_json: Option, } -/// A declarative Goose provider constructed from provider JSON. -#[derive(uniffi::Object)] -pub struct DeclarativeProvider { - provider: Box, - runtime: Arc, -} +static RUNTIME: OnceLock = OnceLock::new(); -#[uniffi::export] -impl DeclarativeProvider { - /// Construct a declarative provider using the process environment to resolve - /// configured API key environment variables. - #[uniffi::constructor] - pub fn from_json(json: String) -> Result, 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), - })) +fn runtime() -> Result<&'static tokio::runtime::Runtime, GooseError> { + if let Some(runtime) = RUNTIME.get() { + return Ok(runtime); } - pub fn name(&self) -> String { + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|error| GooseError::Generic(error.to_string()))?; + + let _ = RUNTIME.set(runtime); + Ok(RUNTIME.get().expect("runtime was initialized")) +} + +async fn run_on_runtime( + future: impl Future + Send + 'static, +) -> Result +where + T: Send + 'static, +{ + let (sender, receiver) = tokio::sync::oneshot::channel(); + runtime()?.spawn(async move { + let _ = sender.send(future.await); + }); + + receiver + .await + .map_err(|_| GooseError::Generic("runtime task was cancelled".to_string())) +} + +struct ProviderHandle { + provider: Arc, +} + +impl ProviderHandle { + fn new(provider: Box) -> Self { + Self { + provider: Arc::from(provider), + } + } + + 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( + async fn stream( &self, model: ProviderModelConfig, system: String, messages: Vec, - ) -> Result, GooseError> { + ) -> Result, GooseError> { let model = model.to_goose_model_config()?; let messages = messages .iter() .map(ProviderMessage::to_goose_message) .collect::>(); + let provider = Arc::clone(&self.provider); let stream = - self.runtime - .block_on(self.provider.stream(&model, &system, &messages, &[]))?; + run_on_runtime(async move { provider.stream(&model, &system, &messages, &[]).await }) + .await??; - Ok(Arc::new(DeclarativeProviderStream { - stream: Mutex::new(stream), - runtime: Arc::clone(&self.runtime), + Ok(Arc::new(ProviderStream { + stream: Arc::new(tokio::sync::Mutex::new(stream)), })) } } -/// A blocking iterator over provider stream chunks. +/// A Goose provider backed by one of Goose's native provider implementations. #[derive(uniffi::Object)] -pub struct DeclarativeProviderStream { - stream: Mutex, - runtime: Arc, +pub struct Provider { + handle: ProviderHandle, +} + +impl Provider { + fn new(provider: Box) -> Arc { + Arc::new(Self { + handle: ProviderHandle::new(provider), + }) + } } #[uniffi::export] -impl DeclarativeProviderStream { +impl Provider { + pub fn name(&self) -> String { + self.handle.name() + } + + /// Start a streaming completion request. Tools are not yet exposed over the + /// uniffi boundary, so this calls providers with an empty tool list. + pub async fn stream( + &self, + model: ProviderModelConfig, + system: String, + messages: Vec, + ) -> Result, GooseError> { + self.handle.stream(model, system, messages).await + } +} + +#[uniffi::export] +pub fn declarative_provider_from_json(json: String) -> Result, GooseError> { + let provider = goose_providers::declarative::from_json(&json, None, EnvKeyResolver {})?; + Ok(Provider::new(provider)) +} + +#[uniffi::export] +pub fn openai_default_model() -> String { + goose_providers::openai::OPEN_AI_DEFAULT_MODEL.to_string() +} + +#[uniffi::export] +pub fn openai_provider(api_key: String) -> Result, GooseError> { + let api_client = ApiClient::new_with_tls( + "https://api.openai.com".to_string(), + AuthMethod::BearerToken(api_key), + None, + )?; + let provider = OpenAiProviderBuilder::new(api_client).build(); + Ok(Provider::new(Box::new(provider))) +} + +#[uniffi::export] +pub fn databricks_default_model() -> String { + goose_providers::databricks::DATABRICKS_DEFAULT_MODEL.to_string() +} + +#[uniffi::export] +pub fn databricks_provider(host: String, token: String) -> Result, GooseError> { + let retry_config = GooseDatabricksProvider::load_retry_config(|key| std::env::var(key).ok()); + let provider = GooseDatabricksProvider::new( + host, + DatabricksAuth::token(token), + retry_config, + None, + None, + None, + None, + None, + None, + None, + )?; + + Ok(Provider::new(Box::new(provider))) +} + +/// An async iterator over provider stream chunks. +#[derive(uniffi::Object)] +pub struct ProviderStream { + stream: Arc>, +} + +#[uniffi::export] +impl ProviderStream { /// Return the next stream chunk, or `None` when the stream is exhausted. - pub fn next(&self) -> Result, GooseError> { - let mut stream = self - .stream - .lock() - .map_err(|_| GooseError::Generic("provider stream lock poisoned".to_string()))?; + pub async fn next(&self) -> Result, GooseError> { + let stream = Arc::clone(&self.stream); + run_on_runtime(async move { + let mut stream = stream.lock().await; + let Some((message, usage)) = stream.next().await.transpose()? else { + return Ok(None); + }; - 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()?; - 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, - })) + Ok(Some(ProviderStreamChunk { + text, + message_json, + usage_json, + })) + }) + .await? } } diff --git a/crates/goose-sdk/uniffi.toml b/crates/goose-sdk/uniffi.toml index aef1f592c..ceb625960 100644 --- a/crates/goose-sdk/uniffi.toml +++ b/crates/goose-sdk/uniffi.toml @@ -1,2 +1,2 @@ [bindings.kotlin] -package_name = "io.aaif.goose" +package_name = "io.github.aaif_goose"