From 8e4f7e6103f3bb2dfaa2eb680d032df24a97d205 Mon Sep 17 00:00:00 2001 From: Nadeesha Cabral Date: Fri, 25 Oct 2024 13:45:19 +1100 Subject: [PATCH 01/12] -> sdk-node --- .eslintrc => sdk-node/.eslintrc | 0 .gitignore => sdk-node/.gitignore | 0 .prettierrc => sdk-node/.prettierrc | 0 CHANGELOG.md => sdk-node/CHANGELOG.md | 0 Makefile => sdk-node/Makefile | 0 {assets => sdk-node/assets}/logo.png | Bin babel.config.js => sdk-node/babel.config.js | 0 package-lock.json => sdk-node/package-lock.json | 0 package.json => sdk-node/package.json | 0 {src => sdk-node/src}/Inferable.test.ts | 0 {src => sdk-node/src}/Inferable.ts | 0 {src => sdk-node/src}/contract.ts | 0 {src => sdk-node/src}/create-client.ts | 0 {src => sdk-node/src}/errors.ts | 0 {src => sdk-node/src}/eval/promptfoo.ts | 0 {src => sdk-node/src}/execute-fn.test.ts | 0 {src => sdk-node/src}/execute-fn.ts | 0 {src => sdk-node/src}/index.ts | 0 {src => sdk-node/src}/links.ts | 0 {src => sdk-node/src}/machine-id.ts | 0 {src => sdk-node/src}/serialize-error.js | 0 {src => sdk-node/src}/service.ts | 0 {src => sdk-node/src}/tests/errors/animals.ts | 0 {src => sdk-node/src}/tests/errors/errors.test.ts | 0 {src => sdk-node/src}/tests/utility/caching.test.ts | 0 {src => sdk-node/src}/tests/utility/product.ts | 0 {src => sdk-node/src}/tests/utility/retry.test.ts | 0 {src => sdk-node/src}/tests/utils.ts | 0 {src => sdk-node/src}/types.ts | 0 {src => sdk-node/src}/util.test.ts | 0 {src => sdk-node/src}/util.ts | 0 tsconfig.json => sdk-node/tsconfig.json | 0 32 files changed, 0 insertions(+), 0 deletions(-) rename .eslintrc => sdk-node/.eslintrc (100%) rename .gitignore => sdk-node/.gitignore (100%) rename .prettierrc => sdk-node/.prettierrc (100%) rename CHANGELOG.md => sdk-node/CHANGELOG.md (100%) rename Makefile => sdk-node/Makefile (100%) rename {assets => sdk-node/assets}/logo.png (100%) rename babel.config.js => sdk-node/babel.config.js (100%) rename package-lock.json => sdk-node/package-lock.json (100%) rename package.json => sdk-node/package.json (100%) rename {src => sdk-node/src}/Inferable.test.ts (100%) rename {src => sdk-node/src}/Inferable.ts (100%) rename {src => sdk-node/src}/contract.ts (100%) rename {src => sdk-node/src}/create-client.ts (100%) rename {src => sdk-node/src}/errors.ts (100%) rename {src => sdk-node/src}/eval/promptfoo.ts (100%) rename {src => sdk-node/src}/execute-fn.test.ts (100%) rename {src => sdk-node/src}/execute-fn.ts (100%) rename {src => sdk-node/src}/index.ts (100%) rename {src => sdk-node/src}/links.ts (100%) rename {src => sdk-node/src}/machine-id.ts (100%) rename {src => sdk-node/src}/serialize-error.js (100%) rename {src => sdk-node/src}/service.ts (100%) rename {src => sdk-node/src}/tests/errors/animals.ts (100%) rename {src => sdk-node/src}/tests/errors/errors.test.ts (100%) rename {src => sdk-node/src}/tests/utility/caching.test.ts (100%) rename {src => sdk-node/src}/tests/utility/product.ts (100%) rename {src => sdk-node/src}/tests/utility/retry.test.ts (100%) rename {src => sdk-node/src}/tests/utils.ts (100%) rename {src => sdk-node/src}/types.ts (100%) rename {src => sdk-node/src}/util.test.ts (100%) rename {src => sdk-node/src}/util.ts (100%) rename tsconfig.json => sdk-node/tsconfig.json (100%) diff --git a/.eslintrc b/sdk-node/.eslintrc similarity index 100% rename from .eslintrc rename to sdk-node/.eslintrc diff --git a/.gitignore b/sdk-node/.gitignore similarity index 100% rename from .gitignore rename to sdk-node/.gitignore diff --git a/.prettierrc b/sdk-node/.prettierrc similarity index 100% rename from .prettierrc rename to sdk-node/.prettierrc diff --git a/CHANGELOG.md b/sdk-node/CHANGELOG.md similarity index 100% rename from CHANGELOG.md rename to sdk-node/CHANGELOG.md diff --git a/Makefile b/sdk-node/Makefile similarity index 100% rename from Makefile rename to sdk-node/Makefile diff --git a/assets/logo.png b/sdk-node/assets/logo.png similarity index 100% rename from assets/logo.png rename to sdk-node/assets/logo.png diff --git a/babel.config.js b/sdk-node/babel.config.js similarity index 100% rename from babel.config.js rename to sdk-node/babel.config.js diff --git a/package-lock.json b/sdk-node/package-lock.json similarity index 100% rename from package-lock.json rename to sdk-node/package-lock.json diff --git a/package.json b/sdk-node/package.json similarity index 100% rename from package.json rename to sdk-node/package.json diff --git a/src/Inferable.test.ts b/sdk-node/src/Inferable.test.ts similarity index 100% rename from src/Inferable.test.ts rename to sdk-node/src/Inferable.test.ts diff --git a/src/Inferable.ts b/sdk-node/src/Inferable.ts similarity index 100% rename from src/Inferable.ts rename to sdk-node/src/Inferable.ts diff --git a/src/contract.ts b/sdk-node/src/contract.ts similarity index 100% rename from src/contract.ts rename to sdk-node/src/contract.ts diff --git a/src/create-client.ts b/sdk-node/src/create-client.ts similarity index 100% rename from src/create-client.ts rename to sdk-node/src/create-client.ts diff --git a/src/errors.ts b/sdk-node/src/errors.ts similarity index 100% rename from src/errors.ts rename to sdk-node/src/errors.ts diff --git a/src/eval/promptfoo.ts b/sdk-node/src/eval/promptfoo.ts similarity index 100% rename from src/eval/promptfoo.ts rename to sdk-node/src/eval/promptfoo.ts diff --git a/src/execute-fn.test.ts b/sdk-node/src/execute-fn.test.ts similarity index 100% rename from src/execute-fn.test.ts rename to sdk-node/src/execute-fn.test.ts diff --git a/src/execute-fn.ts b/sdk-node/src/execute-fn.ts similarity index 100% rename from src/execute-fn.ts rename to sdk-node/src/execute-fn.ts diff --git a/src/index.ts b/sdk-node/src/index.ts similarity index 100% rename from src/index.ts rename to sdk-node/src/index.ts diff --git a/src/links.ts b/sdk-node/src/links.ts similarity index 100% rename from src/links.ts rename to sdk-node/src/links.ts diff --git a/src/machine-id.ts b/sdk-node/src/machine-id.ts similarity index 100% rename from src/machine-id.ts rename to sdk-node/src/machine-id.ts diff --git a/src/serialize-error.js b/sdk-node/src/serialize-error.js similarity index 100% rename from src/serialize-error.js rename to sdk-node/src/serialize-error.js diff --git a/src/service.ts b/sdk-node/src/service.ts similarity index 100% rename from src/service.ts rename to sdk-node/src/service.ts diff --git a/src/tests/errors/animals.ts b/sdk-node/src/tests/errors/animals.ts similarity index 100% rename from src/tests/errors/animals.ts rename to sdk-node/src/tests/errors/animals.ts diff --git a/src/tests/errors/errors.test.ts b/sdk-node/src/tests/errors/errors.test.ts similarity index 100% rename from src/tests/errors/errors.test.ts rename to sdk-node/src/tests/errors/errors.test.ts diff --git a/src/tests/utility/caching.test.ts b/sdk-node/src/tests/utility/caching.test.ts similarity index 100% rename from src/tests/utility/caching.test.ts rename to sdk-node/src/tests/utility/caching.test.ts diff --git a/src/tests/utility/product.ts b/sdk-node/src/tests/utility/product.ts similarity index 100% rename from src/tests/utility/product.ts rename to sdk-node/src/tests/utility/product.ts diff --git a/src/tests/utility/retry.test.ts b/sdk-node/src/tests/utility/retry.test.ts similarity index 100% rename from src/tests/utility/retry.test.ts rename to sdk-node/src/tests/utility/retry.test.ts diff --git a/src/tests/utils.ts b/sdk-node/src/tests/utils.ts similarity index 100% rename from src/tests/utils.ts rename to sdk-node/src/tests/utils.ts diff --git a/src/types.ts b/sdk-node/src/types.ts similarity index 100% rename from src/types.ts rename to sdk-node/src/types.ts diff --git a/src/util.test.ts b/sdk-node/src/util.test.ts similarity index 100% rename from src/util.test.ts rename to sdk-node/src/util.test.ts diff --git a/src/util.ts b/sdk-node/src/util.ts similarity index 100% rename from src/util.ts rename to sdk-node/src/util.ts diff --git a/tsconfig.json b/sdk-node/tsconfig.json similarity index 100% rename from tsconfig.json rename to sdk-node/tsconfig.json From de021871dfbac55b3124f1734f0a09dd162eb39a Mon Sep 17 00:00:00 2001 From: Nadeesha Cabral Date: Fri, 25 Oct 2024 14:04:34 +1100 Subject: [PATCH 02/12] Unification scripts --- grand-unifier.sh | 23 + sdk-bash/LICENSE | 21 + sdk-bash/README.md | 249 +++++++++ sdk-bash/handler.sh | 14 + sdk-bash/inferable.sh | 247 +++++++++ sdk-bash/test.sh | 39 ++ sdk-dotnet/.github/workflows/build.yaml | 34 ++ sdk-dotnet/.github/workflows/publish.yaml | 75 +++ sdk-dotnet/.gitignore | 484 ++++++++++++++++++ sdk-dotnet/Inferable.sln | 33 ++ sdk-dotnet/LICENSE | 21 + sdk-dotnet/README.md | 93 ++++ sdk-dotnet/src/API/APIClient.cs | 106 ++++ sdk-dotnet/src/API/Models.cs | 115 +++++ sdk-dotnet/src/API/SerializableException.cs | 23 + sdk-dotnet/src/Function.cs | 79 +++ sdk-dotnet/src/Inferable.cs | 165 ++++++ sdk-dotnet/src/Inferable.csproj | 22 + sdk-dotnet/src/IunctionRegistration.cs | 0 sdk-dotnet/src/Machine.cs | 20 + sdk-dotnet/src/Service.cs | 159 ++++++ .../tests/Inferable.Tests/GlobalUsings.cs | 1 + .../Inferable.Tests/Inferable.Tests.csproj | 32 ++ .../tests/Inferable.Tests/InferableTest.cs | 230 +++++++++ sdk-go/.github/workflows/build.yml | 36 ++ sdk-go/.github/workflows/publish.yml | 57 +++ sdk-go/.gitignore | 72 +++ sdk-go/LICENSE | 21 + sdk-go/README.md | 164 ++++++ sdk-go/go.mod | 19 + sdk-go/go.sum | 23 + sdk-go/inferable.go | 353 +++++++++++++ sdk-go/inferable_test.go | 230 +++++++++ sdk-go/internal/client/client.go | 116 +++++ sdk-go/internal/util/test_util.go | 34 ++ sdk-go/internal/util/util.go | 43 ++ sdk-go/main_test.go | 146 ++++++ sdk-go/service.go | 395 ++++++++++++++ sdk-go/service_test.go | 290 +++++++++++ sdk-node/.husky/pre-commit | 2 + sdk-node/README.md | 119 +++++ workflows/.github/workflows/publish.yml | 45 ++ workflows/.github/workflows/test.yml | 42 ++ 43 files changed, 4492 insertions(+) create mode 100644 grand-unifier.sh create mode 100644 sdk-bash/LICENSE create mode 100644 sdk-bash/README.md create mode 100755 sdk-bash/handler.sh create mode 100644 sdk-bash/inferable.sh create mode 100644 sdk-bash/test.sh create mode 100644 sdk-dotnet/.github/workflows/build.yaml create mode 100644 sdk-dotnet/.github/workflows/publish.yaml create mode 100644 sdk-dotnet/.gitignore create mode 100644 sdk-dotnet/Inferable.sln create mode 100644 sdk-dotnet/LICENSE create mode 100644 sdk-dotnet/README.md create mode 100644 sdk-dotnet/src/API/APIClient.cs create mode 100644 sdk-dotnet/src/API/Models.cs create mode 100644 sdk-dotnet/src/API/SerializableException.cs create mode 100644 sdk-dotnet/src/Function.cs create mode 100644 sdk-dotnet/src/Inferable.cs create mode 100644 sdk-dotnet/src/Inferable.csproj create mode 100644 sdk-dotnet/src/IunctionRegistration.cs create mode 100644 sdk-dotnet/src/Machine.cs create mode 100644 sdk-dotnet/src/Service.cs create mode 100644 sdk-dotnet/tests/Inferable.Tests/GlobalUsings.cs create mode 100644 sdk-dotnet/tests/Inferable.Tests/Inferable.Tests.csproj create mode 100644 sdk-dotnet/tests/Inferable.Tests/InferableTest.cs create mode 100644 sdk-go/.github/workflows/build.yml create mode 100644 sdk-go/.github/workflows/publish.yml create mode 100644 sdk-go/.gitignore create mode 100644 sdk-go/LICENSE create mode 100644 sdk-go/README.md create mode 100644 sdk-go/go.mod create mode 100644 sdk-go/go.sum create mode 100644 sdk-go/inferable.go create mode 100644 sdk-go/inferable_test.go create mode 100644 sdk-go/internal/client/client.go create mode 100644 sdk-go/internal/util/test_util.go create mode 100644 sdk-go/internal/util/util.go create mode 100644 sdk-go/main_test.go create mode 100644 sdk-go/service.go create mode 100644 sdk-go/service_test.go create mode 100644 sdk-node/.husky/pre-commit create mode 100644 sdk-node/README.md create mode 100644 workflows/.github/workflows/publish.yml create mode 100644 workflows/.github/workflows/test.yml diff --git a/grand-unifier.sh b/grand-unifier.sh new file mode 100644 index 00000000..777f553a --- /dev/null +++ b/grand-unifier.sh @@ -0,0 +1,23 @@ +rm -rf sdk-node +git clone git@github.com:inferablehq/inferable-node.git sdk-node +rm -rf sdk-node/.git +mv sdk-node/.github workflows/ +rm -rf sdk-node/.editorconfig + +rm -rf sdk-go +git clone git@github.com:inferablehq/inferable-go.git sdk-go +rm -rf sdk-go/.git +mv sdk-go/.github workflows/ +rm -rf sdk-go/.editorconfig + +rm -rf sdk-bash +git clone git@github.com:inferablehq/inferable-bash.git sdk-bash +rm -rf sdk-bash/.git +mv sdk-bash/.github workflows/ +rm -rf sdk-bash/.editorconfig + +rm -rf sdk-dotnet +git clone git@github.com:inferablehq/inferable-dotnet.git sdk-dotnet +rm -rf sdk-dotnet/.git +mv sdk-dotnet/.github workflows/ +rm -rf sdk-dotnet/.editorconfig diff --git a/sdk-bash/LICENSE b/sdk-bash/LICENSE new file mode 100644 index 00000000..2afc091d --- /dev/null +++ b/sdk-bash/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 inferablehq + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/sdk-bash/README.md b/sdk-bash/README.md new file mode 100644 index 00000000..485fe1b0 --- /dev/null +++ b/sdk-bash/README.md @@ -0,0 +1,249 @@ +# Inferable Bash SDK + +> **Note**: This is a demonstration project to show that "all we need is HTTP" for an Inferable integration. We don't recommend creating bash-based AI clients for production use. + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Documentation](https://img.shields.io/badge/docs-inferable.ai-brightgreen)](https://docs.inferable.ai/) + +A lightweight Bash SDK for interacting with the Inferable API. This SDK allows you to register and run Inferable functions directly from shell scripts. + +## Installation + +1. Download the SDK: + +```bash +curl -O https://raw.githubusercontent.com/inferablehq/inferable-bash/main/inferable.sh +chmod +x inferable.sh +``` + +2. Set up your environment: + +```bash +export INFERABLE_API_SECRET="your-api-secret" # Required +export INFERABLE_API_ENDPOINT="https://api.inferable.ai" # Optional, defaults to https://api.inferable.ai +export INFERABLE_MACHINE_ID="custom-machine-id" # Optional, auto-generated if not provided +``` + +## Quick Start + +Here's a minimal example to get you started with the Inferable Bash SDK: + +```bash +#!/bin/bash + +# Source the SDK +source ./inferable.sh + +# Initialize the SDK +inferable_init || exit 1 + +# Create a handler file (handler.sh) +cat > handler.sh << 'EOF' +#!/bin/bash + +function_name=$1 +input=$2 + +case "$function_name" in + "greet") + name=$(echo "$input" | jq -r '.name') + echo "{\"message\": \"Hello, $name!\"}" + ;; + *) + echo "{\"error\": \"Unknown function\"}" + ;; +esac +EOF + +chmod +x handler.sh + +# Define your functions +FUNCTIONS='[ + { + "name": "greet", + "description": "Greet a user", + "schema": { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "required": ["name"] + } + } +]' + +# Register the service +CLUSTER_ID=$(register_service "greeting-service" "$FUNCTIONS") + +# Start polling with your handler +start_service "$CLUSTER_ID" "greeting-service" "./handler.sh" +``` + +## Dependencies + +- `bash` (version 4.0 or later) +- `curl` for making HTTP requests +- `jq` for JSON processing + +## API Reference + +### Core Functions + +#### `inferable_init` + +Initializes the SDK and validates the connection to Inferable. + +```bash +inferable_init || exit 1 +``` + +#### `register_service` + +Registers a new service with Inferable. + +```bash +register_service +``` + +Parameters: + +- `service_name`: Name of your service +- `functions_json`: JSON array of function definitions + +Returns: Cluster ID on success + +#### `start_service` + +Starts the service and begins polling for jobs. + +```bash +start_service [poll_interval] +``` + +Parameters: + +- `cluster_id`: ID returned from register_service +- `service_name`: Name of your service +- `handler_script`: Path to your handler script +- `poll_interval`: Optional polling interval in seconds (default: 10) + +### Utility Functions + +#### `generate_machine_id` + +Generates a unique machine identifier. + +```bash +machine_id=$(generate_machine_id ) +``` + +#### `make_request` + +Makes an HTTP request to the Inferable API. + +```bash +make_request [body] +``` + +## Handler Script + +Your handler script should accept two arguments: + +1. Function name +2. JSON input data + +Example handler: + +```bash +#!/bin/bash + +function_name=$1 +input=$2 + +case "$function_name" in + "myFunction") + # Process the input + value=$(echo "$input" | jq -r '.someField') + + # Return JSON result + echo "{\"result\": \"Processed $value\"}" + ;; + *) + echo "{\"error\": \"Unknown function\"}" + ;; +esac +``` + +## Environment Variables + +| Variable | Required | Default | Description | +| ------------------------ | -------- | -------------------------- | ---------------------------------- | +| `INFERABLE_API_SECRET` | Yes | - | Your Inferable API secret | +| `INFERABLE_API_ENDPOINT` | No | `https://api.inferable.ai` | Inferable API endpoint | +| `INFERABLE_MACHINE_ID` | No | Auto-generated | Unique identifier for this machine | + +## Error Handling + +The SDK includes basic error handling. Functions will return non-zero exit codes on failure. We recommend wrapping critical operations in error checks: + +```bash +if ! inferable_init; then + echo "Failed to initialize SDK" >&2 + exit 1 +fi + +CLUSTER_ID=$(register_service "my-service" "$FUNCTIONS") +if [ -z "$CLUSTER_ID" ]; then + echo "Failed to register service" >&2 + exit 1 +fi +``` + +## Best Practices + +1. Always source the SDK rather than executing it: + +```bash +source ./inferable.sh # Correct +./inferable.sh # Incorrect +``` + +2. Validate the initialization: + +```bash +inferable_init || exit 1 +``` + +3. Use error handling in your handler scripts: + +```bash +#!/bin/bash + +function_name=$1 +input=$2 + +if [ -z "$input" ]; then + echo "{\"error\": \"No input provided\"}" + exit 1 +fi + +# Process function... +``` + +4. Set reasonable polling intervals based on your needs: + +```bash +# More frequent polling (5 seconds) +start_service "$CLUSTER_ID" "my-service" "./handler.sh" 5 + +# Less frequent polling (30 seconds) +start_service "$CLUSTER_ID" "my-service" "./handler.sh" 30 +``` + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. diff --git a/sdk-bash/handler.sh b/sdk-bash/handler.sh new file mode 100755 index 00000000..eebc5302 --- /dev/null +++ b/sdk-bash/handler.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +function_name=$1 +input=$2 + +case "$function_name" in + "greet") + name=$(echo "$input" | jq -r '.name') + echo "{\"message\": \"Hello, $name!\"}" + ;; + *) + echo "{\"error\": \"Unknown function\"}" + ;; +esac diff --git a/sdk-bash/inferable.sh b/sdk-bash/inferable.sh new file mode 100644 index 00000000..d1424389 --- /dev/null +++ b/sdk-bash/inferable.sh @@ -0,0 +1,247 @@ +#!/bin/bash + +# Inferable Bash SDK +# Version: 0.1.0 + +# Check for INFERABLE_API_SECRET immediately +if [ -z "$INFERABLE_API_SECRET" ]; then + echo "Error: INFERABLE_API_SECRET environment variable is required" >&2 + exit 1 +fi + +# Default configuration +INFERABLE_API_ENDPOINT="${INFERABLE_API_ENDPOINT:-https://api.inferable.ai}" +INFERABLE_SDK_VERSION="0.1.0" +INFERABLE_SDK_LANGUAGE="bash" +PING_INTERVAL=60 # Ping every 60 seconds +MAX_CONSECUTIVE_FAILURES=50 + +# Generate a unique machine ID if not provided +generate_machine_id() { + local length=$1 + local charset="abcdefghijklmnopqrstuvwxyz" + local machine_id="bash-" + + # Get system info for seed + local hostname=$(hostname) + local os_info=$(uname -a) + local seed=$(($(echo "$hostname$os_info" | cksum | cut -d' ' -f1) % 1000000)) + + # Generate random string + for ((i=0; i&2 + return 1 + fi + + return 0 +} + +# Start ping loop in background +start_ping_loop() { + local cluster_id=$1 + local failure_count=0 + + while true; do + if ! ping_server "$cluster_id"; then + ((failure_count++)) + if [ $failure_count -gt $MAX_CONSECUTIVE_FAILURES ]; then + echo "Error: Too many consecutive ping failures" >&2 + exit 1 + fi + else + failure_count=0 + fi + + sleep $PING_INTERVAL + done +} + +# Initialize the SDK +inferable_init() { + # Validate required environment variables + if [ -z "$INFERABLE_API_SECRET" ]; then + echo "Error: INFERABLE_API_SECRET environment variable is required" >&2 + exit 1 + fi + + # Generate machine ID if not set + INFERABLE_MACHINE_ID=${INFERABLE_MACHINE_ID:-$(generate_machine_id 8)} + + # Test connection + local response=$(make_request "GET" "/live") + if ! echo "$response" | jq -e '.status == "ok"' > /dev/null; then + echo "Error: Failed to connect to Inferable API" >&2 + return 1 + fi + + return 0 +} + +# Register a service +register_service() { + local service_name=$1 + local functions_json=$2 + + # Ensure functions_json is a valid JSON string + if ! echo "$functions_json" | jq empty 2>/dev/null; then + echo "Error: Invalid JSON for functions" >&2 + return 1 + fi + + local payload="{ + \"service\": \"$service_name\", + \"functions\": $functions_json + }" + + local response=$(make_request "POST" "/machines" "$payload") + + local cluster_id=$(echo "$response" | jq -r '.clusterId') + + if [ -z "$cluster_id" ] || [ "$cluster_id" = "null" ]; then + echo "$response" >&2 + echo "Error: Failed to register service" >&2 + return 1 + fi + + echo "$cluster_id" + return 0 +} + +# Poll for jobs +poll_jobs() { + local cluster_id=$1 + local service_name=$2 + + local response=$(make_request "GET" "/clusters/$cluster_id/calls?acknowledge=true&service=$service_name&status=pending&limit=10") + echo "$response" +} + +# Submit job result +submit_result() { + local cluster_id=$1 + local call_id=$2 + local result=$3 + local result_type=${4:-"resolution"} + local execution_time=$5 + + local meta="{}" + if [ -n "$execution_time" ]; then + meta="{\"functionExecutionTime\": $execution_time}" + fi + + local payload="{ + \"result\": $result, + \"resultType\": \"$result_type\", + \"meta\": $meta + }" + + make_request "POST" "/clusters/$cluster_id/calls/$call_id/result" "$payload" +} + +# Start service polling loop +start_service() { + local cluster_id=$1 + local service_name=$2 + local handler_script=$3 + local poll_interval=${4:-10} + + echo "Starting service '$service_name' polling..." + + # Start ping loop in background + start_ping_loop "$cluster_id" & + local ping_pid=$! + + # Trap to kill ping loop on exit + trap "kill $ping_pid 2>/dev/null" EXIT + + # Main polling loop + while true; do + local jobs=$(poll_jobs "$cluster_id" "$service_name") + + # Check if jobs is valid JSON + if ! echo "$jobs" | jq empty 2>/dev/null; then + echo "Error: Invalid response from poll_jobs" >&2 + sleep "$poll_interval" + continue + fi + + echo "$jobs" | jq -c '.[]' | while read -r job; do + # Validate job structure + if ! echo "$job" | jq -e '.id and .function and .input' > /dev/null 2>&1; then + echo "Error: Invalid job structure" >&2 + continue + fi + + local call_id=$(echo "$job" | jq -r '.id') + local function_name=$(echo "$job" | jq -r '.function') + local input=$(echo "$job" | jq -r '.input') + + # Execute handler script with job details + local start_time=$(date +%s) + local result=$($handler_script "$function_name" "$input") + local end_time=$(date +%s) + local execution_time=$((end_time - start_time)) + + # Submit result + submit_result "$cluster_id" "$call_id" "$result" "resolution" "$execution_time" + done + + sleep "$poll_interval" + done +} + +# Example usage: +if [ "${BASH_SOURCE[0]}" = "${0}" ]; then + echo "This script should be sourced, not executed directly." + echo "Usage: source inferable.sh" + exit 1 +fi diff --git a/sdk-bash/test.sh b/sdk-bash/test.sh new file mode 100644 index 00000000..a055013f --- /dev/null +++ b/sdk-bash/test.sh @@ -0,0 +1,39 @@ +INFERABLE_API_SECRET="sk_cluster_machine_151qeOFp251eH9v0MxRTYDnxOt2wexZQLmIXU8Vb8" + +source ./inferable.sh + +# Initialize the SDK +inferable_init || exit 1 + +# Create a handler file (handler.sh) +cat > handler.sh << 'EOF' +#!/bin/bash + +function_name=$1 +input=$2 + +case "$function_name" in + "greet") + name=$(echo "$input" | jq -r '.name') + echo "{\"message\": \"Hello, $name!\"}" + ;; + *) + echo "{\"error\": \"Unknown function\"}" + ;; +esac +EOF + +# Define your functions +FUNCTIONS='[ + { + "name": "greet", + "description": "Greet a user", + "schema": "{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"}},\"required\":[\"name\"]}" + } +]' + +# Register the service +CLUSTER_ID=$(register_service "greetingService" "$FUNCTIONS") + +# Start polling with your handler +start_service "$CLUSTER_ID" "greetingService" "./handler.sh" diff --git a/sdk-dotnet/.github/workflows/build.yaml b/sdk-dotnet/.github/workflows/build.yaml new file mode 100644 index 00000000..299e3db6 --- /dev/null +++ b/sdk-dotnet/.github/workflows/build.yaml @@ -0,0 +1,34 @@ +name: Build and Test .NET Project + +on: + pull_request: + branches: + - main + +jobs: + build: + runs-on: windows-latest + + env: + INFERABLE_API_ENDPOINT: 'https://api.inferable.ai' + INFERABLE_CLUSTER_ID: ${{ vars.INFERABLE_CLUSTER_ID }} + INFERABLE_MACHINE_SECRET: ${{ secrets.INFERABLE_MACHINE_SECRET }} + INFERABLE_CONSUME_SECRET: ${{ secrets.INFERABLE_CONSUME_SECRET }} + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Setup .NET + uses: actions/setup-dotnet@v2 + with: + dotnet-version: '8.0' + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --configuration Release --no-restore + + - name: Test + run: dotnet test --no-restore diff --git a/sdk-dotnet/.github/workflows/publish.yaml b/sdk-dotnet/.github/workflows/publish.yaml new file mode 100644 index 00000000..2518ab97 --- /dev/null +++ b/sdk-dotnet/.github/workflows/publish.yaml @@ -0,0 +1,75 @@ +name: Build and Publish .NET Project + +on: + push: + branches: + - main + +jobs: + publish: + runs-on: windows-latest + + env: + INFERABLE_API_ENDPOINT: 'https://api.inferable.ai' + INFERABLE_CLUSTER_ID: ${{ vars.INFERABLE_CLUSTER_ID }} + INFERABLE_MACHINE_SECRET: ${{ secrets.INFERABLE_MACHINE_SECRET }} + INFERABLE_CONSUME_SECRET: ${{ secrets.INFERABLE_CONSUME_SECRET }} + + permissions: + contents: write + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Setup .NET + uses: actions/setup-dotnet@v2 + with: + dotnet-version: '8.0' + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --configuration Release --no-restore + + - name: Test + run: dotnet test --no-restore + + - name: Pack + run: dotnet pack --configuration Release --no-restore --output ./output + + - name: Setup NuGet + uses: nuget/setup-nuget@v1 + with: + nuget-api-key: ${{ secrets.NUGET_API_KEY }} + nuget-version: latest + + - name: Publish + if: github.ref == 'refs/heads/main' + run: | + dotnet nuget push output\*.nupkg -s https://api.nuget.org/v3/index.json + + - name: Extract version from NuGet package + id: extract_version + shell: pwsh + run: | + # Get the first .nupkg file in the output directory + $nupkg = Get-ChildItem -Path ./output -Filter *.nupkg | Select-Object -First 1 + + # Extract the version from the filename + if ($nupkg) { + $version = $nupkg.Name -replace '^[a-zA-Z0-9.-]+\.([0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+)?)\.nupkg$', '$1' + Write-Host "Extracted version: $version" + echo "::set-output name=version::$version" + } else { + Write-Error "No .nupkg file found." + } + + - name: Create release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ steps.extract_version.outputs.version }} + release_name: ${{ steps.extract_version.outputs.version }} diff --git a/sdk-dotnet/.gitignore b/sdk-dotnet/.gitignore new file mode 100644 index 00000000..104b5441 --- /dev/null +++ b/sdk-dotnet/.gitignore @@ -0,0 +1,484 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from `dotnet new gitignore` + +# dotenv files +.env + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# Mac bundle stuff +*.dmg +*.app + +# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp diff --git a/sdk-dotnet/Inferable.sln b/sdk-dotnet/Inferable.sln new file mode 100644 index 00000000..38014b64 --- /dev/null +++ b/sdk-dotnet/Inferable.sln @@ -0,0 +1,33 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Inferable", "src\Inferable.csproj", "{499B888C-EAB2-4DB8-AC24-5F7CA338E9E9}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{F255EE36-6482-46E4-B791-236CC3CDC49D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Inferable.Tests", "tests\Inferable.Tests\Inferable.Tests.csproj", "{15B3556B-12FE-4452-8FE1-4738802E1E5D}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {499B888C-EAB2-4DB8-AC24-5F7CA338E9E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {499B888C-EAB2-4DB8-AC24-5F7CA338E9E9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {499B888C-EAB2-4DB8-AC24-5F7CA338E9E9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {499B888C-EAB2-4DB8-AC24-5F7CA338E9E9}.Release|Any CPU.Build.0 = Release|Any CPU + {15B3556B-12FE-4452-8FE1-4738802E1E5D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {15B3556B-12FE-4452-8FE1-4738802E1E5D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {15B3556B-12FE-4452-8FE1-4738802E1E5D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {15B3556B-12FE-4452-8FE1-4738802E1E5D}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {15B3556B-12FE-4452-8FE1-4738802E1E5D} = {F255EE36-6482-46E4-B791-236CC3CDC49D} + EndGlobalSection +EndGlobal diff --git a/sdk-dotnet/LICENSE b/sdk-dotnet/LICENSE new file mode 100644 index 00000000..8aa26455 --- /dev/null +++ b/sdk-dotnet/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) [year] [fullname] + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/sdk-dotnet/README.md b/sdk-dotnet/README.md new file mode 100644 index 00000000..c702b2fa --- /dev/null +++ b/sdk-dotnet/README.md @@ -0,0 +1,93 @@ +

+ +

+ +# .NET SDK for Inferable + +[![NuGet version](https://img.shields.io/nuget/v/Inferable.svg)](https://www.nuget.org/packages/Inferable/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Documentation](https://img.shields.io/badge/docs-inferable.ai-brightgreen)](https://docs.inferable.ai/) + +The **Inferable .NET Client** is a .NET library that allows you to interact with the Inferable API. This library provides functionality to register your .NET functions, manage services, and handle API communication easily. + +## Installation + +To install the Inferable .NET Client, use the following command in your project: + +```bash +dotnet add package Inferable +``` + +## Usage + +### Initializing the Client + +To create a new Inferable client, use the `InferableClient` class: + +```csharp +using Inferable; + +var options = new InferableOptions +{ + ApiSecret = "your-api-secret", // Replace with your API secret + BaseUrl = "https://api.inferable.ai" // Optional, uses default if not provided +}; + +var client = new InferableClient(options); +``` + +If you don't provide an API key or base URL, it will attempt to read them from the following environment variables: + +- `INFERABLE_API_SECRET` +- `INFERABLE_API_ENDPOINT` + +### Registering a Function + +To register a function with the Inferable API, you can use the following: + +```csharp +public class MyInput +{ + public string Message { get; set; } +} + +client.Default.RegisterFunction(new FunctionRegistration +{ + Function = new Func>((input) => { + // Your code here + }), + Name = "MyFunction", + Description = "A simple greeting function", +}); + +await client.Default.Start(); +``` + +### Starting and Stopping a Service + +The example above used the Default service, you can also register separate named services. + +```csharp +var userService = client.RegisterService(new ServiceRegistration +{ + Name = "UserService", +}); + +userService.RegisterFunction(....) + +await userService.Start(); +``` + +To stop the service, use: + +```csharp +await userService.StopAsync(); +``` + +## Contributing + +Contributions to the Inferable .NET Client are welcome. Please ensure that your code adheres to the existing style and includes appropriate tests. + +## Support + +For support or questions, please [create an issue in the repository](https://github.com/inferablehq/inferable-dotnet/issues). diff --git a/sdk-dotnet/src/API/APIClient.cs b/sdk-dotnet/src/API/APIClient.cs new file mode 100644 index 00000000..03a68d3b --- /dev/null +++ b/sdk-dotnet/src/API/APIClient.cs @@ -0,0 +1,106 @@ +using System.Net.Http.Headers; +using System.Reflection; +using System.Text; +using System.Text.Json; + +namespace Inferable.API +{ + public class ApiClientOptions + { + public required string BaseUrl { get; set; } + public required string ApiSecret { get; set; } + public required string MachineId { get; set; } + } + + public class ApiClient + { + + private readonly HttpClient _client; + + public ApiClient(ApiClientOptions options) + { + this._client = new HttpClient(); + + var version = Assembly.GetAssembly(typeof(InferableClient))? + .GetName()? + .Version? + .ToString() ?? throw new Exception("Failed to get Inferable SDK version"); + + _client.BaseAddress = new Uri(options.BaseUrl); + + _client.DefaultRequestHeaders.Add("x-machine-id", options.MachineId); + _client.DefaultRequestHeaders.Add("x-machine-sdk-version", version); + _client.DefaultRequestHeaders.Add("x-machine-sdk-language", "cs"); + + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("bearer", options.ApiSecret); + } + + + async public Task CreateMachine(CreateMachineInput input) + { + string jsonData = JsonSerializer.Serialize(input); + + HttpResponseMessage response = await _client.PostAsync( + "/machines", + new StringContent(jsonData, Encoding.UTF8, "application/json") + ); + + response.EnsureSuccessStatusCode(); + + string responseBody = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(responseBody); + } + + async public Task CreateCallResult(string clusterId, string callId, CreateResultInput input) + { + string jsonData = JsonSerializer.Serialize(input); + + HttpResponseMessage response = await _client.PostAsync( + $"/clusters/{clusterId}/calls/{callId}/result", + new StringContent(jsonData, Encoding.UTF8, "application/json") + ); + + response.EnsureSuccessStatusCode(); + } + + async public Task<(List, int?)> ListCalls(string clusterId, string service) + { + HttpResponseMessage response = await _client.GetAsync( + $"/clusters/{clusterId}/calls?service={service}&acknowledge=true" + ); + + response.EnsureSuccessStatusCode(); + + string responseBody = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize>(responseBody) ?? new List(); + + var retryAfterHeader = response.Headers.RetryAfter?.ToString() ?? ""; + + try + { + int.Parse(retryAfterHeader); + } + catch + { + return (result, null); + } + + return (result, int.Parse(retryAfterHeader)); + } + + async public Task CreateCall(string clusterId, CreateCallInput input) + { + string jsonData = JsonSerializer.Serialize(input); + + HttpResponseMessage response = await _client.PostAsync( + $"/clusters/{clusterId}/calls?waitTime=20", + new StringContent(jsonData, Encoding.UTF8, "application/json") + ); + + response.EnsureSuccessStatusCode(); + + string responseBody = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(responseBody); + } + } +} diff --git a/sdk-dotnet/src/API/Models.cs b/sdk-dotnet/src/API/Models.cs new file mode 100644 index 00000000..9256ea8c --- /dev/null +++ b/sdk-dotnet/src/API/Models.cs @@ -0,0 +1,115 @@ +using System.Text.Json.Serialization; + +namespace Inferable.API +{ + public struct CreateMachineInput + { + [JsonPropertyName("service")] + public required string Service { get; set; } + [JsonPropertyName("functions")] + public required List Functions { get; set; } + } + + public struct CreateMachineResult + { + [JsonPropertyName("clusterId")] + public required string ClusterId { get; set; } + } + + public struct CallMessage + { + [JsonPropertyName("id")] + public required string Id { get; set; } + [JsonPropertyName("function")] + public required string Function { get; set; } + [JsonPropertyName("input")] + public object Input { get; set; } + } + + public struct CreateResultMeta { + [ + JsonPropertyName("functionExecutionTime"), + JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull) + ] + public int? FunctionExecutionTime { get; set; } + } + + public struct CreateResultInput + { + [JsonPropertyName("result")] + public object? Result { get; set; } + [JsonPropertyName("resultType")] + public required string ResultType { get; set; } + [JsonPropertyName("meta")] + public CreateResultMeta Meta { get; set; } + } + + public struct CreateCallInput + { + [JsonPropertyName("service")] + public required string Service { get; set; } + + [JsonPropertyName("function")] + public required string Function { get; set; } + + [JsonPropertyName("input")] + public required object Input { get; set; } + } + + public struct CreateCallResult + { + // TODO Make enum + [JsonPropertyName("resultType")] + public required string? ResultType { get; set; } + + [JsonPropertyName("result")] + public object? Result { get; set; } + + // TODO: Make enum + [JsonPropertyName("status")] + public required string Status { get; set; } + } + + public struct Function + { + [JsonPropertyName("name")] + public string? Name { get; set; } + + [ + JsonPropertyName("description"), + JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull) + ] + public string? Description { get; set; } + + [JsonPropertyName("schema")] + public required string Schema { get; set; } + + [ + JsonPropertyName("config"), + JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull) + ] + public FunctionConfig? Config { get; set; } + } + + public struct FunctionConfig + { + [ + JsonPropertyName("requiresApproval"), + JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull) + ] + public bool? RequiresApproval { get; set; } + + [ + JsonPropertyName("retryCountOnStall"), + JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull) + ] + public int? RetryCountOnStall { get; set; } + + [ + JsonPropertyName("timeoutSeconds"), + JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull) + ] + public int? TimeoutSeconds { get; set; } + } + +} diff --git a/sdk-dotnet/src/API/SerializableException.cs b/sdk-dotnet/src/API/SerializableException.cs new file mode 100644 index 00000000..f309e41b --- /dev/null +++ b/sdk-dotnet/src/API/SerializableException.cs @@ -0,0 +1,23 @@ +namespace Inferable.API +{ + public class SerializableException + { + public string? Message { get; set; } + public string? StackTrace { get; set; } + public string? Source { get; set; } + public string Type { get; set; } + public SerializableException? InnerException { get; set; } + + public SerializableException(Exception ex) + { + Message = ex.Message; + StackTrace = ex.StackTrace; + Source = ex.Source; + Type = ex.GetType().FullName ?? "Exception"; + if (ex.InnerException != null) + { + InnerException = new SerializableException(ex.InnerException); + } + } + } +} diff --git a/sdk-dotnet/src/Function.cs b/sdk-dotnet/src/Function.cs new file mode 100644 index 00000000..3991082e --- /dev/null +++ b/sdk-dotnet/src/Function.cs @@ -0,0 +1,79 @@ +using System.Text.Json; +using Inferable.API; +using NJsonSchema; + +namespace Inferable +{ + internal interface IFunctionRegistration + { + string Name { get; set; } + string? Description { get; set; } + FunctionConfig? Config { get; set; } + JsonSchema Schema { get; } + CreateResultInput Invoke(object rawInput); + } + + public class FunctionRegistration : IFunctionRegistration where T : struct + { + public required string Name { get; set; } + + public string? Description { get; set; } + + public FunctionConfig? Config { get; set; } + + public required Func Func { get; set; } + + public JsonSchema Schema => JsonSchema.FromType(typeof(T)) ?? throw new Exception("Could not generate JsonSchema"); + + public CreateResultInput Invoke(object rawInput) + { + + var inputJson = JsonSerializer.Serialize(rawInput); + var start = DateTime.Now; + + T input; + try + { + input = JsonSerializer.Deserialize(inputJson); + } + catch (Exception e) + { + return new CreateResultInput + { + ResultType = "rejection", + Result = JsonSerializer.Serialize(new SerializableException(e)), + Meta = new CreateResultMeta() + }; + } + + try + { + var result = this.Func(input); + var functionExecutionTime = (int)(DateTime.Now - start).TotalMilliseconds; + + return new CreateResultInput + { + ResultType = "resolution", + Result = result, + Meta = new CreateResultMeta { + FunctionExecutionTime = functionExecutionTime + } + }; + } + catch (Exception e) + { + var functionExecutionTime = (int)(DateTime.Now - start).TotalMilliseconds; + return new CreateResultInput + { + ResultType = "rejection", + Result = JsonSerializer.Serialize(new SerializableException(e)), + Meta = new CreateResultMeta { + FunctionExecutionTime = functionExecutionTime + } + }; + } + } + } +} + + diff --git a/sdk-dotnet/src/Inferable.cs b/sdk-dotnet/src/Inferable.cs new file mode 100644 index 00000000..193b6699 --- /dev/null +++ b/sdk-dotnet/src/Inferable.cs @@ -0,0 +1,165 @@ +using Inferable.API; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Inferable +{ + public class InferableOptions + { + public string? BaseUrl { get; set; } + public string? ApiSecret { get; set; } + /// + /// PingInterval in seconds + /// + public int? PingInterval { get; set; } + public string? MachineId { get; set; } + } + + + public class InferableClient + { + public static string DefaultBaseUrl = "https://api.inferable.ai/"; + + private readonly ApiClient _client; + private readonly ILogger _logger; + + // Dictionary of service name to list of functions + private Dictionary> _functionRegistry = new Dictionary>(); + + private List _services = new List(); + + public RegisteredService Default + { + get + { + return this.RegisterService("default"); + } + } + + public InferableClient(InferableOptions? options = null, ILogger? logger = null) + { + string? apiSecret = options?.ApiSecret ?? Environment.GetEnvironmentVariable("INFERABLE_API_SECRET"); + string baseUrl = options?.BaseUrl ?? Environment.GetEnvironmentVariable("INFERABLE_API_ENDPOINT") ?? DefaultBaseUrl; + string machineId = options?.MachineId ?? Machine.GenerateMachineId(); + + if (apiSecret == null) + { + throw new ArgumentNullException(nameof(options.ApiSecret), "APIKey cannot be null."); + } + + if (!apiSecret.StartsWith("sk_cluster_machine")) + { + if (apiSecret.StartsWith("sk_")) + { + throw new ArgumentException($"Provided non-Machine API Secret. Please see"); + } + + throw new ArgumentException($"Invalid API Secret. Please see"); + } + + + this._client = new ApiClient(new ApiClientOptions{ + ApiSecret = apiSecret, + BaseUrl = baseUrl, + MachineId = machineId + }); + + this._logger = logger ?? NullLogger.Instance; + } + + public RegisteredService RegisterService(string name) + { + return new RegisteredService(name, this); + } + + public IEnumerable ActiveServices + { + get + { + return this._services.Where(s => s.Polling).Select(s => s.Name); + } + } + + public IEnumerable InactiveServices + { + get + { + return this._services.Where(s => !s.Polling).Select(s => s.Name); + } + } + + public IEnumerable RegisteredFunctions + { + get + { + return this._functionRegistry.SelectMany(f => f.Value.Select(v => v.Name)); + + } + } + + internal void RegisterFunction(string serviceName, FunctionRegistration function) where T : struct { + var existing = this.RegisteredFunctions.FirstOrDefault(f => f == function.Name); + if (existing != null) { + throw new Exception($"Function with name '{function.Name}' already registered"); + } + + if (!this._functionRegistry.ContainsKey(serviceName)) { + this._functionRegistry.Add(serviceName, new List { function }); + } else { + this._functionRegistry[serviceName].Add(function); + } + + } + + internal async Task StartService(string name) { + var existing = this._services.FirstOrDefault(s => s.Name == name); + if (existing != null) { + throw new Exception("Service is already started"); + } + + if (!this._functionRegistry.ContainsKey(name)) { + throw new Exception($"No functions registered with for service '{name}'"); + } + + var functions = this._functionRegistry[name]; + + var service = new Service(name, this._client, this._logger, functions); + + this._services.Add(service); + await service.Start(); + } + + internal async Task StopService(string name) { + var existing = this._services.FirstOrDefault(s => s.Name == name); + if (existing == null) { + throw new Exception("Service is not started"); + } + await existing.Stop(); + } + } + + public struct RegisteredService + { + private string _name; + private InferableClient _inferable; + + internal RegisteredService(string name, InferableClient inferable) { + this._name = name; + this._inferable = inferable; + } + + public void RegisterFunction(FunctionRegistration function) where T : struct { + this._inferable.RegisterFunction(this._name, function); + } + + async public Task Start() { + await this._inferable.StartService(this._name); + } + + async public Task Stop() { + await this._inferable.StopService(this._name); + } + } + + +} diff --git a/sdk-dotnet/src/Inferable.csproj b/sdk-dotnet/src/Inferable.csproj new file mode 100644 index 00000000..e0388483 --- /dev/null +++ b/sdk-dotnet/src/Inferable.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + + Inferable + 0.0.8 + Inferable + Inferable + Client library for interacting with the Inferable API + MIT + https://inferable.ai + https://github.com/inferablehq/inferable-dotnet + + + + + + + diff --git a/sdk-dotnet/src/IunctionRegistration.cs b/sdk-dotnet/src/IunctionRegistration.cs new file mode 100644 index 00000000..e69de29b diff --git a/sdk-dotnet/src/Machine.cs b/sdk-dotnet/src/Machine.cs new file mode 100644 index 00000000..3966fd97 --- /dev/null +++ b/sdk-dotnet/src/Machine.cs @@ -0,0 +1,20 @@ +namespace Inferable +{ + class Machine + { + public static string GenerateMachineId() + { + return $"cs-{GetRandomString(8)}"; + } + + private static string GetRandomString(int length) + { + const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + var random = new Random(); + + return new string(Enumerable.Repeat(chars, length) + .Select(s => s[random.Next(s.Length)]) + .ToArray()); + } + } +} diff --git a/sdk-dotnet/src/Service.cs b/sdk-dotnet/src/Service.cs new file mode 100644 index 00000000..9c8cbcbd --- /dev/null +++ b/sdk-dotnet/src/Service.cs @@ -0,0 +1,159 @@ +using Inferable.API; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Inferable +{ + /// + /// Internal class for managing the lifecycle of a service, polling, etc + /// + internal class Service + { + static int MAX_CONSECUTIVE_POLL_FAILURES = 50; + static int DEFAULT_RETRY_AFTER_SECONDS = 10; + + private string _name; + private string? _clusterId; + private bool _polling = false; + + private int _retryAfter = DEFAULT_RETRY_AFTER_SECONDS; + + private ApiClient _client; + private ILogger _logger; + + private List _functions = new List(); + + internal Service(string name, ApiClient client, ILogger? logger, List functions) + { + this._name = name; + this._functions = functions; + + this._client = client; + this._logger = logger ?? NullLogger.Instance; + } + + internal string Name + { + get + { + return this._name; + } + } + + internal bool Polling + { + get + { + return this._polling; + } + } + + async internal Task Start() + { + this._logger.LogDebug("Starting service '{name}'", this._name); + this._clusterId = await RegisterMachine(); + + // Purposely not awaiting + _ = this.runLoop(); + + return this._clusterId; + } + + async internal Task Stop() + { + this._logger.LogDebug("Stopping service '{name}'", this._name); + this._polling = false; + await Task.FromResult(0); + } + + async private Task runLoop() { + this._polling = true; + var failureCount = 0; + + while (this._polling && failureCount < Service.MAX_CONSECUTIVE_POLL_FAILURES) { + try { + await this.pollIteration(); + } catch (Exception e) { + this._logger.LogError(e, "Failed poll iteration"); + failureCount++; + } + + await Task.Delay(1000 * this._retryAfter); + } + + this._polling = false; + this._logger.LogError("Quiting polling service '{name}'", this._name); + } + async private Task pollIteration() + { + if (this._clusterId == null) { + throw new Exception("Failed to poll. Could not find clusterId"); + } + + List messages = new List(); + + try { + var pollResult = await _client.ListCalls(this._clusterId, this._name); + + messages = pollResult.Item1; + if (pollResult.Item2 != null) { + this._retryAfter = pollResult.Item2.Value; + } + } catch (HttpRequestException e) { + if (e.StatusCode == System.Net.HttpStatusCode.Gone) { + await this.RegisterMachine(); + } + + throw; + } + + foreach (var call in messages) + { + var function = this._functions.FirstOrDefault(f => f.Name == call.Function); + if (function == null) + { + this._logger.LogWarning("Received message for unknown function {TargetFn}", call.Function); + continue; + } + + var result = function.Invoke(call.Input); + + try + { + await this._client.CreateCallResult(this._clusterId, call.Id, result); + } + catch (Exception e) + { + this._logger.LogError(e, "Failed to create result for job {CallId}", call.Id); + } + } + + _logger.LogDebug($"Polling service {this._name}"); + } + + async private Task RegisterMachine() + { + this._logger.LogDebug("Registering machine"); + var functions = new List(); + + foreach (var function in this._functions) + { + + functions.Add(new Function + { + Name = function.Name, + Config = function.Config, + Description = function.Description, + Schema = function.Schema.ToJson() + }); + }; + + var registerResult = await _client.CreateMachine(new CreateMachineInput { + Service = this._name, + Functions = functions + }); + + return registerResult.ClusterId; + } + } +} diff --git a/sdk-dotnet/tests/Inferable.Tests/GlobalUsings.cs b/sdk-dotnet/tests/Inferable.Tests/GlobalUsings.cs new file mode 100644 index 00000000..c802f448 --- /dev/null +++ b/sdk-dotnet/tests/Inferable.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/sdk-dotnet/tests/Inferable.Tests/Inferable.Tests.csproj b/sdk-dotnet/tests/Inferable.Tests/Inferable.Tests.csproj new file mode 100644 index 00000000..a9c79ec4 --- /dev/null +++ b/sdk-dotnet/tests/Inferable.Tests/Inferable.Tests.csproj @@ -0,0 +1,32 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/sdk-dotnet/tests/Inferable.Tests/InferableTest.cs b/sdk-dotnet/tests/Inferable.Tests/InferableTest.cs new file mode 100644 index 00000000..ac7fe981 --- /dev/null +++ b/sdk-dotnet/tests/Inferable.Tests/InferableTest.cs @@ -0,0 +1,230 @@ +using DotNetEnv; +using Inferable.API; +using Microsoft.Extensions.Logging; +using NJsonSchema; + +namespace Inferable.Tests +{ + public class InferableTests + { + static string EnvFilePath = "../../../../../.env"; + static string TestClusterId; + + static ApiClient ApiClient; + + static InferableTests() + { + Env.Load(EnvFilePath); + ApiClient = new ApiClient(new ApiClientOptions{ + ApiSecret = System.Environment.GetEnvironmentVariable("INFERABLE_CONSUME_SECRET")!, + BaseUrl = System.Environment.GetEnvironmentVariable("INFERABLE_API_ENDPOINT")!, + MachineId = "test" + }); + + TestClusterId = System.Environment.GetEnvironmentVariable("INFERABLE_CLUSTER_ID")!; + } + + static InferableClient CreateInferableClient() + { + var logger = LoggerFactory.Create(builder => + { + builder.AddConsole(); + builder.SetMinimumLevel(LogLevel.Debug); + }).CreateLogger(); + + return new InferableClient(new InferableOptions { + ApiSecret = System.Environment.GetEnvironmentVariable("INFERABLE_MACHINE_SECRET")!, + }, logger); + } + + private struct TestInput + { + public required string testString { get; set; } + } + + [Fact] + public void Inferable_Can_Instantiate() + { + var inferable = CreateInferableClient(); + + Assert.NotNull(inferable); + + Assert.NotNull(inferable.Default); + } + + [Fact] + public void Inferable_Can_Register_Service() + { + var inferable = CreateInferableClient(); + + var service = inferable.RegisterService("test"); + + Assert.NotNull(service); + } + + [Fact] + async public void Inferable_Can_Generate_Schema() + { + var inferable = CreateInferableClient(); + + var registration = new FunctionRegistration + { + Name = "test", + Func = new Func((input) => + { + return "test"; + }) + }; + + var expectedJsonSchema = """ + { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "TestInput", + "additionalProperties": false, + "type": "object", + "properties": { + "testString": { + "type": "string" + } + } + } + """; + + var expectedSchema = await JsonSchema.FromJsonAsync(expectedJsonSchema); + + Assert.Equal(expectedSchema.ToJson(), registration.Schema.ToJson()); + } + + [Fact] + public void Inferable_Should_Throw_If_No_API_Secret_Provided() + { + Assert.Throws(() => new InferableClient()); + } + + [Fact] + public void Inferable_Should_Throw_If_Invalid_API_Secret_Provided() + { + Assert.Throws(() => new InferableClient(new InferableOptions + { + ApiSecret = "invalid" + })); + } + + [Fact] + public void Inferable_Should_Throw_If_Incorrect_API_Secret_Provided() + { + Assert.Throws(() => new InferableClient(new InferableOptions + { + ApiSecret = System.Environment.GetEnvironmentVariable("INFERABLE_CONSUME_SECRET")! + })); + } + + [Fact] + async public void Inferable_Can_Register_Function() + { + var inferable = CreateInferableClient(); + + var registration = new FunctionRegistration + { + Name = "test", + Func = new Func((input) => + { + Console.WriteLine("Executing test function"); + return "test"; + }) + }; + + inferable.Default.RegisterFunction(registration); + + await inferable.Default.Start(); + await inferable.Default.Stop(); + } + + [Fact] + async public void Inferable_Can_Handle_Functions() + { + var inferable = CreateInferableClient(); + + var registration = new FunctionRegistration + { + Name = "successFunction", + Func = new Func((input) => + { + Console.WriteLine("Executing successFunction"); + return "This is a test response"; + }) + }; + + inferable.Default.RegisterFunction(registration); + + try + { + await inferable.Default.Start(); + + var result = ApiClient.CreateCall(TestClusterId, new CreateCallInput + { + Service = "default", + Function = "successFunction", + Input = new Dictionary + { + { "testString", "test" } + } + }); + + Assert.NotNull(result); + Assert.Equal("resolution", result.Result.ResultType); + Assert.Equal("This is a test response", result.Result.Result?.ToString()); + + } + finally + { + await inferable.Default.Stop(); + } + } + + [Fact] + async public void Inferable_Can_Handle_Functions_Failure() + { + var inferable = CreateInferableClient(); + + var registration = new FunctionRegistration + { + Name = "failureFunction", + Func = new Func((input) => + { + Console.WriteLine("Executing failureFunction"); + throw new Exception("This is a test exception"); + }) + }; + + inferable.Default.RegisterFunction(registration); + + try + { + await inferable.Default.Start(); + + var result = ApiClient.CreateCall(TestClusterId, new CreateCallInput + { + Service = "default", + Function = "failureFunction", + Input = new Dictionary + { + { "testString", "test" } + } + }); + + Assert.NotNull(result); + Assert.Equal("rejection", result.Result.ResultType); + Assert.Contains("This is a test exception", result.Result.Result?.ToString()); + + } + finally + { + await inferable.Default.Stop(); + } + } + } + + //TODO: Test transient /call failures + //TODO: TEST /machines failures +} diff --git a/sdk-go/.github/workflows/build.yml b/sdk-go/.github/workflows/build.yml new file mode 100644 index 00000000..f7b4bcef --- /dev/null +++ b/sdk-go/.github/workflows/build.yml @@ -0,0 +1,36 @@ +name: Go Build and Test + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + name: Build and Test + runs-on: ubuntu-latest + + env: + INFERABLE_API_ENDPOINT: 'https://api.inferable.ai' + INFERABLE_CLUSTER_ID: ${{ vars.INFERABLE_CLUSTER_ID }} + INFERABLE_MACHINE_SECRET: ${{ secrets.INFERABLE_MACHINE_SECRET }} + INFERABLE_CONSUME_SECRET: ${{ secrets.INFERABLE_CONSUME_SECRET }} + + steps: + - name: Check out code + uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: "1.22" # Use the Go version specified in your go.mod file + + - name: Get dependencies + run: go mod download + + - name: Build + run: go build -v ./... + + - name: Test + run: go test -v ./... diff --git a/sdk-go/.github/workflows/publish.yml b/sdk-go/.github/workflows/publish.yml new file mode 100644 index 00000000..93ad0cf2 --- /dev/null +++ b/sdk-go/.github/workflows/publish.yml @@ -0,0 +1,57 @@ +name: Publish Go Package + +on: + push: + branches: [main] + +jobs: + publish: + name: Publish + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Check out code + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: "1.22" + + - name: Get current version + id: get_version + run: | + VERSION=$(grep -oP 'const Version = "\K[^"]+' inferable.go) + echo "Current version: $VERSION" + echo "current_version=$VERSION" >> $GITHUB_OUTPUT + + - name: Increment patch version + id: increment_version + run: | + IFS='.' read -ra VERSION_PARTS <<< "${{ steps.get_version.outputs.current_version }}" + MAJOR=${VERSION_PARTS[0]} + MINOR=${VERSION_PARTS[1]} + PATCH=$((VERSION_PARTS[2] + 1)) + NEW_VERSION="$MAJOR.$MINOR.$PATCH" + echo "New version: $NEW_VERSION" + echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT + + - name: Update version in code + run: | + sed -i 's/const Version = "[^"]*"/const Version = "${{ steps.increment_version.outputs.new_version }}"/' inferable.go + + - name: Commit and push changes + run: | + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + git add inferable.go + git commit -m "Bump version to ${{ steps.increment_version.outputs.new_version }}" + git push + + - name: Create Git tag + run: | + git tag v${{ steps.increment_version.outputs.new_version }} + git push origin v${{ steps.increment_version.outputs.new_version }} diff --git a/sdk-go/.gitignore b/sdk-go/.gitignore new file mode 100644 index 00000000..0c0ba40a --- /dev/null +++ b/sdk-go/.gitignore @@ -0,0 +1,72 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +# Generated environment file +generated.golang.env + +# Other generated files +*.gen.go +*_generated.go + +# IDEs and editors +.idea/ +.vscode/ +*.swp +*.swo + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Log files +*.log + +# Temporary files +*.tmp +*.bak +*.backup + +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a + +# Debug files +debug + +# Environment files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Build output directory +/build/ +/dist/ + +# Go binary +/bin/ + +# Generator config (uncomment if you want to version this) +# generator.yaml diff --git a/sdk-go/LICENSE b/sdk-go/LICENSE new file mode 100644 index 00000000..8aa26455 --- /dev/null +++ b/sdk-go/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) [year] [fullname] + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/sdk-go/README.md b/sdk-go/README.md new file mode 100644 index 00000000..aa0764de --- /dev/null +++ b/sdk-go/README.md @@ -0,0 +1,164 @@ +

+ +

+ +# Go SDK for Inferable + +[![Go Reference](https://pkg.go.dev/badge/github.com/inferablehq/inferable-go.svg)](https://pkg.go.dev/github.com/inferablehq/inferable-go) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Documentation](https://img.shields.io/badge/docs-inferable.ai-brightgreen)](https://docs.inferable.ai/) +[![Go Report Card](https://goreportcard.com/badge/github.com/inferablehq/inferable-go)](https://goreportcard.com/report/github.com/inferablehq/inferable-go) + +Inferable Go Client is a Go package that provides a client for interacting with the Inferable API. It allows you to register your go functions against the Inferable control plane. + +## Installation + +To install the Inferable Go Client, use the following command: + +``` +go get github.com/inferablehq/inferable-go +``` + +## Usage + +### Initializing Inferable + +To create a new Inferable client, use the `New` function: + +```go +import "github.com/inferablehq/inferable-go/inferable" + +client, err := inferable.New("your-api-secret", "https://api.inferable.ai") + +if err != nil { + // Handle error +} +``` + +If you don't provide an API endpoint, it will use the default endpoint: `https://api.inferable.ai`. + +### Hello World Function + +Register a "SayHello" [function](https://docs.inferable.ai/pages/functions) with the [control-plane](https://docs.inferable.ai/pages/control-plane). + +```go +type MyInput struct { + Message string `json:"message"` +} + +sayHello, err := client.Default.RegisterFunc(inferable.Function{ + Func: myFunc, + Name: "SayHello", + Description: "A simple greeting function", +}) + +if err != nil { + // Handle error +} +``` + +
+ +👉 The Golang SDK for Inferable reflects the types from the input struct of the function. + +Unlike the TypeScript schema, the Golang SDK for Inferable reflects the types from the input struct of the function. It uses the [invopop/jsonschema](https://pkg.go.dev/github.com/invopop/jsonschema) library under the hood to generate JSON schemas from Go types through reflection. + +If the input struct defines jsonschema properties using struct tags, the SDK will use those in the generated schema. This allows for fine-grained control over the schema generation. + +Here's an example to illustrate this: + +```go +import ( + "github.com/inferablehq/inferable-go/inferable" + "time" +) + +type UserInput struct { + ID int `json:"id" jsonschema:"required"` + Name string `json:"name" jsonschema:"minLength=2,maxLength=50"` + Email string `json:"email" jsonschema:"format=email"` + BirthDate time.Time `json:"birth_date" jsonschema:"format=date"` + Tags []string `json:"tags" jsonschema:"uniqueItems=true"` +} + +func createUser(input UserInput) string { + // Function implementation +} + +service, _ := client.RegisterService("UserService") + +err := service.RegisterFunc(inferable.Function{ + Func: createUser, + Name: "CreateUser", + Description: "Creates a new user", +}) + +if err != nil { + // Handle error +} +``` + +In this example, the UserInput struct uses jsonschema tags to define additional properties for the schema: + +- The id field is marked as required. +- The name field has minimum and maximum length constraints. +- The email field is specified to be in email format. +- The birth_date field is set to date format. +- The tags field is defined as an array with unique items. + +When this function is registered, the Inferable Go SDK will use these jsonschema tags to generate a more detailed and constrained JSON schema for the input. + +The [invopop/jsonschema library](https://pkg.go.dev/github.com/invopop/jsonschema) provides many more options for schema customization, including support for enums, pattern validation, numeric ranges, and more. + +
+ +### Starting the Service + +To start the service and begin listening for incoming requests: + +```go +err := service.Start() +if err != nil { + // Handle error +} +``` + +### Stopping the Service + +To stop the service: + +```go +service.Stop() +``` + +### Trigger a run + +The following code will create an [Inferable run](https://docs.inferable.ai/pages/runs) with the prompt "Say hello to John" and the `sayHello` function attached. + +> You can inspect the progress of the run: +> +> - in the [playground UI](https://app.inferable.ai/) via `inf app` +> - in the [CLI](https://www.npmjs.com/package/@inferable/cli) via `inf runs list` + +```typescript + run, err := i.CreateRun(&inferable.Run{ + Message: "Say hello to John Smith", + Functions: []*inferable.FunctionHandle{ + sayHello, + }, + // Optionally, subscribe an Inferable function as a result handler which will be called when the run is complete. + // Result: &inferable.RunResult{Handler: resultHandler}, + }) + +``` + +> Runs can also be triggered via the [API](https://docs.inferable.ai/pages/invoking-a-run-api), [CLI](https://www.npmjs.com/package/@inferable/cli) or [playground UI](https://app.inferable.ai/). + + +## Contributing + +Contributions to the Inferable Go Client are welcome. Please ensure that your code adheres to the existing style and includes appropriate tests. + +## Support + +For support or questions, please [create an issue in the repository](https://github.com/inferablehq/inferable-go/issues). diff --git a/sdk-go/go.mod b/sdk-go/go.mod new file mode 100644 index 00000000..f872b697 --- /dev/null +++ b/sdk-go/go.mod @@ -0,0 +1,19 @@ +module github.com/inferablehq/inferable-go + +go 1.22 + +require ( + github.com/invopop/jsonschema v0.12.0 + github.com/joho/godotenv v1.5.1 + github.com/stretchr/testify v1.9.0 +) + +require ( + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/sdk-go/go.sum b/sdk-go/go.sum new file mode 100644 index 00000000..c39ca886 --- /dev/null +++ b/sdk-go/go.sum @@ -0,0 +1,23 @@ +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/invopop/jsonschema v0.12.0 h1:6ovsNSuvn9wEQVOyc72aycBMVQFKz7cPdMJn10CvzRI= +github.com/invopop/jsonschema v0.12.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/sdk-go/inferable.go b/sdk-go/inferable.go new file mode 100644 index 00000000..63130b4d --- /dev/null +++ b/sdk-go/inferable.go @@ -0,0 +1,353 @@ +// Package inferable provides a client for interacting with the Inferable API. +package inferable + +import ( + "encoding/json" + "fmt" + "net/http" + "reflect" + + "github.com/inferablehq/inferable-go/internal/client" + "github.com/inferablehq/inferable-go/internal/util" +) + +// Version of the inferable package +const Version = "0.1.14" + +const ( + DefaultAPIEndpoint = "https://api.inferable.ai" +) + +type functionRegistry struct { + services map[string]*service +} + +type Inferable struct { + client *client.Client + apiEndpoint string + apiSecret string + functionRegistry functionRegistry + machineID string + clusterID string + Default *service +} + +type InferableOptions struct { + APIEndpoint string + APISecret string + MachineID string + ClusterID string +} + +// Input struct passed to a Run's result handler +type RunResultHandlerInput struct { + Status string `json:"status"` + RunId string `json:"runId"` + Result interface{} `json:"result"` + Summary string `json:"summary"` + Metadata interface{} `json:"metadata"` +} + +type RunResult struct { + Handler *FunctionHandle + Schema interface{} +} + +type RunTemplate struct { + ID string + Input map[string]interface{} +} + +type Run struct { + Functions []*FunctionHandle + Message string + Result *RunResult + Metadata map[string]string + Template *RunTemplate +} + +type runHandle struct { + ID string +} + +type templateHandle struct { + ID string + Run func(input *Run) (*runHandle, error) +} + +func New(options InferableOptions) (*Inferable, error) { + if options.APIEndpoint == "" { + options.APIEndpoint = DefaultAPIEndpoint + } + client, err := client.NewClient(client.ClientOptions{ + Endpoint: options.APIEndpoint, + Secret: options.APISecret, + }) + if err != nil { + return nil, fmt.Errorf("error creating client: %v", err) + } + + machineID := options.MachineID + if machineID == "" { + machineID = util.GenerateMachineID(8) + } + + + inferable := &Inferable{ + client: client, + apiEndpoint: options.APIEndpoint, + apiSecret: options.APISecret, + clusterID: options.ClusterID, + functionRegistry: functionRegistry{services: make(map[string]*service)}, + machineID: machineID, + } + + // Automatically register the default service + inferable.Default, err = inferable.RegisterService("default") + if err != nil { + return nil, fmt.Errorf("error registering default service: %v", err) + } + + return inferable, nil +} + +func (i *Inferable) RegisterService(serviceName string) (*service, error) { + if _, exists := i.functionRegistry.services[serviceName]; exists { + return nil, fmt.Errorf("service with name '%s' already registered", serviceName) + } + service := &service{ + Name: serviceName, + Functions: make(map[string]Function), + inferable: i, // Set the reference to the Inferable instance + } + i.functionRegistry.services[serviceName] = service + return service, nil +} + +func (i *Inferable) CreateRun(input *Run) (*runHandle, error) { + if i.clusterID == "" { + return nil, fmt.Errorf("cluster ID must be provided to manage runs") + } + + var attachedFunctions []string + for _, fn := range input.Functions { + attachedFunctions = append(attachedFunctions, fmt.Sprintf("%s_%s", fn.Service, fn.Function)) + } + + + payload := client.CreateRunInput{ + Message: input.Message, + AttachedFunctions: attachedFunctions, + Metadata: input.Metadata, + } + + if (input.Template != nil) { + payload.Template = &client.CreateRunTemplateInput{ + Input: input.Template.Input, + ID: input.Template.ID, + } + } + + if (input.Result != nil) { + payload.Result = &client.CreateRunResultInput{ + } + if (input.Result.Handler != nil) { + payload.Result.Handler = &client.CreateRunResultHandlerInput{ + Service: input.Result.Handler.Service, + Function: input.Result.Handler.Function, + } + } + if (input.Result.Schema != nil) { + payload.Result.Schema = input.Result.Schema + } + } + + + // Marshal the payload to JSON + jsonPayload, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("failed to marshal payload: %v", err) + } + + // Prepare headers + headers := map[string]string{ + "Authorization": "Bearer " + i.apiSecret, + "X-Machine-ID": i.machineID, + "X-Machine-SDK-Version": Version, + "X-Machine-SDK-Language": "go", + } + + // Call the registerMachine endpoint + options := client.FetchDataOptions{ + Path: fmt.Sprintf("/clusters/%s/runs", i.clusterID), + Method: "POST", + Headers: headers, + Body: string(jsonPayload), + } + + responseData, _, err, _ := i.fetchData(options) + if err != nil { + return nil, fmt.Errorf("failed to create run: %v", err) + } + + // Parse the response + var response struct { + ID string `json:"id"` + } + + err = json.Unmarshal(responseData, &response) + if err != nil { + return nil, fmt.Errorf("failed to parse run response: %v", err) + } + + return &runHandle{ID: response.ID}, nil +} + +func (i *Inferable) GetTemplate(id string) (*templateHandle, error) { + if i.clusterID == "" { + return nil, fmt.Errorf("cluster ID must be provided to manage runs") + } + + // Prepare headers + headers := map[string]string{ + "Authorization": "Bearer " + i.apiSecret, + "X-Machine-ID": i.machineID, + "X-Machine-SDK-Version": Version, + "X-Machine-SDK-Language": "go", + } + + // Call the registerMachine endpoint + options := client.FetchDataOptions{ + Path: fmt.Sprintf("/clusters/%s/prompt-templates/%s", i.clusterID, id), + Method: "GET", + Headers: headers, + } + + responseData, _, err, _ := i.fetchData(options) + if err != nil { + return nil, fmt.Errorf("failed to get template: %v", err) + } + + // Parse the response + var response struct { + ID string `json:"id"` + } + + err = json.Unmarshal(responseData, &response) + if err != nil { + return nil, fmt.Errorf("failed to parse template response: %v", err) + } + + return &templateHandle{ + ID: response.ID, + Run: func(input *Run) (*runHandle, error) { + // CLone the input + inputCopy := *input + + // Set the template ID + if inputCopy.Template == nil { + inputCopy.Template = &RunTemplate{ + ID: response.ID, + } + } else { + inputCopy.Template.ID = response.ID + } + + fmt.Println(inputCopy) + + return i.CreateRun(&inputCopy) + }, + }, nil +} + +func (i *Inferable) callFunc(serviceName, funcName string, args ...interface{}) ([]reflect.Value, error) { + service, exists := i.functionRegistry.services[serviceName] + if !exists { + return nil, fmt.Errorf("service with name '%s' not found", serviceName) + } + + fn, exists := service.Functions[funcName] + if !exists { + return nil, fmt.Errorf("function with name '%s' not found in service '%s'", funcName, serviceName) + } + + // Get the reflect.Value of the function + fnValue := reflect.ValueOf(fn.Func) + + // Check if the number of arguments is correct + if len(args) != fnValue.Type().NumIn() { + return nil, fmt.Errorf("invalid number of arguments for function '%s'", funcName) + } + + // Prepare the arguments + inArgs := make([]reflect.Value, len(args)) + for i, arg := range args { + inArgs[i] = reflect.ValueOf(arg) + } + + // Call the function + return fnValue.Call(inArgs), nil +} + +func (i *Inferable) toJSONDefinition() ([]byte, error) { + definitions := make([]map[string]interface{}, 0) + + for serviceName, service := range i.functionRegistry.services { + serviceDef := make(map[string]interface{}) + functions := make([]map[string]interface{}, 0) + + for _, function := range service.Functions { + funcDef := map[string]interface{}{ + "name": function.Name, + "description": function.Description, + "schema": function.schema, + } + functions = append(functions, funcDef) + } + + serviceDef["service"] = serviceName + serviceDef["functions"] = functions + + definitions = append(definitions, serviceDef) + } + + return json.MarshalIndent(definitions, "", " ") +} + +func (i *Inferable) fetchData(options client.FetchDataOptions) ([]byte, http.Header, error, int) { + // Add default Content-Type header if not present + if options.Headers == nil { + options.Headers = make(map[string]string) + } + if _, exists := options.Headers["Content-Type"]; !exists && options.Body != "" { + options.Headers["Content-Type"] = "application/json" + } + + data, headers, err, status:= i.client.FetchData(options) + return []byte(data), headers, err, status +} + +func (i *Inferable) serverOk() error { + data, _, err, _ := i.client.FetchData(client.FetchDataOptions{ + Path: "/live", + Method: "GET", + }) + if err != nil { + return fmt.Errorf("error fetching data from /live: %v", err) + } + + var response struct { + Status string `json:"status"` + } + + // Convert string to []byte before unmarshaling + if err := json.Unmarshal([]byte(data), &response); err != nil { + return fmt.Errorf("error unmarshaling response: %v", err) + } + + if response.Status != "ok" { + return fmt.Errorf("unexpected status from /live: %s", response.Status) + } + + return nil +} diff --git a/sdk-go/inferable_test.go b/sdk-go/inferable_test.go new file mode 100644 index 00000000..a0762ab9 --- /dev/null +++ b/sdk-go/inferable_test.go @@ -0,0 +1,230 @@ +package inferable + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNew(t *testing.T) { + i, err := New(InferableOptions{ + APIEndpoint: DefaultAPIEndpoint, + APISecret: "test-secret", + }) + require.NoError(t, err) + assert.Equal(t, DefaultAPIEndpoint, i.apiEndpoint) + assert.Equal(t, "test-secret", i.apiSecret) + assert.NotEmpty(t, i.machineID) +} + +func TestRegisterService(t *testing.T) { + i, _ := New(InferableOptions{ + APIEndpoint: DefaultAPIEndpoint, + APISecret: "test-secret", + }) + service, err := i.RegisterService("TestService") + require.NoError(t, err) + assert.Equal(t, "TestService", service.Name) + + // Try to register the same service again + _, err = i.RegisterService("TestService") + assert.Error(t, err) +} + +func TestRegisterDefaultService(t *testing.T) { + i, err := New(InferableOptions{ + APIEndpoint: DefaultAPIEndpoint, + APISecret: "test-secret", + }) + require.NoError(t, err) + assert.Equal(t, "default", i.Default.Name) + + require.NoError(t, err) +} + +func TestCallFunc(t *testing.T) { + i, _ := New(InferableOptions{ + APIEndpoint: DefaultAPIEndpoint, + APISecret: "test-secret", + }) + + type TestInput struct { + A int `json:"a"` + B int `json:"b"` + } + + testFunc := func(input TestInput) int { return input.A + input.B } + i.Default.RegisterFunc(Function{ + Func: testFunc, + Name: "TestFunc", + }) + + result, err := i.callFunc("default", "TestFunc", TestInput{A: 2, B: 3}) + require.NoError(t, err) + assert.Equal(t, 5, result[0].Interface()) + + // Test calling non-existent function + _, err = i.callFunc("TestService", "NonExistentFunc") + assert.Error(t, err) +} + +func TestToJSONDefinition(t *testing.T) { + i, _ := New(InferableOptions{ + APIEndpoint: DefaultAPIEndpoint, + APISecret: "test-secret", + }) + service, _ := i.RegisterService("TestService") + + type TestInput struct { + A int `json:"a"` + B int `json:"b"` + } + + testFunc := func(input TestInput) int { return input.A + input.B } + service.RegisterFunc(Function{ + Func: testFunc, + Name: "TestFunc", + Description: "Test function", + }) + + jsonDef, err := i.toJSONDefinition() + require.NoError(t, err) + + var definitions []map[string]interface{} + err = json.Unmarshal(jsonDef, &definitions) + require.NoError(t, err) + + // Log the definitions + t.Log(string(jsonDef)) + assert.Len(t, definitions, 2) + // Sort by service name + + sort.Slice(definitions, func(i, j int) bool { + return definitions[i]["service"].(string) > definitions[j]["service"].(string) + }) + + assert.Equal(t, "default", definitions[0]["service"]) + assert.Equal(t, "TestService", definitions[1]["service"]) + + functions := definitions[1]["functions"].([]interface{}) + + assert.Len(t, functions, 1) + funcDef := functions[0].(map[string]interface{}) + + assert.Equal(t, "TestFunc", funcDef["name"]) + assert.Equal(t, "Test function", funcDef["description"]) +} + +func TestServerOk(t *testing.T) { + // Create a test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/live" { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status": "ok"}`)) + } + })) + defer server.Close() + + i, _ := New(InferableOptions{ + APIEndpoint: server.URL, + APISecret: "test-secret", + }) + err := i.serverOk() + assert.NoError(t, err) +} + +func TestGetMachineID(t *testing.T) { + i, _ := New(InferableOptions{ + APIEndpoint: DefaultAPIEndpoint, + APISecret: "test-secret", + }) + machineID := i.machineID + assert.NotEmpty(t, machineID) + + // Check if the machine ID is persistent + i2, _ := New(InferableOptions{ + APIEndpoint: DefaultAPIEndpoint, + APISecret: "test-secret", + }) + assert.Equal(t, machineID, i2.machineID) +} + +func TestGetSchema(t *testing.T) { + i, _ := New(InferableOptions{ + APIEndpoint: DefaultAPIEndpoint, + APISecret: "test-secret", + }) + service, _ := i.RegisterService("TestService") + + type TestInput struct { + A int `json:"a"` + B int `json:"b"` + } + + testFunc := func(input TestInput) int { return input.A + input.B } + service.RegisterFunc(Function{ + Func: testFunc, + Name: "TestFunc", + }) + + type TestInput2 struct { + C struct { + D int `json:"d"` + E []int `json:"e"` + } `json:"c"` + } + + testFunc2 := func(input TestInput2) int { return input.C.D * 2 } + service.RegisterFunc(Function{ + Func: testFunc2, + Name: "TestFunc2", + }) + + schema, err := service.getSchema() + require.NoError(t, err) + assert.Equal(t, "TestFunc", schema["TestFunc"].(map[string]interface{})["name"]) + assert.Equal(t, "TestFunc2", schema["TestFunc2"].(map[string]interface{})["name"]) + + // Marshal the schema to JSON and assert it as a string + schemaJSON, err := json.Marshal(schema) + require.NoError(t, err) + assert.NotEmpty(t, string(schemaJSON)) + + expectedJSON := `{ + "TestFunc": { + "name": "TestFunc", + "input": { + "type": "object", + "properties": { + "a": {"type": "integer"}, + "b": {"type": "integer"} + }, + "required": ["a", "b"] + } + }, + "TestFunc2": { + "name": "TestFunc2", + "input": { + "type": "object", + "properties": { + "c": { + "type": "object", + "properties": { + "d": {"type": "integer"}, + "e": {"type": "array", "items": {"type": "integer"}} + }, + "additionalProperties": false, + "required": ["d", "e"] + } + }, + "required": ["c"] + } + } + }` + assert.JSONEq(t, expectedJSON, string(schemaJSON)) +} diff --git a/sdk-go/internal/client/client.go b/sdk-go/internal/client/client.go new file mode 100644 index 00000000..b47ea5ef --- /dev/null +++ b/sdk-go/internal/client/client.go @@ -0,0 +1,116 @@ +package client + +import ( + "fmt" + "io/ioutil" + "net/http" + "strings" +) + +// Client represents an Inferable API client +type Client struct { + endpoint string + secret string + httpClient *http.Client +} + +type ClientOptions struct { + Endpoint string + Secret string +} + +// NewClient creates a new Inferable API client +func NewClient(options ClientOptions) (*Client, error) { + if !strings.HasPrefix(options.Endpoint, "http://") && !strings.HasPrefix(options.Endpoint, "https://") { + return nil, fmt.Errorf("invalid URL: %s", options.Endpoint) + } + + return &Client{ + endpoint: options.Endpoint, + secret: options.Secret, + httpClient: &http.Client{}, + }, nil +} + +type CreateRunInput struct { + Message string `json:"message,omitempty"` + AttachedFunctions []string`json:"attachedFunctions,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + Result *CreateRunResultInput `json:"result,omitempty"` + Template *CreateRunTemplateInput `json:"template,omitempty"` +} + +type CreateRunTemplateInput struct { + Input map[string]interface{} `json:"input,omitempty"` + ID string `json:"id,omitempty"` +} + +type CreateRunResultInput struct { + Handler *CreateRunResultHandlerInput `json:"handler,omitempty"` + Schema interface{} `json:"schema,omitempty"` +} + +type CreateRunResultHandlerInput struct { + Service string `json:"service"` + Function string `json:"function"` +} + +type FetchDataOptions struct { + Path string + Headers map[string]string + QueryParams map[string]string + Body string + Method string +} + +func (c *Client) FetchData(options FetchDataOptions) (string, http.Header, error, int) { + fullURL := fmt.Sprintf("%s%s", c.endpoint, options.Path) + + if !strings.HasPrefix(fullURL, "http://") && !strings.HasPrefix(fullURL, "https://") { + return "", nil, fmt.Errorf("invalid URL: %s", fullURL), -1 + } + + req, err := http.NewRequest(options.Method, fullURL, strings.NewReader(options.Body)) + if err != nil { + return "", nil, fmt.Errorf("error creating request: %v", err), -1 + } + + req.Header.Set("Authorization", "Bearer "+c.secret) + + // Add custom headers + for key, value := range options.Headers { + req.Header.Set(key, value) + } + + // Add query parameters + q := req.URL.Query() + for key, value := range options.QueryParams { + q.Add(key, value) + } + req.URL.RawQuery = q.Encode() + + // Set Content-Type header if body is not empty + if options.Body != "" { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.httpClient.Do(req) + if err != nil { + if resp == nil { + return "", nil, fmt.Errorf("error making request: %v", err), -1 + } + return "", nil, fmt.Errorf("error making request: %v", err), resp.StatusCode + } + defer resp.Body.Close() + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return "", nil, fmt.Errorf("error reading response: %v", err), resp.StatusCode + } + + if resp.StatusCode >= 400 { + return "", resp.Header, fmt.Errorf("API error: %s (status code: %d)", string(body), resp.StatusCode), resp.StatusCode + } + + return string(body), resp.Header, nil, resp.StatusCode +} diff --git a/sdk-go/internal/util/test_util.go b/sdk-go/internal/util/test_util.go new file mode 100644 index 00000000..dc278daf --- /dev/null +++ b/sdk-go/internal/util/test_util.go @@ -0,0 +1,34 @@ +package util + +import ( + "github.com/joho/godotenv" + "os" +) + +func GetTestVars() (string, string, string, string) { + if os.Getenv("INFERABLE_MACHINE_SECRET") == "" { + err := godotenv.Load("./.env") + if err != nil { + panic(err) + } + } + machineSecret := os.Getenv("INFERABLE_MACHINE_SECRET") + consumeSecret := os.Getenv("INFERABLE_CONSUME_SECRET") + clusterId := os.Getenv("INFERABLE_CLUSTER_ID") + apiEndpoint := os.Getenv("INFERABLE_API_ENDPOINT") + + if apiEndpoint == "" { + panic("INFERABLE_API_ENDPOINT is not available") + } + if machineSecret == "" { + panic("INFERABLE_MACHINE_SECRET is not available") + } + if consumeSecret == "" { + panic("INFERABLE_CONSUME_SECRET is not available") + } + if clusterId == "" { + panic("INFERABLE_CLUSTER_ID is not set in .env") + } + + return machineSecret, consumeSecret, clusterId, apiEndpoint +} diff --git a/sdk-go/internal/util/util.go b/sdk-go/internal/util/util.go new file mode 100644 index 00000000..67b4500c --- /dev/null +++ b/sdk-go/internal/util/util.go @@ -0,0 +1,43 @@ +package util + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "math/rand" + "os" + "runtime" + "strings" +) + +const ( + MachineIDFile = "inferable_machine_id.json" +) + +func GetMachineID() string { + hostname, _ := os.Hostname() + cpuInfo := runtime.GOARCH + runtime.GOOS + runtime.Version() + machineID := hostname + cpuInfo + + hash := sha256.Sum256([]byte(machineID)) + return hex.EncodeToString(hash[:]) +} + +func GenerateMachineID(length int) string { + machineID := GetMachineID() + seed := int64(0) + for _, char := range machineID { + seed += int64(char) + } + + r := rand.New(rand.NewSource(seed)) + const charset = "abcdefghijklmnopqrstuvwxyz" + + var sb strings.Builder + sb.Grow(length) + for i := 0; i < length; i++ { + sb.WriteByte(charset[r.Intn(len(charset))]) + } + + return fmt.Sprintf("go-%s", sb.String()) +} diff --git a/sdk-go/main_test.go b/sdk-go/main_test.go new file mode 100644 index 00000000..61aee4b2 --- /dev/null +++ b/sdk-go/main_test.go @@ -0,0 +1,146 @@ +package inferable + +import ( + "testing" + + "github.com/inferablehq/inferable-go/internal/util" +) + +type EchoInput struct { + Input string +} + +func echo(input EchoInput) string { + return input.Input +} + +type ReverseInput struct { + Input string +} + +func reverse(input ReverseInput) string { + runes := []rune(input.Input) + for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 { + runes[i], runes[j] = runes[j], runes[i] + } + return string(runes) +} + +func TestInferableFunctions(t *testing.T) { + machineSecret, _, _, apiEndpoint := util.GetTestVars() + + inferableInstance, err := New(InferableOptions{ + APIEndpoint: apiEndpoint, + APISecret: machineSecret, + }) + if err != nil { + t.Fatalf("Error creating Inferable instance: %v", err) + } + + service, err := inferableInstance.RegisterService("string_operations") + if err != nil { + t.Fatalf("Error registering service: %v", err) + } + + _, err = service.RegisterFunc(Function{ + Func: echo, + Description: "Echoes the input string", + Name: "echo", + }) + if err != nil { + t.Fatalf("Error registering echo function: %v", err) + } + + _, err = service.RegisterFunc(Function{ + Func: reverse, + Description: "Reverses the input string", + Name: "reverse", + }) + if err != nil { + t.Fatalf("Error registering reverse function: %v", err) + } + + jsonDef, err := inferableInstance.toJSONDefinition() + if err != nil { + t.Fatalf("Error generating JSON definition: %v", err) + } + t.Logf("JSON Definition:\n%s\n", string(jsonDef)) + + t.Run("Echo Function", func(t *testing.T) { + testInput := EchoInput{Input: "Hello, Inferable!"} + result, err := inferableInstance.callFunc("string_operations", "echo", testInput) + if err != nil { + t.Fatalf("Error calling echo function: %v", err) + } + + if len(result) != 1 { + t.Fatalf("Expected 1 return value, got %d", len(result)) + } + + returnedString := result[0].Interface().(string) + if returnedString != testInput.Input { + t.Errorf("Echo function returned incorrect result. Expected: %s, Got: %s", testInput.Input, returnedString) + } + }) + + t.Run("Reverse Function", func(t *testing.T) { + testInput := ReverseInput{Input: "Hello, Inferable!"} + result, err := inferableInstance.callFunc("string_operations", "reverse", testInput) + if err != nil { + t.Fatalf("Error calling reverse function: %v", err) + } + + if len(result) != 1 { + t.Fatalf("Expected 1 return value, got %d", len(result)) + } + + returnedString := result[0].Interface().(string) + if returnedString != "!elbarefnI ,olleH" { + t.Errorf("Reverse function returned incorrect result. Expected: %s, Got: %s", testInput.Input, returnedString) + } + }) + + t.Run("Server Health Check", func(t *testing.T) { + err := inferableInstance.serverOk() + if err != nil { + t.Fatalf("Server health check failed: %v", err) + } + t.Log("Server health check passed") + }) + + t.Run("Machine ID Generation", func(t *testing.T) { + machineID := inferableInstance.machineID + if machineID == "" { + t.Error("Machine ID is empty") + } + t.Logf("Generated Machine ID: %s", machineID) + }) + + t.Run("Machine ID Consistency", func(t *testing.T) { + machineSecret, _, _, apiEndpoint := util.GetTestVars() + + instance1, err := New(InferableOptions{ + APIEndpoint: apiEndpoint, + APISecret: machineSecret, + }) + if err != nil { + t.Fatalf("Error creating first Inferable instance: %v", err) + } + id1 := instance1.machineID + + instance2, err := New(InferableOptions{ + APIEndpoint: apiEndpoint, + APISecret: machineSecret, + }) + if err != nil { + t.Fatalf("Error creating second Inferable instance: %v", err) + } + id2 := instance2.machineID + + if id1 != id2 { + t.Errorf("Machine IDs are not consistent. First: %s, Second: %s", id1, id2) + } else { + t.Logf("Machine ID is consistent: %s", id1) + } + }) +} diff --git a/sdk-go/service.go b/sdk-go/service.go new file mode 100644 index 00000000..d1efb08c --- /dev/null +++ b/sdk-go/service.go @@ -0,0 +1,395 @@ +package inferable + +import ( + "context" + "encoding/json" + "fmt" + "log" + "reflect" + "strconv" + "strings" + "time" + + "github.com/invopop/jsonschema" + + "github.com/inferablehq/inferable-go/internal/client" +) + +const ( + MaxConsecutivePollFailures = 50 + DefaultRetryAfter = 10 +) + +type Function struct { + Name string + Description string + schema interface{} + Config interface{} + Func interface{} +} + +type service struct { + Name string + Functions map[string]Function + inferable *Inferable + clusterId string + ctx context.Context + cancel context.CancelFunc + retryAfter int +} + +type callMessage struct { + Id string `json:"id"` + Function string `json:"function"` + Input interface{} `json:"input"` +} + +type callResultMeta struct { + FunctionExecutionTime int64 `json:"functionExecutionTime,omitempty"` +} + +type callResult struct { + Result interface{} `json:"result"` + ResultType string `json:"resultType"` + Meta callResultMeta `json:"meta"` +} + +type FunctionHandle struct { + Service string + Function string +} + +func (s *service) RegisterFunc(fn Function) (*FunctionHandle, error) { + if s.isPolling() { + return nil, fmt.Errorf("functions must be registered before starting the service.") + } + + if _, exists := s.Functions[fn.Name]; exists { + return nil, fmt.Errorf("function with name '%s' already registered for service '%s'", fn.Name, s.Name) + } + + // Validate that the function has exactly one argument and it's a struct + fnType := reflect.TypeOf(fn.Func) + if fnType.NumIn() != 1 { + return nil, fmt.Errorf("function '%s' must have exactly one argument", fn.Name) + } + argType := fnType.In(0) + if argType.Kind() != reflect.Struct { + return nil, fmt.Errorf("function '%s' first argument must be a struct", fn.Name) + } + + // Get the schema for the input struct + reflector := jsonschema.Reflector{} + schema := reflector.Reflect(reflect.New(argType).Interface()) + + if schema == nil { + return nil, fmt.Errorf("failed to get schema for function '%s'", fn.Name) + } + + // Extract the relevant part of the schema + defs, ok := schema.Definitions[argType.Name()] + if !ok { + return nil, fmt.Errorf("failed to find schema definition for %s", argType.Name()) + } + + defsString, err := json.Marshal(defs) + if err != nil { + return nil, fmt.Errorf("failed to marshal schema for function '%s': %v", fn.Name, err) + } + + if strings.Contains(string(defsString), "\"$ref\":\"#/$defs") { + return nil, fmt.Errorf("schema for function '%s' contains a $ref to an external definition. this is currently not supported. see https://go.inferable.ai/go-schema-limitation for details", fn.Name) + } + + defs.AdditionalProperties = nil + fn.schema = defs + + s.Functions[fn.Name] = fn + return &FunctionHandle{Service: s.Name, Function: fn.Name}, nil +} + +func (s *service) registerMachine() error { + // Check if there are any registered functions + if len(s.Functions) == 0 { + return fmt.Errorf("cannot register service '%s': no functions registered", s.Name) + } + + // Prepare the payload for registration + payload := struct { + Service string `json:"service"` + Functions []struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Schema string `json:"schema,omitempty"` + } `json:"functions,omitempty"` + }{ + Service: s.Name, + } + + // Add registered functions to the payload + for _, fn := range s.Functions { + schemaJSON, err := json.Marshal(fn.schema) + if err != nil { + return fmt.Errorf("failed to marshal schema for function '%s': %v", fn.Name, err) + } + + payload.Functions = append(payload.Functions, struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Schema string `json:"schema,omitempty"` + }{ + Name: fn.Name, + Description: fn.Description, + Schema: string(schemaJSON), + }) + } + + // Marshal the payload to JSON + jsonPayload, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal payload: %v", err) + } + + // Prepare headers + headers := map[string]string{ + "Authorization": "Bearer " + s.inferable.apiSecret, + "X-Machine-ID": s.inferable.machineID, + "X-Machine-SDK-Version": Version, + "X-Machine-SDK-Language": "go", + } + + // Call the registerMachine endpoint + options := client.FetchDataOptions{ + Path: "/machines", + Method: "POST", + Headers: headers, + Body: string(jsonPayload), + } + + responseData, _, err, _ := s.inferable.fetchData(options) + if err != nil { + return fmt.Errorf("failed to register machine: %v", err) + } + + // Parse the response + var response struct { + ClusterId string `json:"clusterId"` + } + + err = json.Unmarshal(responseData, &response) + if err != nil { + return fmt.Errorf("failed to parse registration response: %v", err) + } + + s.clusterId = response.ClusterId + + return nil +} + +// Start initializes the service, registers the machine, and starts polling for messages +func (s *service) Start() error { + err := s.registerMachine() + if err != nil { + return fmt.Errorf("failed to register machine: %v", err) + } + + s.ctx, s.cancel = context.WithCancel(context.Background()) + s.retryAfter = 0 + + go func() { + failureCount := DefaultRetryAfter + for { + time.Sleep(time.Duration(s.retryAfter) * time.Second) + + select { + case <-s.ctx.Done(): + return + default: + err := s.poll() + + if err != nil { + failureCount++ + + if failureCount > MaxConsecutivePollFailures { + log.Printf("Too many consecutive poll failures, exiting service: %s", s.Name) + s.Stop() + } + + log.Printf("Failed to poll: %v", err) + } + } + } + }() + + log.Printf("Service '%s' started and polling for messages", s.Name) + return nil +} + +// Stop stops the service and cancels the polling +func (s *service) Stop() { + if s.cancel != nil { + s.cancel() + log.Printf("Service '%s' stopped", s.Name) + } +} + +func (s *service) poll() error { + headers := map[string]string{ + "Authorization": "Bearer " + s.inferable.apiSecret, + "X-Machine-ID": s.inferable.machineID, + "X-Machine-SDK-Version": Version, + "X-Machine-SDK-Language": "go", + } + + options := client.FetchDataOptions{ + Path: fmt.Sprintf("/clusters/%s/calls?acknowledge=true&service=%s&status=pending&limit=10", s.clusterId, s.Name), + Method: "GET", + Headers: headers, + } + + result, respHeaders, err, status := s.inferable.fetchData(options) + + if status == 410 { + s.registerMachine() + } + + if err != nil { + return fmt.Errorf("failed to poll calls: %v", err) + } + + if retryAfter, ok := respHeaders["Retry-After"]; ok { + for _, v := range retryAfter { + if i, err := strconv.Atoi(v); err == nil { + s.retryAfter = i + } + } + } + + parsed := []callMessage{} + + err = json.Unmarshal(result, &parsed) + if err != nil { + return fmt.Errorf("failed to parse poll response: %v", err) + } + + errors := []string{} + for _, msg := range parsed { + err := s.handleMessage(msg) + if err != nil { + errors = append(errors, err.Error()) + } + } + + if len(errors) > 0 { + return fmt.Errorf("failed to handle messages: %v", errors) + } + + return nil +} + +func (s *service) handleMessage(msg callMessage) error { + // Find the target function + fn, ok := s.Functions[msg.Function] + if !ok { + log.Printf("Received call for unknown function: %s", msg.Function) + return nil + } + + // Create a new instance of the function's input type + fnType := reflect.TypeOf(fn.Func) + argType := fnType.In(0) + argPtr := reflect.New(argType) + + inputJson, err := json.Marshal(msg.Input) + + if err != nil { + return fmt.Errorf("failed to marshal input: %v", err) + } + + err = json.Unmarshal(inputJson, argPtr.Interface()) + if err != nil { + return fmt.Errorf("failed to unmarshal input: %v", err) + } + + start := time.Now() + // Call the function with the unmarshaled argument + fnValue := reflect.ValueOf(fn.Func) + returnValues := fnValue.Call([]reflect.Value{argPtr.Elem()}) + + resultType := "resolution" + resultValue := returnValues[0].Interface() + + // Check if ANY of the return values is an error + for _, v := range returnValues { + if v.Type().AssignableTo(reflect.TypeOf((*error)(nil)).Elem()) && v.Interface() != nil { + resultType = "rejection" + // Serialize the error + resultValue = v.Interface().(error).Error() + break + } + } + + result := callResult{ + Result: resultValue, + ResultType: resultType, + Meta: callResultMeta{ + FunctionExecutionTime: int64(time.Since(start).Milliseconds()), + }, + } + + // Persist the job result + if err := s.persistJobResult(msg.Id, result); err != nil { + return fmt.Errorf("failed to persist job result: %v", err) + } + + return nil +} + +func (s *service) persistJobResult(jobID string, result callResult) error { + payloadJSON, err := json.Marshal(result) + if err != nil { + return fmt.Errorf("failed to marshal payload for persistJobResult: %v", err) + } + + headers := map[string]string{ + "Authorization": "Bearer " + s.inferable.apiSecret, + "X-Machine-ID": s.inferable.machineID, + "X-Machine-SDK-Version": Version, + "X-Machine-SDK-Language": "go", + } + + options := client.FetchDataOptions{ + Path: fmt.Sprintf("/clusters/%s/calls/%s/result", s.clusterId, jobID), + Method: "POST", + Headers: headers, + Body: string(payloadJSON), + } + + _, _, err, _ = s.inferable.fetchData(options) + if err != nil { + return fmt.Errorf("failed to persist job result: %v", err) + } + + return nil +} + +func (s *service) getSchema() (map[string]interface{}, error) { + if len(s.Functions) == 0 { + return nil, fmt.Errorf("no functions registered for service '%s'", s.Name) + } + + schema := make(map[string]interface{}) + + for _, fn := range s.Functions { + schema[fn.Name] = map[string]interface{}{ + "input": fn.schema, + "name": fn.Name, + } + } + + return schema, nil +} + +func (s *service) isPolling() bool { + return s.cancel != nil +} diff --git a/sdk-go/service_test.go b/sdk-go/service_test.go new file mode 100644 index 00000000..4c9f30c2 --- /dev/null +++ b/sdk-go/service_test.go @@ -0,0 +1,290 @@ +package inferable + +import ( + "encoding/json" + "fmt" + "testing" + + "bytes" + "net/http" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/inferablehq/inferable-go/internal/util" +) + +func TestRegisterFunc(t *testing.T) { + _, _, _, apiEndpoint := util.GetTestVars() + + i, _ := New(InferableOptions{ + APIEndpoint: apiEndpoint, + APISecret: "test-secret", + }) + service, _ := i.RegisterService("TestService1") + + type TestInput struct { + A int `json:"a"` + B int `json:"b"` + } + + testFunc := func(input TestInput) int { return input.A + input.B } + _, err := service.RegisterFunc(Function{ + Func: testFunc, + Name: "TestFunc", + Description: "Test function", + }) + require.NoError(t, err) + + // Try to register the same function again + _, err = service.RegisterFunc(Function{ + Func: testFunc, + Name: "TestFunc", + }) + assert.Error(t, err) + + // Try to register a function with invalid input + invalidFunc := func(a, b int) int { return a + b } + _, err = service.RegisterFunc(Function{ + Func: invalidFunc, + Name: "InvalidFunc", + }) + assert.Error(t, err) +} + +func TestRegistrationAndConfig(t *testing.T) { + machineSecret, _, _, apiEndpoint := util.GetTestVars() + + machineID := "random-machine-id" + + // Create a new Inferable instance + i, err := New(InferableOptions{ + APIEndpoint: apiEndpoint, + APISecret: machineSecret, + MachineID: machineID, + }) + require.NoError(t, err) + + // Register a service + service, err := i.RegisterService("TestService1") + require.NoError(t, err) + + // Register a test function + type TestInput struct { + A int `json:"a"` + B int `json:"b"` + C []struct { + D int `json:"d"` + E string `json:"e"` + F []interface{} `json:"f"` + } `json:"c"` + } + + testFunc := func(input TestInput) int { return input.A + input.B } + + _, err = service.RegisterFunc(Function{ + Func: testFunc, + Name: "TestFunc", + Description: "Test function", + }) + + require.NoError(t, err) + + // Call Listen to trigger registration + err = service.Start() + require.NoError(t, err) +} + +func TestErrorneousRegistration(t *testing.T) { + machineSecret, _, _, apiEndpoint := util.GetTestVars() + + machineID := "random-machine-id" + + // Create a new Inferable instance + i, err := New(InferableOptions{ + APIEndpoint: apiEndpoint, + APISecret: machineSecret, + MachineID: machineID, + }) + require.NoError(t, err) + + // Register a service + service, err := i.RegisterService("TestService1") + require.NoError(t, err) + + type F struct { + G int `json:"g"` + } + + // Register a test function + type TestInput struct { + A int `json:"a"` + B int `json:"b"` + C []struct { + D int `json:"d"` + E string `json:"e"` + F []F `json:"f"` + } `json:"c"` + } + + testFunc := func(input TestInput) int { return input.A + input.B } + + _, err = service.RegisterFunc(Function{ + Func: testFunc, + Name: "TestFunc", + Description: "Test function", + }) + + require.ErrorContains(t, err, "schema for function 'TestFunc' contains a $ref to an external definition. this is currently not supported.") +} + +func TestServiceStartAndReceiveMessage(t *testing.T) { + machineSecret, consumeSecret, clusterId, apiEndpoint := util.GetTestVars() + + machineID := "random-machine-id" + + // Create a new Inferable instance + i, err := New(InferableOptions{ + APIEndpoint: apiEndpoint, + APISecret: machineSecret, + MachineID: machineID, + }) + require.NoError(t, err) + + // Register a service + service, err := i.RegisterService("TestServiceSuccess") + require.NoError(t, err) + + // Register a test function + type TestInput struct { + Message string `json:"message"` + } + + testFunc := func(input TestInput) string { return "Received: " + input.Message } + + _, err = service.RegisterFunc(Function{ + Func: testFunc, + Name: "TestFunc", + Description: "Test function", + }) + require.NoError(t, err) + + // Start the service + err = service.Start() + require.NoError(t, err) + + // Ensure the service is stopped at the end of the test + defer service.Stop() + + // Use executeJobSync to invoke the function + testMessage := "Hello, SQS!" + executeCallUrl := fmt.Sprintf("%s/clusters/%s/calls?waitTime=20", apiEndpoint, clusterId) + payload := map[string]interface{}{ + "service": "TestServiceSuccess", + "function": "TestFunc", + "input": map[string]string{ + "message": testMessage, + }, + } + + jsonPayload, err := json.Marshal(payload) + require.NoError(t, err) + + req, err := http.NewRequest("POST", executeCallUrl, bytes.NewBuffer(jsonPayload)) + require.NoError(t, err) + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+consumeSecret) + + client := &http.Client{} + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + + // Check if the job was executed successfully + require.Equal(t, "resolution", result["resultType"]) + require.Equal(t, "success", result["status"]) + require.Equal(t, "Received: Hello, SQS!", result["result"]) +} + +func TestServiceStartAndReceiveFailingMessage(t *testing.T) { + machineSecret, consumeSecret, clusterId, apiEndpoint := util.GetTestVars() + + machineID := "random-machine-id" + + // Create a new Inferable instance + i, err := New(InferableOptions{ + APIEndpoint: apiEndpoint, + APISecret: machineSecret, + MachineID: machineID, + }) + require.NoError(t, err) + + // Register a service + service, err := i.RegisterService("TestServiceFail") + require.NoError(t, err) + + // Register a test function + type TestInput struct { + Message string `json:"message"` + } + + // Purposfuly failing function + testFailingFunc := func(input TestInput) (*string, error) { return nil, fmt.Errorf("test error") } + + _, err = service.RegisterFunc(Function{ + Func: testFailingFunc, + Name: "FailingFunc", + Description: "Test function", + }) + require.NoError(t, err) + + // Start the service + err = service.Start() + require.NoError(t, err) + + // Ensure the service is stopped at the end of the test + defer service.Stop() + + // Use executeJobSync to invoke the function + testMessage := "Hello, SQS!" + executeCallUrl := fmt.Sprintf("%s/clusters/%s/calls?waitTime=20", apiEndpoint, clusterId) + payload := map[string]interface{}{ + "service": "TestServiceFail", + "function": "FailingFunc", + "input": map[string]string{ + "message": testMessage, + }, + } + + jsonPayload, err := json.Marshal(payload) + require.NoError(t, err) + + req, err := http.NewRequest("POST", executeCallUrl, bytes.NewBuffer(jsonPayload)) + require.NoError(t, err) + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+consumeSecret) + + client := &http.Client{} + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + + // Check if the job was executed successfully + require.Equal(t, "rejection", result["resultType"]) + require.Equal(t, "success", result["status"]) + require.Equal(t, "test error", result["result"]) +} diff --git a/sdk-node/.husky/pre-commit b/sdk-node/.husky/pre-commit new file mode 100644 index 00000000..bd73ce04 --- /dev/null +++ b/sdk-node/.husky/pre-commit @@ -0,0 +1,2 @@ +npx lint-staged + diff --git a/sdk-node/README.md b/sdk-node/README.md new file mode 100644 index 00000000..c5e2d885 --- /dev/null +++ b/sdk-node/README.md @@ -0,0 +1,119 @@ +

+ +

+ +# Typescript SDK + +[![npm version](https://badge.fury.io/js/inferable.svg)](https://badge.fury.io/js/inferable) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Documentation](https://img.shields.io/badge/docs-inferable.ai-brightgreen)](https://docs.inferable.ai/) +[![Downloads](https://img.shields.io/npm/dm/inferable)](https://www.npmjs.com/package/inferable) + +This is the official Inferable AI SDK for Typescript. + +## Installation + +### npm + +```bash +npm install inferable +``` + +### yarn + +```bash +yarn add inferable +``` + +### pnpm + +```bash +pnpm add inferable +``` + +## Quick Start + +### 1. Initializing Inferable + +Create a file named i.ts which will be used to initialize Inferable. This file will export the Inferable instance. + +```typescript +// d.ts + +import { Inferable } from "inferable"; + +// Initialize the Inferable client with your API secret. +// Get yours at https://console.inferable.ai. +export const d = new Inferable({ + apiSecret: "YOUR_API_SECRET", +}); +``` + +### 2. Hello World Function + +In a separate file, register a "sayHello" [function](https://docs.inferable.ai/pages/functions). This file will import the Inferable instance from `i.ts` and register the [function](https://docs.inferable.ai/pages/functions) with the [control-plane](https://docs.inferable.ai/pages/control-plane). + +```typescript +// service.ts + +import { i } from "./i"; + +// Define a simple function that returns "Hello, World!" +const sayHello = async ({ to }: { to: string }) => { + return `Hello, ${to}!`; +}; + +// Register the service (using the 'default' service) +const sayHello = i.default.register({ + name: "sayHello", + func: sayHello, + schema: { + input: z.object({ + to: z.string(), + }), + }, +}); + +// Start the 'default' service +i.default.start(); +``` + +### 3. Running the Service + +To run the service, simply run the file with the [function](https://docs.inferable.ai/pages/functions) definition. This will start the `default` [service](https://docs.inferable.ai/pages/services) and make it available to the Inferable agent. + +```bash +tsx service.ts +``` + +### 4. Trigger a run + +The following code will create an [Inferable run](https://docs.inferable.ai/pages/runs) with the prompt "Say hello to John" and the `sayHello` function attached. + +> You can inspect the progress of the run: +> +> - in the [playground UI](https://app.inferable.ai/) via `inf app` +> - in the [CLI](https://www.npmjs.com/package/@inferable/cli) via `inf runs list` + +```typescript +const run = await i.run({ + message: "Say hello to John", + functions: [sayHello], + // Alternatively, subscribe an Inferable function as a result handler which will be called when the run is complete. + //result: { handler: YOUR_HANDLER_FUNCTION } +}); + +console.log("Started Run", { + result: run.id, +}); + +console.log("Run result", { + result: await run.poll(), +}); +``` + +> Runs can also be triggered via the [API](https://docs.inferable.ai/pages/invoking-a-run-api), [CLI](https://www.npmjs.com/package/@inferable/cli) or [playground UI](https://app.inferable.ai/). + +## Documentation + +- [Inferable documentation](https://docs.inferable.ai/) contains all the information you need to get started with Inferable. diff --git a/workflows/.github/workflows/publish.yml b/workflows/.github/workflows/publish.yml new file mode 100644 index 00000000..8d4135e0 --- /dev/null +++ b/workflows/.github/workflows/publish.yml @@ -0,0 +1,45 @@ +name: Publish + +on: + workflow_run: + workflows: + - Build and Test + types: + - completed + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-publish: + if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main' }} + runs-on: ubuntu-latest + + permissions: + contents: write + + steps: + - name: Checkout code + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Install dependencies on root + run: npm ci + + - name: Build packages + run: npm run build + + - name: Configure Git User + run: | + git config --global user.name "Inferable CI" + git config --global user.email "ci@inferable.ai" + + - name: Release It + run: | + npm config set //registry.npmjs.org/:_authToken $NPM_TOKEN + npx release-it --npm.skipChecks + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/workflows/.github/workflows/test.yml b/workflows/.github/workflows/test.yml new file mode 100644 index 00000000..d2dc56eb --- /dev/null +++ b/workflows/.github/workflows/test.yml @@ -0,0 +1,42 @@ +name: Build and Test + +on: + push: + branches: + - main + pull_request: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Set up Node.js + uses: actions/setup-node@v2 + with: + node-version: 20 + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build packages + run: npm run build + + - name: Run tests + run: npm run test + env: + INFERABLE_API_ENDPOINT: 'https://api.inferable.ai' + INFERABLE_CLUSTER_ID: ${{ vars.INFERABLE_CLUSTER_ID }} + INFERABLE_MACHINE_SECRET: ${{ secrets.INFERABLE_MACHINE_SECRET }} + INFERABLE_CONSUME_SECRET: ${{ secrets.INFERABLE_CONSUME_SECRET }} + From 48925d59c87e96403140e3f41936b121ae0e4a82 Mon Sep 17 00:00:00 2001 From: Nadeesha Cabral Date: Fri, 25 Oct 2024 14:14:24 +1100 Subject: [PATCH 03/12] update --- .github/workflows/build.yml | 103 ++++++++++++++++++ grand-unifier.sh | 10 +- workflows/.github/workflows/test.yml | 42 ------- .../dotnet}/.github/workflows/build.yaml | 0 .../dotnet}/.github/workflows/publish.yaml | 0 .../go}/.github/workflows/build.yml | 0 .../go}/.github/workflows/publish.yml | 0 .../node/.github/workflows/build.yml | 0 .../{ => node}/.github/workflows/publish.yml | 0 9 files changed, 111 insertions(+), 44 deletions(-) create mode 100644 .github/workflows/build.yml delete mode 100644 workflows/.github/workflows/test.yml rename {sdk-dotnet => workflows/dotnet}/.github/workflows/build.yaml (100%) rename {sdk-dotnet => workflows/dotnet}/.github/workflows/publish.yaml (100%) rename {sdk-go => workflows/go}/.github/workflows/build.yml (100%) rename {sdk-go => workflows/go}/.github/workflows/publish.yml (100%) rename .github/workflows/test.yml => workflows/node/.github/workflows/build.yml (100%) rename workflows/{ => node}/.github/workflows/publish.yml (100%) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..7d673882 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,103 @@ +name: Build and Test + +on: + push: + branches: + - main + pull_request: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-test-node: + runs-on: ubuntu-latest + defaults: + run: + working-directory: sdk-node + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: 20 + cache: "npm" + cache-dependency-path: sdk-node/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Build package + run: npm run build + + - name: Run tests + run: npm run test + env: + INFERABLE_API_ENDPOINT: "https://api.inferable.ai" + INFERABLE_CLUSTER_ID: ${{ vars.INFERABLE_CLUSTER_ID }} + INFERABLE_MACHINE_SECRET: ${{ secrets.INFERABLE_MACHINE_SECRET }} + INFERABLE_CONSUME_SECRET: ${{ secrets.INFERABLE_CONSUME_SECRET }} + + build-and-test-dotnet: + runs-on: windows-latest + defaults: + run: + working-directory: sdk-dotnet + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: "8.0.x" + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --configuration Release --no-restore + + - name: Test + run: dotnet test --no-restore + env: + INFERABLE_API_ENDPOINT: "https://api.inferable.ai" + INFERABLE_CLUSTER_ID: ${{ vars.INFERABLE_CLUSTER_ID }} + INFERABLE_MACHINE_SECRET: ${{ secrets.INFERABLE_MACHINE_SECRET }} + INFERABLE_CONSUME_SECRET: ${{ secrets.INFERABLE_CONSUME_SECRET }} + + build-and-test-go: + runs-on: ubuntu-latest + defaults: + run: + working-directory: sdk-go + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: "1.22" + + - name: Get dependencies + run: go mod download + + - name: Build + run: go build -v ./... + + - name: Test + run: go test -v ./... + env: + INFERABLE_API_ENDPOINT: "https://api.inferable.ai" + INFERABLE_CLUSTER_ID: ${{ vars.INFERABLE_CLUSTER_ID }} + INFERABLE_MACHINE_SECRET: ${{ secrets.INFERABLE_MACHINE_SECRET }} + INFERABLE_CONSUME_SECRET: ${{ secrets.INFERABLE_CONSUME_SECRET }} diff --git a/grand-unifier.sh b/grand-unifier.sh index 777f553a..f2d879fa 100644 --- a/grand-unifier.sh +++ b/grand-unifier.sh @@ -1,23 +1,29 @@ rm -rf sdk-node git clone git@github.com:inferablehq/inferable-node.git sdk-node rm -rf sdk-node/.git -mv sdk-node/.github workflows/ +mkdir -p workflows/node +mv sdk-node/.github workflows/node/ rm -rf sdk-node/.editorconfig rm -rf sdk-go git clone git@github.com:inferablehq/inferable-go.git sdk-go rm -rf sdk-go/.git +mkdir -p workflows/go +mv sdk-go/.github workflows/go/ mv sdk-go/.github workflows/ rm -rf sdk-go/.editorconfig rm -rf sdk-bash git clone git@github.com:inferablehq/inferable-bash.git sdk-bash rm -rf sdk-bash/.git +mkdir -p workflows/bash +mv sdk-bash/.github workflows/bash/ mv sdk-bash/.github workflows/ rm -rf sdk-bash/.editorconfig rm -rf sdk-dotnet git clone git@github.com:inferablehq/inferable-dotnet.git sdk-dotnet rm -rf sdk-dotnet/.git -mv sdk-dotnet/.github workflows/ +mkdir -p workflows/dotnet +mv sdk-dotnet/.github workflows/dotnet/ rm -rf sdk-dotnet/.editorconfig diff --git a/workflows/.github/workflows/test.yml b/workflows/.github/workflows/test.yml deleted file mode 100644 index d2dc56eb..00000000 --- a/workflows/.github/workflows/test.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: Build and Test - -on: - push: - branches: - - main - pull_request: - branches: - - main - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - build-and-test: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v2 - - - name: Set up Node.js - uses: actions/setup-node@v2 - with: - node-version: 20 - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Build packages - run: npm run build - - - name: Run tests - run: npm run test - env: - INFERABLE_API_ENDPOINT: 'https://api.inferable.ai' - INFERABLE_CLUSTER_ID: ${{ vars.INFERABLE_CLUSTER_ID }} - INFERABLE_MACHINE_SECRET: ${{ secrets.INFERABLE_MACHINE_SECRET }} - INFERABLE_CONSUME_SECRET: ${{ secrets.INFERABLE_CONSUME_SECRET }} - diff --git a/sdk-dotnet/.github/workflows/build.yaml b/workflows/dotnet/.github/workflows/build.yaml similarity index 100% rename from sdk-dotnet/.github/workflows/build.yaml rename to workflows/dotnet/.github/workflows/build.yaml diff --git a/sdk-dotnet/.github/workflows/publish.yaml b/workflows/dotnet/.github/workflows/publish.yaml similarity index 100% rename from sdk-dotnet/.github/workflows/publish.yaml rename to workflows/dotnet/.github/workflows/publish.yaml diff --git a/sdk-go/.github/workflows/build.yml b/workflows/go/.github/workflows/build.yml similarity index 100% rename from sdk-go/.github/workflows/build.yml rename to workflows/go/.github/workflows/build.yml diff --git a/sdk-go/.github/workflows/publish.yml b/workflows/go/.github/workflows/publish.yml similarity index 100% rename from sdk-go/.github/workflows/publish.yml rename to workflows/go/.github/workflows/publish.yml diff --git a/.github/workflows/test.yml b/workflows/node/.github/workflows/build.yml similarity index 100% rename from .github/workflows/test.yml rename to workflows/node/.github/workflows/build.yml diff --git a/workflows/.github/workflows/publish.yml b/workflows/node/.github/workflows/publish.yml similarity index 100% rename from workflows/.github/workflows/publish.yml rename to workflows/node/.github/workflows/publish.yml From e0e60c626c3dd398cf120fdbaff316afded19177 Mon Sep 17 00:00:00 2001 From: Nadeesha Cabral Date: Fri, 25 Oct 2024 14:26:20 +1100 Subject: [PATCH 04/12] update --- .github/workflows/build.yml | 74 ++++++++++++++++++++++++++++--------- 1 file changed, 56 insertions(+), 18 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7d673882..29ebe3db 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,91 +13,129 @@ concurrency: cancel-in-progress: true jobs: - build-and-test-node: + build-node: runs-on: ubuntu-latest defaults: run: working-directory: sdk-node - steps: - name: Checkout code uses: actions/checkout@v3 - - name: Set up Node.js uses: actions/setup-node@v3 with: node-version: 20 cache: "npm" cache-dependency-path: sdk-node/package-lock.json - - name: Install dependencies run: npm ci - - name: Build package run: npm run build + test-node: + needs: build-node + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [18.x, 20.x, 21.x] + defaults: + run: + working-directory: sdk-node + steps: + - name: Checkout code + uses: actions/checkout@v3 + - name: Set up Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + cache: "npm" + cache-dependency-path: sdk-node/package-lock.json + - name: Install dependencies + run: npm ci - name: Run tests run: npm run test env: INFERABLE_API_ENDPOINT: "https://api.inferable.ai" - INFERABLE_CLUSTER_ID: ${{ vars.INFERABLE_CLUSTER_ID }} + INFERABLE_CLUSTER_ID: ${{ secrets.INFERABLE_CLUSTER_ID }} INFERABLE_MACHINE_SECRET: ${{ secrets.INFERABLE_MACHINE_SECRET }} INFERABLE_CONSUME_SECRET: ${{ secrets.INFERABLE_CONSUME_SECRET }} - build-and-test-dotnet: + build-dotnet: runs-on: windows-latest defaults: run: working-directory: sdk-dotnet - steps: - name: Checkout code uses: actions/checkout@v3 - - name: Set up .NET uses: actions/setup-dotnet@v3 with: dotnet-version: "8.0.x" - - name: Restore dependencies run: dotnet restore - - name: Build run: dotnet build --configuration Release --no-restore + test-dotnet: + needs: build-dotnet + runs-on: windows-latest + defaults: + run: + working-directory: sdk-dotnet + steps: + - name: Checkout code + uses: actions/checkout@v3 + - name: Set up .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: "8.0.x" + - name: Restore dependencies + run: dotnet restore - name: Test run: dotnet test --no-restore env: INFERABLE_API_ENDPOINT: "https://api.inferable.ai" - INFERABLE_CLUSTER_ID: ${{ vars.INFERABLE_CLUSTER_ID }} + INFERABLE_CLUSTER_ID: ${{ secrets.INFERABLE_CLUSTER_ID }} INFERABLE_MACHINE_SECRET: ${{ secrets.INFERABLE_MACHINE_SECRET }} INFERABLE_CONSUME_SECRET: ${{ secrets.INFERABLE_CONSUME_SECRET }} - build-and-test-go: + build-go: runs-on: ubuntu-latest defaults: run: working-directory: sdk-go - steps: - name: Checkout code uses: actions/checkout@v3 - - name: Set up Go uses: actions/setup-go@v4 with: go-version: "1.22" - - name: Get dependencies run: go mod download - - name: Build run: go build -v ./... + test-go: + needs: build-go + runs-on: ubuntu-latest + defaults: + run: + working-directory: sdk-go + steps: + - name: Checkout code + uses: actions/checkout@v3 + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: "1.22" + - name: Get dependencies + run: go mod download - name: Test run: go test -v ./... env: INFERABLE_API_ENDPOINT: "https://api.inferable.ai" - INFERABLE_CLUSTER_ID: ${{ vars.INFERABLE_CLUSTER_ID }} + INFERABLE_CLUSTER_ID: ${{ secrets.INFERABLE_CLUSTER_ID }} INFERABLE_MACHINE_SECRET: ${{ secrets.INFERABLE_MACHINE_SECRET }} INFERABLE_CONSUME_SECRET: ${{ secrets.INFERABLE_CONSUME_SECRET }} From d98284a004ce9ac39a0c3393ae198be3effcf3b4 Mon Sep 17 00:00:00 2001 From: Nadeesha Cabral Date: Fri, 25 Oct 2024 14:34:42 +1100 Subject: [PATCH 05/12] update --- .github/workflows/build.yml | 34 ++++- .github/workflows/publish.yml | 143 ++++++++++++++++++++-- sdk-dotnet/.github/workflows/build.yaml | 34 +++++ sdk-dotnet/.github/workflows/publish.yaml | 75 ++++++++++++ sdk-node/.github/workflows/publish.yml | 45 +++++++ sdk-node/.github/workflows/test.yml | 42 +++++++ sdk-node/package-lock.json | 4 +- sdk-node/package.json | 2 +- sdk-node/src/Inferable.ts | 124 +++++++++++++------ sdk-node/src/contract.ts | 31 +++++ workflows/.github/workflows/build.yml | 36 ++++++ workflows/.github/workflows/publish.yml | 57 +++++++++ 12 files changed, 575 insertions(+), 52 deletions(-) create mode 100644 sdk-dotnet/.github/workflows/build.yaml create mode 100644 sdk-dotnet/.github/workflows/publish.yaml create mode 100644 sdk-node/.github/workflows/publish.yml create mode 100644 sdk-node/.github/workflows/test.yml create mode 100644 workflows/.github/workflows/build.yml create mode 100644 workflows/.github/workflows/publish.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 29ebe3db..2510a080 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,7 +13,28 @@ concurrency: cancel-in-progress: true jobs: + check_changes: + runs-on: ubuntu-latest + outputs: + sdk_node: ${{ steps.filter.outputs.sdk_node }} + sdk_dotnet: ${{ steps.filter.outputs.sdk_dotnet }} + sdk_go: ${{ steps.filter.outputs.sdk_go }} + steps: + - uses: actions/checkout@v3 + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + sdk_node: + - 'sdk-node/**' + sdk_dotnet: + - 'sdk-dotnet/**' + sdk_go: + - 'sdk-go/**' + build-node: + needs: check_changes + if: ${{ needs.check_changes.outputs.sdk_node == 'true' }} runs-on: ubuntu-latest defaults: run: @@ -33,7 +54,8 @@ jobs: run: npm run build test-node: - needs: build-node + needs: [check_changes, build-node] + if: ${{ needs.check_changes.outputs.sdk_node == 'true' }} runs-on: ubuntu-latest strategy: matrix: @@ -61,6 +83,8 @@ jobs: INFERABLE_CONSUME_SECRET: ${{ secrets.INFERABLE_CONSUME_SECRET }} build-dotnet: + needs: check_changes + if: ${{ needs.check_changes.outputs.sdk_dotnet == 'true' }} runs-on: windows-latest defaults: run: @@ -78,7 +102,8 @@ jobs: run: dotnet build --configuration Release --no-restore test-dotnet: - needs: build-dotnet + needs: [check_changes, build-dotnet] + if: ${{ needs.check_changes.outputs.sdk_dotnet == 'true' }} runs-on: windows-latest defaults: run: @@ -101,6 +126,8 @@ jobs: INFERABLE_CONSUME_SECRET: ${{ secrets.INFERABLE_CONSUME_SECRET }} build-go: + needs: check_changes + if: ${{ needs.check_changes.outputs.sdk_go == 'true' }} runs-on: ubuntu-latest defaults: run: @@ -118,7 +145,8 @@ jobs: run: go build -v ./... test-go: - needs: build-go + needs: [check_changes, build-go] + if: ${{ needs.check_changes.outputs.sdk_go == 'true' }} runs-on: ubuntu-latest defaults: run: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8d4135e0..b82f96ce 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,30 +12,56 @@ concurrency: cancel-in-progress: true jobs: - build-and-publish: - if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main' }} + check_changes: runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main' }} + outputs: + sdk_node: ${{ steps.filter.outputs.sdk_node }} + sdk_dotnet: ${{ steps.filter.outputs.sdk_dotnet }} + sdk_go: ${{ steps.filter.outputs.sdk_go }} + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + sdk_node: + - 'sdk-node/**' + sdk_dotnet: + - 'sdk-dotnet/**' + sdk_go: + - 'sdk-go/**' + publish-node: + needs: check_changes + if: ${{ needs.check_changes.outputs.sdk_node == 'true' }} + runs-on: ubuntu-latest permissions: contents: write - + defaults: + run: + working-directory: sdk-node steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: fetch-depth: 0 - - - name: Install dependencies on root + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: 20 + cache: "npm" + cache-dependency-path: sdk-node/package-lock.json + - name: Install dependencies run: npm ci - - - name: Build packages + - name: Build package run: npm run build - - name: Configure Git User run: | git config --global user.name "Inferable CI" git config --global user.email "ci@inferable.ai" - - name: Release It run: | npm config set //registry.npmjs.org/:_authToken $NPM_TOKEN @@ -43,3 +69,100 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + publish-dotnet: + needs: check_changes + if: ${{ needs.check_changes.outputs.sdk_dotnet == 'true' }} + runs-on: windows-latest + permissions: + contents: write + defaults: + run: + working-directory: sdk-dotnet + steps: + - name: Checkout code + uses: actions/checkout@v3 + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: "8.0.x" + - name: Restore dependencies + run: dotnet restore + - name: Build + run: dotnet build --configuration Release --no-restore + - name: Test + run: dotnet test --no-restore + - name: Pack + run: dotnet pack --configuration Release --no-restore --output ./output + - name: Setup NuGet + uses: nuget/setup-nuget@v1 + with: + nuget-api-key: ${{ secrets.NUGET_API_KEY }} + nuget-version: latest + - name: Publish + run: dotnet nuget push output\*.nupkg -s https://api.nuget.org/v3/index.json + - name: Extract version from NuGet package + id: extract_version + shell: pwsh + run: | + $nupkg = Get-ChildItem -Path ./output -Filter *.nupkg | Select-Object -First 1 + if ($nupkg) { + $version = $nupkg.Name -replace '^[a-zA-Z0-9.-]+\.([0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+)?)\.nupkg$', '$1' + echo "version=$version" >> $GITHUB_OUTPUT + } else { + Write-Error "No .nupkg file found." + } + - name: Create release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ steps.extract_version.outputs.version }} + release_name: ${{ steps.extract_version.outputs.version }} + + publish-go: + needs: check_changes + if: ${{ needs.check_changes.outputs.sdk_go == 'true' }} + runs-on: ubuntu-latest + permissions: + contents: write + defaults: + run: + working-directory: sdk-go + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: "1.22" + - name: Get current version + id: get_version + run: | + VERSION=$(grep -oP 'const Version = "\K[^"]+' inferable.go) + echo "current_version=$VERSION" >> $GITHUB_OUTPUT + - name: Increment patch version + id: increment_version + run: | + IFS='.' read -ra VERSION_PARTS <<< "${{ steps.get_version.outputs.current_version }}" + MAJOR=${VERSION_PARTS[0]} + MINOR=${VERSION_PARTS[1]} + PATCH=$((VERSION_PARTS[2] + 1)) + NEW_VERSION="$MAJOR.$MINOR.$PATCH" + echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT + - name: Update version in code + run: | + sed -i 's/const Version = "[^"]*"/const Version = "${{ steps.increment_version.outputs.new_version }}"/' inferable.go + - name: Commit and push changes + run: | + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + git add inferable.go + git commit -m "Bump version to ${{ steps.increment_version.outputs.new_version }}" + git push + - name: Create Git tag + run: | + git tag v${{ steps.increment_version.outputs.new_version }} + git push origin v${{ steps.increment_version.outputs.new_version }} diff --git a/sdk-dotnet/.github/workflows/build.yaml b/sdk-dotnet/.github/workflows/build.yaml new file mode 100644 index 00000000..299e3db6 --- /dev/null +++ b/sdk-dotnet/.github/workflows/build.yaml @@ -0,0 +1,34 @@ +name: Build and Test .NET Project + +on: + pull_request: + branches: + - main + +jobs: + build: + runs-on: windows-latest + + env: + INFERABLE_API_ENDPOINT: 'https://api.inferable.ai' + INFERABLE_CLUSTER_ID: ${{ vars.INFERABLE_CLUSTER_ID }} + INFERABLE_MACHINE_SECRET: ${{ secrets.INFERABLE_MACHINE_SECRET }} + INFERABLE_CONSUME_SECRET: ${{ secrets.INFERABLE_CONSUME_SECRET }} + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Setup .NET + uses: actions/setup-dotnet@v2 + with: + dotnet-version: '8.0' + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --configuration Release --no-restore + + - name: Test + run: dotnet test --no-restore diff --git a/sdk-dotnet/.github/workflows/publish.yaml b/sdk-dotnet/.github/workflows/publish.yaml new file mode 100644 index 00000000..2518ab97 --- /dev/null +++ b/sdk-dotnet/.github/workflows/publish.yaml @@ -0,0 +1,75 @@ +name: Build and Publish .NET Project + +on: + push: + branches: + - main + +jobs: + publish: + runs-on: windows-latest + + env: + INFERABLE_API_ENDPOINT: 'https://api.inferable.ai' + INFERABLE_CLUSTER_ID: ${{ vars.INFERABLE_CLUSTER_ID }} + INFERABLE_MACHINE_SECRET: ${{ secrets.INFERABLE_MACHINE_SECRET }} + INFERABLE_CONSUME_SECRET: ${{ secrets.INFERABLE_CONSUME_SECRET }} + + permissions: + contents: write + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Setup .NET + uses: actions/setup-dotnet@v2 + with: + dotnet-version: '8.0' + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --configuration Release --no-restore + + - name: Test + run: dotnet test --no-restore + + - name: Pack + run: dotnet pack --configuration Release --no-restore --output ./output + + - name: Setup NuGet + uses: nuget/setup-nuget@v1 + with: + nuget-api-key: ${{ secrets.NUGET_API_KEY }} + nuget-version: latest + + - name: Publish + if: github.ref == 'refs/heads/main' + run: | + dotnet nuget push output\*.nupkg -s https://api.nuget.org/v3/index.json + + - name: Extract version from NuGet package + id: extract_version + shell: pwsh + run: | + # Get the first .nupkg file in the output directory + $nupkg = Get-ChildItem -Path ./output -Filter *.nupkg | Select-Object -First 1 + + # Extract the version from the filename + if ($nupkg) { + $version = $nupkg.Name -replace '^[a-zA-Z0-9.-]+\.([0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+)?)\.nupkg$', '$1' + Write-Host "Extracted version: $version" + echo "::set-output name=version::$version" + } else { + Write-Error "No .nupkg file found." + } + + - name: Create release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ steps.extract_version.outputs.version }} + release_name: ${{ steps.extract_version.outputs.version }} diff --git a/sdk-node/.github/workflows/publish.yml b/sdk-node/.github/workflows/publish.yml new file mode 100644 index 00000000..8d4135e0 --- /dev/null +++ b/sdk-node/.github/workflows/publish.yml @@ -0,0 +1,45 @@ +name: Publish + +on: + workflow_run: + workflows: + - Build and Test + types: + - completed + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-publish: + if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main' }} + runs-on: ubuntu-latest + + permissions: + contents: write + + steps: + - name: Checkout code + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Install dependencies on root + run: npm ci + + - name: Build packages + run: npm run build + + - name: Configure Git User + run: | + git config --global user.name "Inferable CI" + git config --global user.email "ci@inferable.ai" + + - name: Release It + run: | + npm config set //registry.npmjs.org/:_authToken $NPM_TOKEN + npx release-it --npm.skipChecks + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/sdk-node/.github/workflows/test.yml b/sdk-node/.github/workflows/test.yml new file mode 100644 index 00000000..d2dc56eb --- /dev/null +++ b/sdk-node/.github/workflows/test.yml @@ -0,0 +1,42 @@ +name: Build and Test + +on: + push: + branches: + - main + pull_request: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Set up Node.js + uses: actions/setup-node@v2 + with: + node-version: 20 + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build packages + run: npm run build + + - name: Run tests + run: npm run test + env: + INFERABLE_API_ENDPOINT: 'https://api.inferable.ai' + INFERABLE_CLUSTER_ID: ${{ vars.INFERABLE_CLUSTER_ID }} + INFERABLE_MACHINE_SECRET: ${{ secrets.INFERABLE_MACHINE_SECRET }} + INFERABLE_CONSUME_SECRET: ${{ secrets.INFERABLE_CONSUME_SECRET }} + diff --git a/sdk-node/package-lock.json b/sdk-node/package-lock.json index 99606551..b7f5b547 100644 --- a/sdk-node/package-lock.json +++ b/sdk-node/package-lock.json @@ -1,12 +1,12 @@ { "name": "inferable", - "version": "0.30.22", + "version": "0.30.23", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "inferable", - "version": "0.30.22", + "version": "0.30.23", "license": "MIT", "dependencies": { "@ts-rest/core": "^3.28.0", diff --git a/sdk-node/package.json b/sdk-node/package.json index 47ac760e..055b5ba1 100644 --- a/sdk-node/package.json +++ b/sdk-node/package.json @@ -1,6 +1,6 @@ { "name": "inferable", - "version": "0.30.22", + "version": "0.30.23", "description": "Javascript SDK for inferable.ai", "main": "bin/index.js", "scripts": { diff --git a/sdk-node/src/Inferable.ts b/sdk-node/src/Inferable.ts index fe6c2509..9cbf7df6 100644 --- a/sdk-node/src/Inferable.ts +++ b/sdk-node/src/Inferable.ts @@ -2,13 +2,15 @@ import debug from "debug"; import path from "path"; import { z } from "zod"; import zodToJsonSchema from "zod-to-json-schema"; +import { createApiClient } from "./create-client"; import { InferableError } from "./errors"; -import { FunctionRegistration } from "./types"; +import * as links from "./links"; import { machineId } from "./machine-id"; import { Service } from "./service"; import { FunctionConfig, FunctionInput, + FunctionRegistration, FunctionRegistrationInput, JsonSchemaInput, RegisteredService, @@ -20,8 +22,6 @@ import { validateFunctionSchema, validateServiceName, } from "./util"; -import * as links from "./links"; -import { createApiClient } from "./create-client"; // Custom json formatter debug.formatters.J = (json) => { @@ -43,9 +43,9 @@ type RunInput = { Parameters["createRun"]>[0] >["body"], "attachedFunctions" ->; +> & { id?: string }; -type TemplateRunInput = Omit & { +type TemplateRunInput = Omit & { input: Record; }; @@ -226,35 +226,69 @@ export class Inferable { } /** - * Returns a template instance. This can be used to trigger runs of a template. - * @param input The template definition. + * Registers or references a template instance. This can be used to trigger runs of a template. + * @param input The template definition or reference. * @returns A registered template instance. * @example * ```ts * const d = new Inferable({apiSecret: "API_SECRET"}); * - * const template = await d.template({ id: "template-id" }); + * const template = await d.template({ + * id: "new-template-id", + * name: "my-template", + * attachedFunctions: ["my-service.hello"], + * prompt: "Hello {{name}}", + * structuredOutput: { greeting: z.string() } + * }); * - * await template.run({ input: { name: "John Smith" } }); + * await template.run({ input: { name: "Jane Doe" } }); * ``` */ - public async template({ id }: { id: string }) { + public async template({ + id, + attachedFunctions, + name, + prompt, + structuredOutput, + }: { + id: string; + attachedFunctions: string[]; + name: string; + prompt: string; + structuredOutput: z.ZodTypeAny; + }) { if (!this.clusterId) { throw new InferableError( "Cluster ID must be provided to manage templates", ); } - const existingResult = await this.client.getPromptTemplate({ + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let jsonSchema: any; + + try { + jsonSchema = zodToJsonSchema(structuredOutput); + } catch (e) { + throw new InferableError("structuredOutput must be a valid JSON schema"); + } + + const upserted = await this.client.upsertPromptTemplate({ + body: { + id, + attachedFunctions, + name, + prompt, + structuredOutput: jsonSchema, + }, params: { clusterId: this.clusterId, - templateId: id, }, }); - if (existingResult.status != 200) { - throw new InferableError(`Failed to get prompt template`, { - body: existingResult.body, - status: existingResult.status, + if (upserted.status != 200) { + throw new InferableError(`Failed to register prompt template`, { + body: upserted.body, + status: upserted.status, }); } @@ -289,34 +323,52 @@ export class Inferable { if (!this.clusterId) { throw new InferableError("Cluster ID must be provided to manage runs"); } - const runResult = await this.client.createRun({ - params: { - clusterId: this.clusterId, - }, - body: { - ...input, - attachedFunctions: input.functions?.map((f) => { - if (typeof f === "string") { - return f; - } - return `${f.service}_${f.function}`; - }), - }, - }); - if (runResult.status != 201) { - throw new InferableError("Failed to create run", { - body: runResult.body, - status: runResult.status, + let runResult; + if (input.id) { + runResult = await this.client.getRun({ + params: { + clusterId: this.clusterId, + runId: input.id, + }, }); + + if (runResult.status != 200) { + throw new InferableError("Failed to get existing run", { + body: runResult.body, + status: runResult.status, + }); + } + } else { + runResult = await this.client.createRun({ + params: { + clusterId: this.clusterId, + }, + body: { + ...input, + attachedFunctions: input.functions?.map((f) => { + if (typeof f === "string") { + return f; + } + return `${f.service}_${f.function}`; + }), + }, + }); + + if (runResult.status != 201) { + throw new InferableError("Failed to create run", { + body: runResult.body, + status: runResult.status, + }); + } } return { id: runResult.body.id, /** * Polls until the run reaches a terminal state (!= "pending" && != "running") or maxWaitTime is reached. - * @param maxWaitTime The maximum amount of time to wait for the run to reach a terminal state. - * @param delay The amount of time to wait between polling attempts. + * @param maxWaitTime The maximum amount of time to wait for the run to reach a terminal state. Defaults to 60 seconds. + * @param delay The amount of time to wait between polling attempts. Defaults to 500ms. */ poll: async (maxWaitTime?: number, delay?: number) => { const start = Date.now(); diff --git a/sdk-node/src/contract.ts b/sdk-node/src/contract.ts index 28ee1401..6953ba8c 100644 --- a/sdk-node/src/contract.ts +++ b/sdk-node/src/contract.ts @@ -12,6 +12,9 @@ const machineHeaders = { "x-sentinel-unmask-keys": z.string().optional(), }; +// Alphanumeric, underscore, hyphen, no whitespace. From 6 to 128 characters. +const userDefinedIdRegex = /^[a-zA-Z0-9_-]{6,128}$/; + export const blobSchema = z.object({ id: z.string(), name: z.string(), @@ -1184,6 +1187,34 @@ export const definition = { clusterId: z.string(), }), }, + upsertPromptTemplate: { + method: "PUT", + path: "/clusters/:clusterId/prompt-templates", + headers: z.object({ authorization: z.string() }), + body: z.object({ + id: z.string().regex(userDefinedIdRegex), + name: z.string(), + prompt: z.string(), + attachedFunctions: z.array(z.string()), + structuredOutput: z.object({}).passthrough().optional(), + }), + responses: { + 201: z.object({ + id: z.string(), + clusterId: z.string(), + name: z.string(), + prompt: z.string(), + attachedFunctions: z.array(z.string()), + structuredOutput: z.unknown().nullable(), + createdAt: z.date(), + updatedAt: z.date(), + }), + 401: z.undefined(), + }, + pathParams: z.object({ + clusterId: z.string(), + }), + }, getPromptTemplate: { method: "GET", path: "/clusters/:clusterId/prompt-templates/:templateId", diff --git a/workflows/.github/workflows/build.yml b/workflows/.github/workflows/build.yml new file mode 100644 index 00000000..f7b4bcef --- /dev/null +++ b/workflows/.github/workflows/build.yml @@ -0,0 +1,36 @@ +name: Go Build and Test + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + name: Build and Test + runs-on: ubuntu-latest + + env: + INFERABLE_API_ENDPOINT: 'https://api.inferable.ai' + INFERABLE_CLUSTER_ID: ${{ vars.INFERABLE_CLUSTER_ID }} + INFERABLE_MACHINE_SECRET: ${{ secrets.INFERABLE_MACHINE_SECRET }} + INFERABLE_CONSUME_SECRET: ${{ secrets.INFERABLE_CONSUME_SECRET }} + + steps: + - name: Check out code + uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: "1.22" # Use the Go version specified in your go.mod file + + - name: Get dependencies + run: go mod download + + - name: Build + run: go build -v ./... + + - name: Test + run: go test -v ./... diff --git a/workflows/.github/workflows/publish.yml b/workflows/.github/workflows/publish.yml new file mode 100644 index 00000000..93ad0cf2 --- /dev/null +++ b/workflows/.github/workflows/publish.yml @@ -0,0 +1,57 @@ +name: Publish Go Package + +on: + push: + branches: [main] + +jobs: + publish: + name: Publish + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Check out code + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: "1.22" + + - name: Get current version + id: get_version + run: | + VERSION=$(grep -oP 'const Version = "\K[^"]+' inferable.go) + echo "Current version: $VERSION" + echo "current_version=$VERSION" >> $GITHUB_OUTPUT + + - name: Increment patch version + id: increment_version + run: | + IFS='.' read -ra VERSION_PARTS <<< "${{ steps.get_version.outputs.current_version }}" + MAJOR=${VERSION_PARTS[0]} + MINOR=${VERSION_PARTS[1]} + PATCH=$((VERSION_PARTS[2] + 1)) + NEW_VERSION="$MAJOR.$MINOR.$PATCH" + echo "New version: $NEW_VERSION" + echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT + + - name: Update version in code + run: | + sed -i 's/const Version = "[^"]*"/const Version = "${{ steps.increment_version.outputs.new_version }}"/' inferable.go + + - name: Commit and push changes + run: | + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + git add inferable.go + git commit -m "Bump version to ${{ steps.increment_version.outputs.new_version }}" + git push + + - name: Create Git tag + run: | + git tag v${{ steps.increment_version.outputs.new_version }} + git push origin v${{ steps.increment_version.outputs.new_version }} From 113b014da80be7cf80233add62e4fcd544d8ce68 Mon Sep 17 00:00:00 2001 From: Nadeesha Cabral Date: Fri, 25 Oct 2024 16:18:23 +1100 Subject: [PATCH 06/12] update --- .github/workflows/build.yml | 2 ++ .github/workflows/publish.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2510a080..4f921059 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -15,6 +15,8 @@ concurrency: jobs: check_changes: runs-on: ubuntu-latest + permissions: + pull-requests: read outputs: sdk_node: ${{ steps.filter.outputs.sdk_node }} sdk_dotnet: ${{ steps.filter.outputs.sdk_dotnet }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b82f96ce..0c6f9ad5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,6 +14,8 @@ concurrency: jobs: check_changes: runs-on: ubuntu-latest + permissions: + pull-requests: read if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main' }} outputs: sdk_node: ${{ steps.filter.outputs.sdk_node }} From 16a9f03b739435c10223f4fcb4efcf085421956f Mon Sep 17 00:00:00 2001 From: Nadeesha Cabral Date: Fri, 25 Oct 2024 16:23:06 +1100 Subject: [PATCH 07/12] update --- .github/workflows/publish.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0c6f9ad5..9e42cdbd 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -27,6 +27,7 @@ jobs: fetch-depth: 0 - uses: dorny/paths-filter@v3 id: filter + token: ${{ secrets.GITHUB_TOKEN }} with: filters: | sdk_node: From ede61c5b1c9df8c76ef2414c34cc093bbf014880 Mon Sep 17 00:00:00 2001 From: Nadeesha Cabral Date: Fri, 25 Oct 2024 16:30:09 +1100 Subject: [PATCH 08/12] update --- .github/workflows/publish.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9e42cdbd..6ce4a4e4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,6 +15,7 @@ jobs: check_changes: runs-on: ubuntu-latest permissions: + contents: read pull-requests: read if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main' }} outputs: From f6fb197302931962bc6fb48f9d20754fa01680bd Mon Sep 17 00:00:00 2001 From: Nadeesha Cabral Date: Fri, 25 Oct 2024 16:34:07 +1100 Subject: [PATCH 09/12] update --- .github/workflows/build.yml | 21 +++++++++++++++++++-- .github/workflows/publish.yml | 12 +++++++++--- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4f921059..56528e92 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,14 +16,19 @@ jobs: check_changes: runs-on: ubuntu-latest permissions: + contents: read pull-requests: read outputs: sdk_node: ${{ steps.filter.outputs.sdk_node }} sdk_dotnet: ${{ steps.filter.outputs.sdk_dotnet }} sdk_go: ${{ steps.filter.outputs.sdk_go }} steps: - - uses: actions/checkout@v3 - - uses: dorny/paths-filter@v3 + - name: Checkout code + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Filter changed files + uses: dorny/paths-filter@v2 id: filter with: filters: | @@ -38,6 +43,8 @@ jobs: needs: check_changes if: ${{ needs.check_changes.outputs.sdk_node == 'true' }} runs-on: ubuntu-latest + permissions: + contents: read defaults: run: working-directory: sdk-node @@ -59,6 +66,8 @@ jobs: needs: [check_changes, build-node] if: ${{ needs.check_changes.outputs.sdk_node == 'true' }} runs-on: ubuntu-latest + permissions: + contents: read strategy: matrix: node-version: [18.x, 20.x, 21.x] @@ -88,6 +97,8 @@ jobs: needs: check_changes if: ${{ needs.check_changes.outputs.sdk_dotnet == 'true' }} runs-on: windows-latest + permissions: + contents: read defaults: run: working-directory: sdk-dotnet @@ -107,6 +118,8 @@ jobs: needs: [check_changes, build-dotnet] if: ${{ needs.check_changes.outputs.sdk_dotnet == 'true' }} runs-on: windows-latest + permissions: + contents: read defaults: run: working-directory: sdk-dotnet @@ -131,6 +144,8 @@ jobs: needs: check_changes if: ${{ needs.check_changes.outputs.sdk_go == 'true' }} runs-on: ubuntu-latest + permissions: + contents: read defaults: run: working-directory: sdk-go @@ -150,6 +165,8 @@ jobs: needs: [check_changes, build-go] if: ${{ needs.check_changes.outputs.sdk_go == 'true' }} runs-on: ubuntu-latest + permissions: + contents: read defaults: run: working-directory: sdk-go diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6ce4a4e4..49c3320f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -23,12 +23,14 @@ jobs: sdk_dotnet: ${{ steps.filter.outputs.sdk_dotnet }} sdk_go: ${{ steps.filter.outputs.sdk_go }} steps: - - uses: actions/checkout@v3 + - name: Checkout code + uses: actions/checkout@v3 with: fetch-depth: 0 - - uses: dorny/paths-filter@v3 + token: ${{ secrets.GITHUB_TOKEN }} + - name: Filter changed files + uses: dorny/paths-filter@v2 id: filter - token: ${{ secrets.GITHUB_TOKEN }} with: filters: | sdk_node: @@ -44,6 +46,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + packages: write defaults: run: working-directory: sdk-node @@ -52,6 +55,7 @@ jobs: uses: actions/checkout@v3 with: fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Node.js uses: actions/setup-node@v3 with: @@ -130,6 +134,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + packages: write defaults: run: working-directory: sdk-go @@ -138,6 +143,7 @@ jobs: uses: actions/checkout@v3 with: fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Go uses: actions/setup-go@v4 with: From b00a5b62987c436131941ca2b4ef0de39791a01c Mon Sep 17 00:00:00 2001 From: Nadeesha Cabral Date: Fri, 25 Oct 2024 17:04:59 +1100 Subject: [PATCH 10/12] update --- sdk-node/package-lock.json | 4 ++-- sdk-node/package.json | 2 +- sdk-node/src/tests/utility/retry.test.ts | 6 +++++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/sdk-node/package-lock.json b/sdk-node/package-lock.json index b7f5b547..172e0286 100644 --- a/sdk-node/package-lock.json +++ b/sdk-node/package-lock.json @@ -1,12 +1,12 @@ { "name": "inferable", - "version": "0.30.23", + "version": "0.30.24", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "inferable", - "version": "0.30.23", + "version": "0.30.24", "license": "MIT", "dependencies": { "@ts-rest/core": "^3.28.0", diff --git a/sdk-node/package.json b/sdk-node/package.json index 055b5ba1..436e0935 100644 --- a/sdk-node/package.json +++ b/sdk-node/package.json @@ -1,6 +1,6 @@ { "name": "inferable", - "version": "0.30.23", + "version": "0.30.24", "description": "Javascript SDK for inferable.ai", "main": "bin/index.js", "scripts": { diff --git a/sdk-node/src/tests/utility/retry.test.ts b/sdk-node/src/tests/utility/retry.test.ts index 730968ed..314c78aa 100644 --- a/sdk-node/src/tests/utility/retry.test.ts +++ b/sdk-node/src/tests/utility/retry.test.ts @@ -16,6 +16,10 @@ describe("retrying", () => { it("should not retry a function when attempts is 1", async () => { const productId = Math.random().toString(); + const randomFailingFunctionName = `failingFunction${Math.random() + .toString() + .replace(".", "")}`; + const result = await client.createCall({ query: { waitTime: 20, @@ -25,7 +29,7 @@ describe("retrying", () => { }, body: { service: service.definition.name, - function: "failingFunction", + function: randomFailingFunctionName, input: { id: productId }, }, }); From c68aa5ef63b19702807c33c9344162b578d01075 Mon Sep 17 00:00:00 2001 From: Nadeesha Cabral Date: Fri, 25 Oct 2024 17:27:40 +1100 Subject: [PATCH 11/12] update --- sdk-node/src/tests/utility/product.ts | 4 +++- sdk-node/src/tests/utility/retry.test.ts | 6 +----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/sdk-node/src/tests/utility/product.ts b/sdk-node/src/tests/utility/product.ts index ec4ba461..c487db50 100644 --- a/sdk-node/src/tests/utility/product.ts +++ b/sdk-node/src/tests/utility/product.ts @@ -72,7 +72,9 @@ export const productService = () => { service.register({ name: "failingFunction", - func: succeedsOnSecondAttempt, + func: async () => { + await new Promise((resolve) => setTimeout(resolve, 5000)); + }, schema: { input: z.object({ id: z.string(), diff --git a/sdk-node/src/tests/utility/retry.test.ts b/sdk-node/src/tests/utility/retry.test.ts index 314c78aa..730968ed 100644 --- a/sdk-node/src/tests/utility/retry.test.ts +++ b/sdk-node/src/tests/utility/retry.test.ts @@ -16,10 +16,6 @@ describe("retrying", () => { it("should not retry a function when attempts is 1", async () => { const productId = Math.random().toString(); - const randomFailingFunctionName = `failingFunction${Math.random() - .toString() - .replace(".", "")}`; - const result = await client.createCall({ query: { waitTime: 20, @@ -29,7 +25,7 @@ describe("retrying", () => { }, body: { service: service.definition.name, - function: randomFailingFunctionName, + function: "failingFunction", input: { id: productId }, }, }); From ee48e7d7aaeadb8cc83ca2124356c10fdcc37a17 Mon Sep 17 00:00:00 2001 From: Nadeesha Cabral Date: Fri, 25 Oct 2024 17:32:39 +1100 Subject: [PATCH 12/12] update --- .github/workflows/build.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 56528e92..ac2a1858 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -70,7 +70,8 @@ jobs: contents: read strategy: matrix: - node-version: [18.x, 20.x, 21.x] + # node-version: [18.x, 20.x, 21.x] There's a race condition with retry.test.ts + node-version: [20.x] defaults: run: working-directory: sdk-node