mirror of
https://github.com/block/goose.git
synced 2026-07-09 16:09:22 +00:00
add provider bindings MVP to goose-sdk, and add python wheel publishing (#10208)
This commit is contained in:
parent
782f69f175
commit
140158c756
23 changed files with 727 additions and 113 deletions
118
.github/workflows/python-sdk-wheels.yml
vendored
Normal file
118
.github/workflows/python-sdk-wheels.yml
vendored
Normal 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
16
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
4
crates/goose-sdk/.gitignore
vendored
4
crates/goose-sdk/.gitignore
vendored
|
|
@ -1,2 +1,6 @@
|
|||
generated
|
||||
examples/uniffi/*.jar
|
||||
python/build/
|
||||
python/dist/
|
||||
python/src/*.egg-info/
|
||||
python/src/goose/
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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/`.
|
||||
|
|
|
|||
30
crates/goose-sdk/examples/deepseek.json
Normal file
30
crates/goose-sdk/examples/deepseek.json
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
31
crates/goose-sdk/examples/uniffi/Provider.kt
Normal file
31
crates/goose-sdk/examples/uniffi/Provider.kt
Normal 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()
|
||||
}
|
||||
53
crates/goose-sdk/examples/uniffi/README.md
Normal file
53
crates/goose-sdk/examples/uniffi/README.md
Normal 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`.
|
||||
|
|
@ -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()
|
||||
38
crates/goose-sdk/examples/uniffi/provider.py
Executable file
38
crates/goose-sdk/examples/uniffi/provider.py
Executable 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
15
crates/goose-sdk/python/README.md
Normal file
15
crates/goose-sdk/python/README.md
Normal 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/`.
|
||||
34
crates/goose-sdk/python/pyproject.toml
Normal file
34
crates/goose-sdk/python/pyproject.toml
Normal 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"]
|
||||
15
crates/goose-sdk/python/setup.py
Normal file
15
crates/goose-sdk/python/setup.py
Normal 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})
|
||||
|
|
@ -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?");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
[bindings.kotlin]
|
||||
package_name = "aaif.goose"
|
||||
package_name = "io.aaif.goose"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue