diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ea04f54..4d03faf 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -24,5 +24,22 @@ jobs:
with:
node-version: "20"
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: "1.22"
+
+ - name: Set up Java
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: "17"
+ cache: maven
+
+ - name: Set up .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: "8.0.x"
+
- name: Run PR quality gates
run: bash scripts/ci/pr_checks.sh
diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml
index bb8d208..62c12e6 100644
--- a/.github/workflows/pr-ci.yml
+++ b/.github/workflows/pr-ci.yml
@@ -12,22 +12,39 @@ jobs:
steps:
- name: Check out repository
- uses: actions/checkout@v6
+ uses: actions/checkout@v4
- name: Set up Python
- uses: actions/setup-python@v6
+ uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: pip
cache-dependency-path: python/pyproject.toml
- name: Set up Node.js
- uses: actions/setup-node@v6
+ uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: typescript/package-lock.json
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: "1.22"
+
+ - name: Set up Java
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: "17"
+ cache: maven
+
+ - name: Set up .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: "8.0.x"
+
- name: Run PR quality gates
run: bash scripts/ci/pr_checks.sh
diff --git a/.gitignore b/.gitignore
index 203c53d..bffcd88 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,6 +17,13 @@ dist/
coverage/
*.tsbuildinfo
+# .NET
+bin/
+obj/
+
+# Java / Maven
+target/
+
# IDE
.idea/
.vscode/
diff --git a/README.md b/README.md
index e3091f4..0e49284 100644
--- a/README.md
+++ b/README.md
@@ -11,11 +11,14 @@
+
+
+
-Python and TypeScript SDKs for AI agents and developers building on SynapseNetwork.
+Python, TypeScript, Go, Java, and .NET SDKs for AI agents and developers building on SynapseNetwork.
SynapseNetwork lets an agent discover services, invoke them through a gateway, and settle the call with an auditable receipt. The fastest integration path is:
@@ -98,6 +101,37 @@ The SDK never probes DNS and never falls back between environments automatically
Provider is an owner-scoped supply-side role, not a separate root account. Keep ordinary agent runtime code on `SynapseClient`; use `SynapseProvider` only when you are registering or operating services exposed through SynapseNetwork.
+## Official SDK Coverage
+
+| Language | Package path | Current coverage |
+|---|---|---|
+| Python | `python/` | Consumer, owner auth, provider publishing |
+| TypeScript | `typescript/` | Consumer, owner auth, provider publishing |
+| Go | `go/` | Consumer runtime preview |
+| Java/JVM | `java/` | Consumer runtime preview; Kotlin can call the Java SDK |
+| .NET | `dotnet/` | Consumer runtime preview |
+
+Wave 1 multi-language SDKs focus on agent runtime: search/discover, fixed-price invoke, token-metered LLM invoke, receipt lookup, and gateway health. Owner auth and provider publishing for Go, Java, and .NET are planned after the consumer runtime surface is stable.
+
+## Examples By SDK
+
+All runnable examples default to staging and read `SYNAPSE_AGENT_KEY`.
+
+| SDK | Free fixed-price smoke | LLM smoke | Full local E2E |
+|---|---|---|---|
+| Python | `PYTHONPATH=python python3 python/examples/free_service_smoke.py` | `PYTHONPATH=python python3 python/examples/llm_smoke.py` | `PYTHONPATH=python python3 python/examples/e2e.py` |
+| TypeScript | `npm run example:free --prefix typescript` | `npm run example:llm --prefix typescript` | `npm run example:e2e --prefix typescript` |
+| Go | `go -C go run ./examples/free_service_smoke` | `go -C go run ./examples/llm_smoke` | `go -C go run ./examples/e2e` |
+| Java/JVM | `mvn -q -f java/examples/pom.xml exec:java -Dexec.mainClass=ai.synapsenetwork.sdk.examples.FreeServiceSmoke` | `mvn -q -f java/examples/pom.xml exec:java -Dexec.mainClass=ai.synapsenetwork.sdk.examples.LlmSmoke` | `mvn -q -f java/examples/pom.xml exec:java -Dexec.mainClass=ai.synapsenetwork.sdk.examples.E2eSmoke` |
+| .NET | `dotnet run --project dotnet/examples/free-service-smoke/free-service-smoke.csproj` | `dotnet run --project dotnet/examples/llm-smoke/llm-smoke.csproj` | `dotnet run --project dotnet/examples/e2e/e2e.csproj` |
+
+To run every SDK through the same real Gateway E2E harness:
+
+```bash
+export SYNAPSE_AGENT_KEY=agt_xxx
+bash scripts/e2e/sdk_wave1_local.sh --skip-install
+```
+
## Agent Quickstart: Python
Step 1: get your Agent Key from the Synapse Gateway Dashboard.
@@ -295,6 +329,15 @@ The Python examples are staging-first and live under `python/examples`.
cd python
```
+Run consumer smoke examples:
+
+```bash
+export SYNAPSE_AGENT_KEY=agt_xxx
+PYTHONPATH="$PWD" .venv/bin/python examples/free_service_smoke.py
+PYTHONPATH="$PWD" .venv/bin/python examples/llm_smoke.py
+PYTHONPATH="$PWD" .venv/bin/python examples/e2e.py
+```
+
Register a provider service on staging:
```bash
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 6f214e0..707e343 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -11,11 +11,14 @@
+
+
+
-面向 AI Agent 和开发者的 SynapseNetwork Python / TypeScript SDK。
+面向 AI Agent 和开发者的 SynapseNetwork Python / TypeScript / Go / Java / .NET SDK。
SynapseNetwork 让 Agent 可以发现服务、通过 gateway 调用服务,并用可审计 receipt 完成结算验证。最快接入路径是:
@@ -98,6 +101,37 @@ SDK 不会自动探测 DNS,也不会在环境之间自动 fallback。这样可
Provider 是 owner scope 下的供给侧角色,不是第二套根账户体系。普通 Agent runtime 代码保持使用 `SynapseClient`;只有注册或运营 SynapseNetwork 服务时才使用 `SynapseProvider`。
+## 官方 SDK 覆盖范围
+
+| 语言 | 包路径 | 当前覆盖 |
+|---|---|---|
+| Python | `python/` | Consumer、owner auth、provider publishing |
+| TypeScript | `typescript/` | Consumer、owner auth、provider publishing |
+| Go | `go/` | Consumer runtime preview |
+| Java/JVM | `java/` | Consumer runtime preview;Kotlin 可直接调用 Java SDK |
+| .NET | `dotnet/` | Consumer runtime preview |
+
+Wave 1 多语言 SDK 先聚焦 Agent runtime:search/discover、fixed-price invoke、token-metered LLM invoke、receipt lookup 和 gateway health。Go、Java、.NET 的 owner auth 与 provider publishing 会在 consumer runtime 稳定后进入下一阶段。
+
+## 各 SDK 示例
+
+所有 runnable examples 默认使用 staging,并读取 `SYNAPSE_AGENT_KEY`。
+
+| SDK | 免费 fixed-price smoke | LLM smoke | 完整本地 E2E |
+|---|---|---|---|
+| Python | `PYTHONPATH=python python3 python/examples/free_service_smoke.py` | `PYTHONPATH=python python3 python/examples/llm_smoke.py` | `PYTHONPATH=python python3 python/examples/e2e.py` |
+| TypeScript | `npm run example:free --prefix typescript` | `npm run example:llm --prefix typescript` | `npm run example:e2e --prefix typescript` |
+| Go | `go -C go run ./examples/free_service_smoke` | `go -C go run ./examples/llm_smoke` | `go -C go run ./examples/e2e` |
+| Java/JVM | `mvn -q -f java/examples/pom.xml exec:java -Dexec.mainClass=ai.synapsenetwork.sdk.examples.FreeServiceSmoke` | `mvn -q -f java/examples/pom.xml exec:java -Dexec.mainClass=ai.synapsenetwork.sdk.examples.LlmSmoke` | `mvn -q -f java/examples/pom.xml exec:java -Dexec.mainClass=ai.synapsenetwork.sdk.examples.E2eSmoke` |
+| .NET | `dotnet run --project dotnet/examples/free-service-smoke/free-service-smoke.csproj` | `dotnet run --project dotnet/examples/llm-smoke/llm-smoke.csproj` | `dotnet run --project dotnet/examples/e2e/e2e.csproj` |
+
+统一跑全部 SDK:
+
+```bash
+export SYNAPSE_AGENT_KEY=agt_xxx
+bash scripts/e2e/sdk_wave1_local.sh --skip-install
+```
+
## Agent 快速接入:Python
步骤 1:从 Synapse Gateway Dashboard 获取 Agent Key。
diff --git a/contracts/sdk/fixtures/discovery_search_response.json b/contracts/sdk/fixtures/discovery_search_response.json
new file mode 100644
index 0000000..c6853e9
--- /dev/null
+++ b/contracts/sdk/fixtures/discovery_search_response.json
@@ -0,0 +1,23 @@
+{
+ "requestId": "req_contract_discovery",
+ "count": 1,
+ "page": 1,
+ "pageSize": 10,
+ "totalCount": 1,
+ "hasMore": false,
+ "results": [
+ {
+ "serviceId": "svc_contract_weather",
+ "serviceName": "Contract Weather",
+ "status": "active",
+ "serviceKind": "api",
+ "priceModel": "fixed",
+ "pricing": {
+ "amount": "0.010000",
+ "currency": "USDC"
+ },
+ "summary": "Fixture service for SDK contract tests.",
+ "tags": ["fixture", "free"]
+ }
+ ]
+}
diff --git a/contracts/sdk/fixtures/error_budget_exhausted.json b/contracts/sdk/fixtures/error_budget_exhausted.json
new file mode 100644
index 0000000..05cf013
--- /dev/null
+++ b/contracts/sdk/fixtures/error_budget_exhausted.json
@@ -0,0 +1,6 @@
+{
+ "detail": {
+ "code": "CREDENTIAL_CREDIT_LIMIT_EXCEEDED",
+ "message": "Agent credential budget is exhausted."
+ }
+}
diff --git a/contracts/sdk/fixtures/error_price_mismatch.json b/contracts/sdk/fixtures/error_price_mismatch.json
new file mode 100644
index 0000000..d41e6ae
--- /dev/null
+++ b/contracts/sdk/fixtures/error_price_mismatch.json
@@ -0,0 +1,8 @@
+{
+ "detail": {
+ "code": "PRICE_MISMATCH",
+ "message": "Live price changed. Rediscover before retrying.",
+ "expectedPriceUsdc": "0.010000",
+ "currentPriceUsdc": "0.012000"
+ }
+}
diff --git a/contracts/sdk/fixtures/fixed_price_invoke_response.json b/contracts/sdk/fixtures/fixed_price_invoke_response.json
new file mode 100644
index 0000000..90edf93
--- /dev/null
+++ b/contracts/sdk/fixtures/fixed_price_invoke_response.json
@@ -0,0 +1,12 @@
+{
+ "invocationId": "inv_contract_fixed",
+ "status": "SUCCEEDED",
+ "chargedUsdc": "0.010000",
+ "result": {
+ "temperature": 72,
+ "unit": "fahrenheit"
+ },
+ "receipt": {
+ "invocationId": "inv_contract_fixed"
+ }
+}
diff --git a/contracts/sdk/fixtures/llm_invoke_response.json b/contracts/sdk/fixtures/llm_invoke_response.json
new file mode 100644
index 0000000..256f97e
--- /dev/null
+++ b/contracts/sdk/fixtures/llm_invoke_response.json
@@ -0,0 +1,19 @@
+{
+ "invocationId": "inv_contract_llm",
+ "status": "SUCCEEDED",
+ "chargedUsdc": "0.004200",
+ "usage": {
+ "inputTokens": 120,
+ "outputTokens": 32,
+ "totalTokens": 152
+ },
+ "synapse": {
+ "priceModel": "token_metered",
+ "holdUsdc": "0.010000",
+ "chargedUsdc": "0.004200",
+ "releasedUsdc": "0.005800"
+ },
+ "result": {
+ "content": "contract llm response"
+ }
+}
diff --git a/contracts/sdk/fixtures/receipt_response.json b/contracts/sdk/fixtures/receipt_response.json
new file mode 100644
index 0000000..43a55f6
--- /dev/null
+++ b/contracts/sdk/fixtures/receipt_response.json
@@ -0,0 +1,9 @@
+{
+ "invocationId": "inv_contract_fixed",
+ "status": "SETTLED",
+ "chargedUsdc": "0.010000",
+ "result": {
+ "temperature": 72,
+ "unit": "fahrenheit"
+ }
+}
diff --git a/docs/agent-map/index.json b/docs/agent-map/index.json
index 75a5c08..ed1aba0 100644
--- a/docs/agent-map/index.json
+++ b/docs/agent-map/index.json
@@ -1,7 +1,7 @@
{
"schema_version": 1,
"repository": "Synapse-Network-Sdk",
- "updated_at": "2026-04-25",
+ "updated_at": "2026-04-30",
"purpose": "Task-to-file routing map for AI agents working on the SDK repository.",
"global_entrypoints": [
"llms.txt",
@@ -11,6 +11,7 @@
"SECURITY.md",
"docs/sdk/README.md",
"docs/sdk/capability_inventory.md",
+ "contracts/sdk/fixtures/",
"docs/quality-gates.md",
"scripts/ci/pr_checks.sh"
],
@@ -20,8 +21,9 @@
"title": "Consumer runtime client",
"use_when": [
"discovery, search, invoke, receipt, usage logs, or runtime SDK errors change",
- "Python SynapseClient or TypeScript SynapseClient behavior changes",
- "PRICE_MISMATCH rediscovery or typed runtime errors change"
+ "Python, TypeScript, Go, Java, or .NET SynapseClient behavior changes",
+ "PRICE_MISMATCH rediscovery or typed runtime errors change",
+ "shared SDK contract fixtures change"
],
"primary_files": [
"python/synapse_client/client.py",
@@ -29,22 +31,37 @@
"python/synapse_client/test/test_client_unit.py",
"typescript/src/client.ts",
"typescript/src/errors.ts",
- "typescript/tests/unit/client.test.ts"
+ "typescript/tests/unit/client.test.ts",
+ "go/synapse/client.go",
+ "go/synapse/client_test.go",
+ "java/src/main/java/ai/synapsenetwork/sdk/SynapseClient.java",
+ "java/src/test/java/ai/synapsenetwork/sdk/SynapseClientTest.java",
+ "dotnet/src/SynapseNetwork.Sdk/SynapseClient.cs",
+ "dotnet/tests/SynapseNetwork.Sdk.Tests/SynapseClientTests.cs",
+ "contracts/sdk/fixtures/"
],
"supporting_files": [
"docs/sdk/capability_inventory.md",
+ "docs/sdk/api-parity-matrix.md",
"docs/sdk/python_integration.md",
"docs/sdk/typescript_integration.md",
+ "docs/sdk/go_integration.md",
+ "docs/sdk/java_integration.md",
+ "docs/sdk/dotnet_integration.md",
"README.md"
],
"validation_commands": [
"bash scripts/ci/python_checks.sh",
- "bash scripts/ci/typescript_checks.sh"
+ "bash scripts/ci/typescript_checks.sh",
+ "bash scripts/ci/go_checks.sh",
+ "bash scripts/ci/java_checks.sh",
+ "bash scripts/ci/dotnet_checks.sh"
],
"notes": [
"Canonical runtime flow is discovery/search plus one of two invoke modes: fixed-price invoke with costUsdc/cost_usdc, or token-metered LLM invoke with maxCostUsdc/max_cost_usdc.",
"Public examples should use SYNAPSE_AGENT_KEY for agent runtime credentials; SYNAPSE_API_KEY is only a Python legacy fallback.",
"Public preview examples target staging on Arbitrum Sepolia with MockUSDC test assets.",
+ "Go, Java, and .NET are Wave 1 Consumer SDKs only; owner auth and provider lifecycle helpers remain Python/TypeScript until Phase 2.",
"Do not restore old quote-first gateway calls."
]
},
@@ -119,6 +136,12 @@
"python/synapse_client/test/test_auth_unit.py",
"typescript/src/config.ts",
"typescript/tests/unit/client.test.ts",
+ "go/synapse/client.go",
+ "go/synapse/client_test.go",
+ "java/src/main/java/ai/synapsenetwork/sdk/SynapseClient.java",
+ "java/src/test/java/ai/synapsenetwork/sdk/SynapseClientTest.java",
+ "dotnet/src/SynapseNetwork.Sdk/SynapseClient.cs",
+ "dotnet/tests/SynapseNetwork.Sdk.Tests/SynapseClientTests.cs",
".env.example",
"README.md",
"SECURITY.md"
@@ -130,7 +153,10 @@
"validation_commands": [
"bash scripts/ci/repo_hygiene_checks.sh",
"bash scripts/ci/python_checks.sh",
- "bash scripts/ci/typescript_checks.sh"
+ "bash scripts/ci/typescript_checks.sh",
+ "bash scripts/ci/go_checks.sh",
+ "bash scripts/ci/java_checks.sh",
+ "bash scripts/ci/dotnet_checks.sh"
],
"notes": [
"Default environment is staging.",
@@ -150,11 +176,17 @@
],
"primary_files": [
"README.md",
+ "README.zh-CN.md",
"llm-instructions.md",
"docs/sdk/README.md",
+ "docs/sdk/README.zh-CN.md",
"docs/sdk/capability_inventory.md",
+ "docs/sdk/api-parity-matrix.md",
"docs/sdk/python_integration.md",
"docs/sdk/typescript_integration.md",
+ "docs/sdk/go_integration.md",
+ "docs/sdk/java_integration.md",
+ "docs/sdk/dotnet_integration.md",
"docs/sdk/python_provider_integration.md",
"docs/sdk/typescript_provider_integration.md"
],
@@ -172,7 +204,8 @@
"Public docs should say Public Preview unless production DNS and health checks are verified.",
"Use agt_xxx or REPLACE_ME placeholders only.",
"README and gateway docs must show SYNAPSE_AGENT_KEY -> SynapseClient first, with owner auth -> JWT -> credential issuance under Advanced.",
- "README and gateway docs must explain fixed-price API invoke versus token-metered LLM invoke before advanced owner/provider flows."
+ "README and gateway docs must explain fixed-price API invoke versus token-metered LLM invoke before advanced owner/provider flows.",
+ "English docs and README files must not contain Han characters; Chinese localization belongs in zh-CN files."
]
},
{
@@ -182,10 +215,14 @@
"GitHub Actions, PR checks, coverage thresholds, lint, build, or repo hygiene gates change"
],
"primary_files": [
+ ".github/workflows/ci.yml",
".github/workflows/pr-ci.yml",
"scripts/ci/pr_checks.sh",
"scripts/ci/python_checks.sh",
"scripts/ci/typescript_checks.sh",
+ "scripts/ci/go_checks.sh",
+ "scripts/ci/java_checks.sh",
+ "scripts/ci/dotnet_checks.sh",
"scripts/ci/repo_hygiene_checks.sh",
"scripts/ci/security_checks.sh",
"scripts/ci/source_quality_checks.py",
@@ -201,20 +238,37 @@
],
"notes": [
"Local scripts and GitHub Actions must share the same entrypoint.",
- "Source files over 500 lines, high complexity, and duplicate code above threshold must fail PR CI."
+ "Source files over 500 lines, high complexity, and duplicate code above threshold must fail PR CI.",
+ "Go, Java, and .NET checks run in CI; dotnet_checks.sh may skip locally when the .NET SDK is not installed."
]
},
{
"id": "sdk_examples_and_e2e",
"title": "Examples and e2e onboarding plans",
"use_when": [
- "Python examples, TypeScript examples, smoke tests, or e2e onboarding plans change"
+ "Python examples, TypeScript examples, Go examples, Java examples, .NET examples, smoke tests, or e2e onboarding plans change"
],
"primary_files": [
"python/examples/smoke_test.py",
+ "python/examples/free_service_smoke.py",
+ "python/examples/llm_smoke.py",
+ "python/examples/e2e.py",
"python/examples/provider_staging_onboarding.py",
"python/examples/consumer_call_provider.py",
"python/examples/consumer_wallet_to_invoke.py",
+ "typescript/examples/free-service-smoke.ts",
+ "typescript/examples/llm-smoke.ts",
+ "typescript/examples/e2e.ts",
+ "go/examples/free_service_smoke/main.go",
+ "go/examples/llm_smoke/main.go",
+ "go/examples/e2e/main.go",
+ "java/examples/src/main/java/ai/synapsenetwork/sdk/examples/FreeServiceSmoke.java",
+ "java/examples/src/main/java/ai/synapsenetwork/sdk/examples/LlmSmoke.java",
+ "java/examples/src/main/java/ai/synapsenetwork/sdk/examples/E2eSmoke.java",
+ "dotnet/examples/free-service-smoke/Program.cs",
+ "dotnet/examples/llm-smoke/Program.cs",
+ "dotnet/examples/e2e/Program.cs",
+ "scripts/e2e/sdk_wave1_local.sh",
"docs/test/consumer-e2e-plan.md",
"docs/test/typescript-provider-onboarding-e2e-plan.md",
"docs/test/python-consumer-cold-start-e2e-plan.md",
@@ -223,12 +277,19 @@
"supporting_files": [
"docs/sdk/python_integration.md",
"docs/sdk/typescript_integration.md",
+ "docs/sdk/go_integration.md",
+ "docs/sdk/java_integration.md",
+ "docs/sdk/dotnet_integration.md",
"README.md"
],
"validation_commands": [
"bash scripts/ci/repo_hygiene_checks.sh",
"bash scripts/ci/python_checks.sh",
- "bash scripts/ci/typescript_checks.sh"
+ "bash scripts/ci/typescript_checks.sh",
+ "bash scripts/ci/go_checks.sh",
+ "bash scripts/ci/java_checks.sh",
+ "bash scripts/ci/dotnet_checks.sh",
+ "SYNAPSE_AGENT_KEY=agt_xxx bash scripts/e2e/sdk_wave1_local.sh --skip-install"
],
"notes": [
"Provider staging examples require a public HTTPS endpoint reachable by the staging gateway.",
diff --git a/docs/sdk/README.md b/docs/sdk/README.md
index 1c336b7..0c7eeeb 100644
--- a/docs/sdk/README.md
+++ b/docs/sdk/README.md
@@ -16,11 +16,15 @@ This directory is the SDK-side source of truth for capabilities, integration gui
6. [TypeScript Provider Integration Guide](./typescript_provider_integration.md)
7. [Python Integration Guide](./python_integration.md)
8. [Python Provider Integration Guide](./python_provider_integration.md)
-9. [Python Staging Development](../ops/SDK_Python_Staging_Development.md)
-10. [TypeScript Consumer E2E Plan](../test/consumer-e2e-plan.md)
-11. [TypeScript Provider Onboarding E2E Plan](../test/typescript-provider-onboarding-e2e-plan.md)
-12. [Python Consumer Cold-Start E2E Plan](../test/python-consumer-cold-start-e2e-plan.md)
-13. [Python Provider Onboarding E2E Plan](../test/python-provider-onboarding-e2e-plan.md)
+9. [Go Integration Guide](./go_integration.md)
+10. [Java/JVM Integration Guide](./java_integration.md)
+11. [.NET Integration Guide](./dotnet_integration.md)
+12. [Wave 1 Local E2E](#wave-1-local-e2e)
+13. [Python Staging Development](../ops/SDK_Python_Staging_Development.md)
+14. [TypeScript Consumer E2E Plan](../test/consumer-e2e-plan.md)
+15. [TypeScript Provider Onboarding E2E Plan](../test/typescript-provider-onboarding-e2e-plan.md)
+16. [Python Consumer Cold-Start E2E Plan](../test/python-consumer-cold-start-e2e-plan.md)
+17. [Python Provider Onboarding E2E Plan](../test/python-provider-onboarding-e2e-plan.md)
## Current Position
@@ -32,6 +36,8 @@ The SDK currently has three explicit public surfaces:
Provider remains an owner-scoped supply-side role. `SynapseProvider` improves discoverability but does not introduce a second provider root identity.
+Wave 1 multi-language packages add Go, Java/JVM, and .NET consumer runtime SDKs. These packages intentionally start with `SynapseClient` only: discovery/search, fixed-price invoke, token-metered LLM invoke, receipt lookup, and gateway health. Python and TypeScript remain the reference SDKs for owner auth and provider publishing until those control-plane surfaces are added to the new languages.
+
Owner/provider helper returns are typed SDK objects. Do not document or add public `SynapseAuth` / `SynapseProvider` methods that return raw Python `dict` or TypeScript `Record`; add a named result model/interface instead.
Python quote-first methods `create_quote()`, `create_invocation()`, and `invoke_service()` are deprecated. They no longer call old endpoints and instead tell users to use discovery/search + `invoke(..., cost_usdc=...)` for fixed-price APIs.
@@ -45,6 +51,19 @@ Consumer docs should present two invocation modes:
| Fixed-price API | Python `invoke()` / TypeScript `invoke()` | latest discovery price as `cost_usdc` / `costUsdc` |
| Token-metered LLM | Python `invoke_llm()` / TypeScript `invokeLlm()` | optional cap as `max_cost_usdc` / `maxCostUsdc`; never send `cost_usdc` / `costUsdc` |
+## Wave 1 Local E2E
+
+The real Gateway E2E entrypoint for the new Go, Java/JVM, and .NET SDKs is:
+
+```bash
+export SYNAPSE_AGENT_KEY='agt_xxx_your_real_key'
+bash scripts/e2e/sdk_wave1_local.sh
+```
+
+The script verifies health, discovery, fixed-price invoke, receipt lookup, token-metered LLM invoke, local validation failures, and invalid credential handling for each selected SDK. It may install missing local toolchains; .NET is pinned to SDK 8.0 under `$HOME/.synapse-network-sdk-e2e/dotnet` if needed.
+
+By default the fixed-price path only auto-selects a free fixed-price API service. If staging has no such service, set `SYNAPSE_E2E_FIXED_SERVICE_ID`, `SYNAPSE_E2E_FIXED_COST_USDC`, and `SYNAPSE_E2E_FIXED_PAYLOAD_JSON` explicitly.
+
## Staging Docs
The productized Gateway runbook lives in staging docs:
@@ -152,3 +171,14 @@ PYTHONPATH="$PWD" .venv/bin/python examples/smoke_test.py --query 'quotes'
cd typescript
npm run test:unit
```
+
+Runnable examples exist for every SDK:
+
+```bash
+export SYNAPSE_AGENT_KEY='agt_xxx_your_real_key'
+PYTHONPATH=python python3 python/examples/free_service_smoke.py
+npm run example:free --prefix typescript
+go -C go run ./examples/free_service_smoke
+mvn -q -f java/examples/pom.xml exec:java -Dexec.mainClass=ai.synapsenetwork.sdk.examples.FreeServiceSmoke
+dotnet run --project dotnet/examples/free-service-smoke/free-service-smoke.csproj
+```
diff --git a/docs/sdk/README.zh-CN.md b/docs/sdk/README.zh-CN.md
index 635e672..982231f 100644
--- a/docs/sdk/README.zh-CN.md
+++ b/docs/sdk/README.zh-CN.md
@@ -16,11 +16,15 @@
6. [TypeScript Provider Integration Guide](./typescript_provider_integration.md)
7. [Python Integration Guide](./python_integration.md)
8. [Python Provider Integration Guide](./python_provider_integration.md)
-9. [Python Staging Development](../ops/SDK_Python_Staging_Development.md)
-10. [TypeScript Consumer E2E Plan](../test/consumer-e2e-plan.md)
-11. [TypeScript Provider Onboarding E2E Plan](../test/typescript-provider-onboarding-e2e-plan.md)
-12. [Python Consumer Cold-Start E2E Plan](../test/python-consumer-cold-start-e2e-plan.md)
-13. [Python Provider Onboarding E2E Plan](../test/python-provider-onboarding-e2e-plan.md)
+9. [Go Integration Guide](./go_integration.md)
+10. [Java/JVM Integration Guide](./java_integration.md)
+11. [.NET Integration Guide](./dotnet_integration.md)
+12. [Wave 1 本地 E2E](#wave-1-本地-e2e)
+13. [Python Staging Development](../ops/SDK_Python_Staging_Development.md)
+14. [TypeScript Consumer E2E Plan](../test/consumer-e2e-plan.md)
+15. [TypeScript Provider Onboarding E2E Plan](../test/typescript-provider-onboarding-e2e-plan.md)
+16. [Python Consumer Cold-Start E2E Plan](../test/python-consumer-cold-start-e2e-plan.md)
+17. [Python Provider Onboarding E2E Plan](../test/python-provider-onboarding-e2e-plan.md)
## 当前结论
@@ -32,6 +36,8 @@ SDK 当前有三个明确的公开入口:
Provider 仍然是 owner scope 下的供给侧角色。`SynapseProvider` 只是让 provider 接入更容易发现,不引入第二套 provider root 身份。
+Wave 1 多语言包新增 Go、Java/JVM 和 .NET consumer runtime SDK。这些包第一阶段只提供 `SynapseClient`:discovery/search、fixed-price invoke、token-metered LLM invoke、receipt lookup 和 gateway health。Python 与 TypeScript 仍是 owner auth 和 provider publishing 的 reference SDK,等新语言 consumer runtime 稳定后再补控制面能力。
+
Owner/provider helper 的返回值必须是命名 SDK 对象。不要新增或记录返回 raw Python `dict` / TypeScript `Record` 的公开 `SynapseAuth` / `SynapseProvider` 方法;应先新增命名 result model/interface。
Python 旧的 quote-first 方法 `create_quote()`、`create_invocation()`、`invoke_service()` 已经废弃。它们不会再访问旧 endpoint,而是直接提示普通 fixed-price API 改用 discovery/search + `invoke(..., cost_usdc=...)`。
@@ -45,6 +51,19 @@ Consumer 文档应统一呈现两种调用模式:
| Fixed-price API | Python `invoke()` / TypeScript `invoke()` | 最新 discovery price:`cost_usdc` / `costUsdc` |
| Token-metered LLM | Python `invoke_llm()` / TypeScript `invokeLlm()` | 可选上限:`max_cost_usdc` / `maxCostUsdc`;不要发送 `cost_usdc` / `costUsdc` |
+## Wave 1 本地 E2E
+
+新增 Go、Java/JVM 和 .NET SDK 的真实 Gateway E2E 入口是:
+
+```bash
+export SYNAPSE_AGENT_KEY='agt_xxx_your_real_key'
+bash scripts/e2e/sdk_wave1_local.sh
+```
+
+脚本会对每个选中的 SDK 验证 health、discovery、fixed-price invoke、receipt lookup、token-metered LLM invoke、本地参数校验失败,以及 invalid credential 负向路径。缺少本地工具链时可自动安装;.NET 会按项目 baseline 安装 SDK 8.0 到 `$HOME/.synapse-network-sdk-e2e/dotnet`。
+
+默认 fixed-price 路径只会自动选择免费的 fixed-price API 服务。如果 staging 没有这类服务,需要显式设置 `SYNAPSE_E2E_FIXED_SERVICE_ID`、`SYNAPSE_E2E_FIXED_COST_USDC` 和 `SYNAPSE_E2E_FIXED_PAYLOAD_JSON`。
+
## Staging 产品文档
Gateway 的产品化 runbook 以 staging docs 为准:
@@ -150,3 +169,14 @@ PYTHONPATH="$PWD" .venv/bin/python examples/smoke_test.py --query '名人名言'
cd /Users/cliff/workspace/agent/Synapse-Network-Sdk/typescript
npm run test:unit
```
+
+每个 SDK 都有 runnable examples:
+
+```bash
+export SYNAPSE_AGENT_KEY='agt_xxx_your_real_key'
+PYTHONPATH=python python3 python/examples/free_service_smoke.py
+npm run example:free --prefix typescript
+go -C go run ./examples/free_service_smoke
+mvn -q -f java/examples/pom.xml exec:java -Dexec.mainClass=ai.synapsenetwork.sdk.examples.FreeServiceSmoke
+dotnet run --project dotnet/examples/free-service-smoke/free-service-smoke.csproj
+```
diff --git a/docs/sdk/api-parity-matrix.md b/docs/sdk/api-parity-matrix.md
index da4d2dc..3c1f608 100644
--- a/docs/sdk/api-parity-matrix.md
+++ b/docs/sdk/api-parity-matrix.md
@@ -6,12 +6,13 @@ Current public environment: staging on Arbitrum Sepolia with MockUSDC test asset
## Agent Runtime
-| Capability | Python SDK | TypeScript SDK | Gateway route | Notes |
-|---|---|---|---|---|
-| Discover services | `discover()`, `search()` | `discover()`, `search()` | `GET /api/v1/services/discover`, `POST /api/v1/agent/discovery/search` | Use before paid fixed-price calls to capture current price. |
-| Fixed-price invoke | `invoke(..., cost_usdc="0.05")` | `invoke(..., { costUsdc: "0.05" })` | `POST /api/v1/agent/invoke` | Pass string amount from discovery; do not recompute with floats. |
-| Token-metered LLM invoke | `invoke_llm(..., max_cost_usdc="0.10")` | `invokeLlm(..., { maxCostUsdc: "0.10" })` | `POST /api/v1/agent/invoke` | Do not send `cost_usdc` / `costUsdc`; Gateway bills final provider usage. |
-| Invocation receipt | `get_invocation()` / `get_invocation_receipt()` | `getInvocation()` / `getInvocationReceipt()` | `GET /api/v1/agent/invocations/{id}` | Always read receipts after invoke. |
+| Capability | Python SDK | TypeScript SDK | Go SDK | Java SDK | .NET SDK | Gateway route | Notes |
+|---|---|---|---|---|---|---|---|
+| Discover services | `discover()`, `search()` | `discover()`, `search()` | `Discover()`, `Search()` | `discover()`, `search()` | `DiscoverAsync()`, `SearchAsync()` | `POST /api/v1/agent/discovery/search` | Use before paid fixed-price calls to capture current price. |
+| Fixed-price invoke | `invoke(..., cost_usdc="0.05")` | `invoke(..., { costUsdc: "0.05" })` | `Invoke(..., CostUSDC: "0.05")` | `invoke(..., costUsdc="0.05")` | `InvokeAsync(..., CostUsdc="0.05")` | `POST /api/v1/agent/invoke` | Pass string amount from discovery; do not recompute with floats. |
+| Token-metered LLM invoke | `invoke_llm(..., max_cost_usdc="0.10")` | `invokeLlm(..., { maxCostUsdc: "0.10" })` | `InvokeLLM(..., MaxCostUSDC: "0.10")` | `invokeLlm(..., maxCostUsdc="0.10")` | `InvokeLlmAsync(..., MaxCostUsdc="0.10")` | `POST /api/v1/agent/invoke` | Do not send fixed-price cost; Gateway bills final provider usage. |
+| Invocation receipt | `get_invocation()` / `get_invocation_receipt()` | `getInvocation()` / `getInvocationReceipt()` | `GetInvocation()` | `getInvocation()` | `GetInvocationAsync()` | `GET /api/v1/agent/invocations/{id}` | Always read receipts after invoke. |
+| Gateway health | `check_gateway_health()` | `checkGatewayHealth()` | `CheckGatewayHealth()` | `health()` | `HealthAsync()` | `GET /health` | Does not consume agent budget. |
## Owner Control Plane
diff --git a/docs/sdk/capability_inventory.md b/docs/sdk/capability_inventory.md
index 065b80b..57d1141 100644
--- a/docs/sdk/capability_inventory.md
+++ b/docs/sdk/capability_inventory.md
@@ -137,6 +137,42 @@ Provider publishing is available through `auth.provider()` and existing `Synapse
17. create provider withdrawal intent
18. list provider withdrawals
+## Go Consumer
+
+Supported:
+
+1. gateway URL resolution with explicit `gatewayURL` override and `staging` default
+2. discovery/search
+3. fixed-price invoke with string `CostUSDC`
+4. token-metered LLM invoke with optional string `MaxCostUSDC`
+5. invocation receipt lookup
+6. gateway health check
+7. typed auth, budget, price mismatch, discovery, and invoke errors
+
+## Java/JVM Consumer
+
+Supported:
+
+1. gateway URL resolution with explicit `gatewayUrl` override and `staging` default
+2. discovery/search
+3. fixed-price invoke with string `costUsdc`
+4. token-metered LLM invoke with optional string `maxCostUsdc`
+5. invocation receipt lookup
+6. gateway health check
+7. typed auth, budget, price mismatch, and invoke exceptions
+
+## .NET Consumer
+
+Supported:
+
+1. gateway URL resolution with explicit `GatewayUrl` override and `staging` default
+2. discovery/search
+3. fixed-price invoke with string `CostUsdc`
+4. token-metered LLM invoke with optional string `MaxCostUsdc`
+5. invocation receipt lookup
+6. gateway health check
+7. typed auth, budget, price mismatch, and invoke exceptions
+
## Gateway Capabilities Not Yet Wrapped
The SDK does not currently wrap the full gateway management surface. Known uncovered areas:
@@ -147,6 +183,7 @@ The SDK does not currently wrap the full gateway management surface. Known uncov
4. events
5. on-chain transaction signing helpers
6. provider withdrawal completion helper
+7. owner/provider control-plane helpers in Go, Java, and .NET
## Product Boundary
diff --git a/docs/sdk/dotnet_integration.md b/docs/sdk/dotnet_integration.md
new file mode 100644
index 0000000..2f0d93f
--- /dev/null
+++ b/docs/sdk/dotnet_integration.md
@@ -0,0 +1,58 @@
+# .NET SDK Integration Guide
+
+The .NET SDK is a Wave 1 consumer runtime SDK targeting `net8.0`.
+
+## Install
+
+The preview package project lives in `dotnet/src/SynapseNetwork.Sdk` and is intended for NuGet publication as `SynapseNetwork.Sdk`.
+
+```xml
+
+```
+
+## Fixed-Price API Invoke
+
+```csharp
+using SynapseNetwork.Sdk;
+
+var client = new SynapseClient(new SynapseClientOptions
+{
+ Credential = Environment.GetEnvironmentVariable("SYNAPSE_AGENT_KEY")!,
+ Environment = "staging",
+});
+
+var services = await client.SearchAsync("free", new SearchOptions { Limit = 10 });
+var service = services[0];
+var price = service.Pricing?.GetProperty("amount").GetString() ?? "0";
+
+var result = await client.InvokeAsync(
+ service.ServiceId ?? service.Id!,
+ new Dictionary { ["prompt"] = "hello" },
+ new InvokeOptions { CostUsdc = price });
+
+Console.WriteLine($"{result.InvocationId} {result.Status} {result.ChargedUsdc}");
+```
+
+## Token-Metered LLM Invoke
+
+```csharp
+var result = await client.InvokeLlmAsync(
+ "svc_deepseek_chat",
+ new Dictionary
+ {
+ ["messages"] = new[] { new Dictionary { ["role"] = "user", ["content"] = "hello" } },
+ },
+ new LlmInvokeOptions { MaxCostUsdc = "0.010000" });
+```
+
+Do not pass fixed-price `CostUsdc` to LLM services. Use `MaxCostUsdc` as an optional cap or omit it to let the Gateway compute the hold.
+
+## Verification
+
+```bash
+bash scripts/ci/dotnet_checks.sh
+dotnet test dotnet/tests/SynapseNetwork.Sdk.Tests/SynapseNetwork.Sdk.Tests.csproj
+SYNAPSE_AGENT_KEY=agt_xxx dotnet run --project dotnet/examples/free-service-smoke/free-service-smoke.csproj
+SYNAPSE_AGENT_KEY=agt_xxx dotnet run --project dotnet/examples/llm-smoke/llm-smoke.csproj
+SYNAPSE_AGENT_KEY=agt_xxx dotnet run --project dotnet/examples/e2e/e2e.csproj
+```
diff --git a/docs/sdk/go_integration.md b/docs/sdk/go_integration.md
new file mode 100644
index 0000000..abccb1b
--- /dev/null
+++ b/docs/sdk/go_integration.md
@@ -0,0 +1,74 @@
+# Go SDK Integration Guide
+
+The Go SDK is a Wave 1 consumer runtime SDK. It supports service discovery, fixed-price invoke, token-metered LLM invoke, invocation receipt lookup, and gateway health checks.
+
+## Install
+
+The preview module lives in this monorepo:
+
+```bash
+go get github.com/cliff-personal/Synapse-Network-Sdk/go
+```
+
+## Fixed-Price API Invoke
+
+```go
+package main
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ synapse "github.com/cliff-personal/Synapse-Network-Sdk/go/synapse"
+)
+
+func main() {
+ client, err := synapse.NewClient(synapse.Options{
+ Credential: os.Getenv("SYNAPSE_AGENT_KEY"),
+ Environment: "staging",
+ })
+ if err != nil {
+ panic(err)
+ }
+
+ services, err := client.Search(context.Background(), "free", synapse.SearchOptions{Limit: 10})
+ if err != nil {
+ panic(err)
+ }
+ service := services[0]
+
+ result, err := client.Invoke(
+ context.Background(),
+ service.ServiceID,
+ map[string]any{"prompt": "hello"},
+ synapse.InvokeOptions{CostUSDC: fmt.Sprint(service.Pricing["amount"])},
+ )
+ if err != nil {
+ panic(err)
+ }
+ fmt.Println(result.InvocationID, result.Status, result.ChargedUSDC)
+}
+```
+
+## Token-Metered LLM Invoke
+
+```go
+result, err := client.InvokeLLM(
+ context.Background(),
+ "svc_deepseek_chat",
+ map[string]any{"messages": []map[string]string{{"role": "user", "content": "hello"}}},
+ synapse.LLMInvokeOptions{MaxCostUSDC: "0.010000"},
+)
+```
+
+Do not pass fixed-price `CostUSDC` to LLM services. Use `MaxCostUSDC` as an optional cap or omit it to let the Gateway compute the hold.
+
+## Verification
+
+```bash
+bash scripts/ci/go_checks.sh
+SYNAPSE_AGENT_KEY=agt_xxx go -C go run ./examples/free_service_smoke
+SYNAPSE_AGENT_KEY=agt_xxx go -C go run ./examples/llm_smoke
+SYNAPSE_AGENT_KEY=agt_xxx go -C go run ./examples/e2e
+```
diff --git a/docs/sdk/java_integration.md b/docs/sdk/java_integration.md
new file mode 100644
index 0000000..cd618df
--- /dev/null
+++ b/docs/sdk/java_integration.md
@@ -0,0 +1,59 @@
+# Java/JVM SDK Integration Guide
+
+The Java SDK is a Wave 1 consumer runtime SDK targeting Java 17 and newer. Kotlin and other JVM languages can call the Java SDK directly.
+
+## Install
+
+The preview Maven artifact is defined in `java/pom.xml`:
+
+```xml
+
+ ai.synapsenetwork
+ synapse-network-sdk
+ 0.1.0-SNAPSHOT
+
+```
+
+## Fixed-Price API Invoke
+
+```java
+import ai.synapsenetwork.sdk.SynapseClient;
+import java.util.Map;
+
+SynapseClient client = new SynapseClient(
+ SynapseClient.options(System.getenv("SYNAPSE_AGENT_KEY")).environment("staging"));
+
+var services = client.search("free", new SynapseClient.SearchOptions());
+var service = services.get(0);
+
+SynapseClient.InvokeOptions options = new SynapseClient.InvokeOptions();
+options.costUsdc = service.pricing().path("amount").asText("0");
+
+var result = client.invoke(service.serviceId(), Map.of("prompt", "hello"), options);
+System.out.println(result.invocationId() + " " + result.status() + " " + result.chargedUsdc());
+```
+
+## Token-Metered LLM Invoke
+
+```java
+SynapseClient.LlmInvokeOptions options = new SynapseClient.LlmInvokeOptions();
+options.maxCostUsdc = "0.010000";
+
+var result = client.invokeLlm(
+ "svc_deepseek_chat",
+ Map.of("messages", java.util.List.of(Map.of("role", "user", "content", "hello"))),
+ options);
+```
+
+Do not pass fixed-price `costUsdc` to LLM services. Use `maxCostUsdc` as an optional cap or omit it to let the Gateway compute the hold.
+
+## Verification
+
+```bash
+bash scripts/ci/java_checks.sh
+mvn -q -f java/pom.xml test package
+mvn -q -f java/examples/pom.xml compile
+SYNAPSE_AGENT_KEY=agt_xxx mvn -q -f java/examples/pom.xml exec:java -Dexec.mainClass=ai.synapsenetwork.sdk.examples.FreeServiceSmoke
+SYNAPSE_AGENT_KEY=agt_xxx mvn -q -f java/examples/pom.xml exec:java -Dexec.mainClass=ai.synapsenetwork.sdk.examples.LlmSmoke
+SYNAPSE_AGENT_KEY=agt_xxx mvn -q -f java/examples/pom.xml exec:java -Dexec.mainClass=ai.synapsenetwork.sdk.examples.E2eSmoke
+```
diff --git a/docs/sdk/python_integration.md b/docs/sdk/python_integration.md
index 362750e..b7a92cc 100644
--- a/docs/sdk/python_integration.md
+++ b/docs/sdk/python_integration.md
@@ -174,9 +174,12 @@ print(result.synapse.charged_usdc, result.synapse.released_usdc)
示例脚本位于 `python/examples`:
-1. `provider_staging_onboarding.py`:使用 `SynapseAuth` + `auth.provider()` 在 staging 注册 provider service。
-2. `consumer_call_provider.py`:使用已有 `SYNAPSE_AGENT_KEY=agt_xxx` 调用 provider service。
-3. `consumer_wallet_to_invoke.py`:创建新的 staging wallet,签发 credential,再调用免费服务。
+1. `free_service_smoke.py`:搜索免费 fixed-price API service、invoke、读取 receipt。
+2. `llm_smoke.py`:调用 token-metered LLM,不发送 fixed-price cost。
+3. `e2e.py`:完整真实 Gateway 验证并输出 JSON lines。
+4. `provider_staging_onboarding.py`:使用 `SynapseAuth` + `auth.provider()` 在 staging 注册 provider service。
+5. `consumer_call_provider.py`:使用已有 `SYNAPSE_AGENT_KEY=agt_xxx` 调用 provider service。
+6. `consumer_wallet_to_invoke.py`:创建新的 staging wallet,签发 credential,再调用免费服务。
示例命令:
@@ -191,6 +194,10 @@ PYTHONPATH="$PWD" .venv/bin/python examples/provider_staging_onboarding.py \
--price-usdc 0
export SYNAPSE_AGENT_KEY=agt_xxx
+PYTHONPATH="$PWD" .venv/bin/python examples/free_service_smoke.py
+PYTHONPATH="$PWD" .venv/bin/python examples/llm_smoke.py
+PYTHONPATH="$PWD" .venv/bin/python examples/e2e.py
+
PYTHONPATH="$PWD" .venv/bin/python examples/consumer_call_provider.py \
--service-id "weather_api" \
--payload-json '{"prompt":"hello"}'
@@ -242,7 +249,7 @@ Provider onboarding 成功标准以 owner `/api/v1/services` 列表为准,不
```bash
cd /Users/cliff/workspace/agent/Synapse-Network-Sdk/python
-PYTHONPATH="$PWD" .venv/bin/python -m py_compile examples/provider_staging_onboarding.py examples/consumer_call_provider.py examples/consumer_wallet_to_invoke.py
+PYTHONPATH="$PWD" .venv/bin/python -m py_compile examples/*.py
PYTHONPATH="$PWD" .venv/bin/python -m pytest synapse_client/test/test_auth_unit.py synapse_client/test/test_client_unit.py -q
PYTHONPATH="$PWD" .venv/bin/python -m pytest synapse_client/test/test_consumer_e2e.py -q -s
```
diff --git a/docs/sdk/typescript_integration.md b/docs/sdk/typescript_integration.md
index 9a0728c..a67b373 100644
--- a/docs/sdk/typescript_integration.md
+++ b/docs/sdk/typescript_integration.md
@@ -218,6 +218,20 @@ console.log(result.usage?.inputTokens, result.usage?.outputTokens);
console.log(result.synapse?.chargedUsdc, result.synapse?.releasedUsdc);
```
+## TypeScript Examples
+
+Runnable examples live under `typescript/examples`:
+
+```bash
+cd /Users/cliff/workspace/agent/Synapse-Network-Sdk
+export SYNAPSE_AGENT_KEY=agt_xxx
+npm run example:free --prefix typescript
+npm run example:llm --prefix typescript
+npm run example:e2e --prefix typescript
+```
+
+`npm run typecheck:examples --prefix typescript` compiles the examples without calling the Gateway.
+
Timeouts, disconnects, SSE responses, or missing final `usage` release the
entire hold and do not charge. V1 never bills from the estimated hold.
diff --git a/dotnet/examples/e2e/Program.cs b/dotnet/examples/e2e/Program.cs
new file mode 100644
index 0000000..a6a33c0
--- /dev/null
+++ b/dotnet/examples/e2e/Program.cs
@@ -0,0 +1,271 @@
+using System.Globalization;
+using System.Text.Json;
+using SynapseNetwork.Sdk;
+
+const string DefaultFixedPayload = "{\"prompt\":\"hello\"}";
+const string DefaultLlmPayload = "{\"messages\":[{\"role\":\"user\",\"content\":\"hello\"}]}";
+
+var credential = RequireEnv("SYNAPSE_AGENT_KEY");
+var client = Client(credential);
+var cancellationToken = new CancellationTokenSource(TimeSpan.FromMinutes(2)).Token;
+
+await LocalNegative(client, cancellationToken);
+await client.HealthAsync(cancellationToken);
+Emit("health", status: "ok");
+
+if (!EnvBool("SYNAPSE_E2E_SKIP_AUTH_NEGATIVE"))
+{
+ await AuthNegative(cancellationToken);
+}
+
+var fixedTarget = await FixedTarget(client, cancellationToken);
+var fixedResult = await client.InvokeAsync(
+ fixedTarget.ServiceId,
+ fixedTarget.Payload,
+ new InvokeOptions
+ {
+ CostUsdc = fixedTarget.CostUsdc,
+ IdempotencyKey = "dotnet-e2e-fixed-" + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture),
+ },
+ cancellationToken);
+var fixedReceipt = await AwaitReceipt(client, fixedResult.InvocationId, cancellationToken);
+Emit(
+ "fixed-price",
+ fixedResult.Status,
+ fixedResult.InvocationId,
+ fixedReceipt.ChargedUsdc,
+ fixedReceipt.Status,
+ fixedTarget.ServiceId);
+
+if (!EnvBool("SYNAPSE_E2E_FREE_ONLY"))
+{
+ var llmServiceId = EnvDefault("SYNAPSE_E2E_LLM_SERVICE_ID", "svc_deepseek_chat");
+ var maxCost = EnvDefault("SYNAPSE_E2E_LLM_MAX_COST_USDC", "0.010000");
+ var llmResult = await client.InvokeLlmAsync(
+ llmServiceId,
+ Payload(EnvDefault("SYNAPSE_E2E_LLM_PAYLOAD_JSON", DefaultLlmPayload)),
+ new LlmInvokeOptions
+ {
+ MaxCostUsdc = maxCost,
+ IdempotencyKey = "dotnet-e2e-llm-" + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture),
+ },
+ cancellationToken);
+ var llmReceipt = await AwaitReceipt(client, llmResult.InvocationId, cancellationToken);
+ var charged = FirstNonBlank(llmReceipt.ChargedUsdc, llmResult.ChargedUsdc);
+ if (string.IsNullOrWhiteSpace(charged))
+ {
+ Fail("llm invoke did not report chargedUsdc");
+ }
+ if (decimal.Parse(charged, CultureInfo.InvariantCulture) > decimal.Parse(maxCost, CultureInfo.InvariantCulture))
+ {
+ Fail($"llm chargedUsdc {charged} exceeds maxCostUsdc {maxCost}");
+ }
+ Emit("llm", llmResult.Status, llmResult.InvocationId, charged, llmReceipt.Status, llmServiceId);
+}
+
+static SynapseClient Client(string credential)
+{
+ var gatewayUrl = Environment.GetEnvironmentVariable("SYNAPSE_GATEWAY_URL");
+ return new SynapseClient(new SynapseClientOptions
+ {
+ Credential = credential,
+ Environment = string.IsNullOrWhiteSpace(gatewayUrl) ? "staging" : null,
+ GatewayUrl = gatewayUrl,
+ });
+}
+
+static async Task LocalNegative(SynapseClient client, CancellationToken cancellationToken)
+{
+ await ExpectFailure(() =>
+ client.InvokeAsync("svc_local", null, new InvokeOptions { CostUsdc = "" }, cancellationToken));
+ await ExpectFailure(() =>
+ client.InvokeLlmAsync("svc_llm", new Dictionary { ["stream"] = true }, null, cancellationToken));
+ Emit("local-negative", status: "ok");
+}
+
+static async Task AuthNegative(CancellationToken cancellationToken)
+{
+ var invalidClient = Client("agt_invalid");
+ await ExpectFailure(() =>
+ invalidClient.InvokeAsync(
+ "svc_invalid_auth_probe",
+ new Dictionary(),
+ new InvokeOptions { CostUsdc = "0" },
+ cancellationToken));
+ Emit("auth-negative", status: "ok");
+}
+
+static async Task FixedTarget(SynapseClient client, CancellationToken cancellationToken)
+{
+ var payload = Payload(EnvDefault("SYNAPSE_E2E_FIXED_PAYLOAD_JSON", DefaultFixedPayload));
+ var configuredServiceId = Environment.GetEnvironmentVariable("SYNAPSE_E2E_FIXED_SERVICE_ID");
+ if (!string.IsNullOrWhiteSpace(configuredServiceId))
+ {
+ var cost = Environment.GetEnvironmentVariable("SYNAPSE_E2E_FIXED_COST_USDC");
+ if (string.IsNullOrWhiteSpace(cost))
+ {
+ Fail("SYNAPSE_E2E_FIXED_COST_USDC is required when SYNAPSE_E2E_FIXED_SERVICE_ID is set");
+ throw new InvalidOperationException("unreachable");
+ }
+ return new FixedServiceTarget(configuredServiceId.Trim(), cost.Trim(), payload);
+ }
+
+ var services = await client.SearchAsync("free", new SearchOptions { Limit = 25 }, cancellationToken);
+ foreach (var service in services)
+ {
+ var amount = PricingAmount(service);
+ if (!string.IsNullOrWhiteSpace(service.ServiceId)
+ && string.Equals(service.ServiceKind, "api", StringComparison.OrdinalIgnoreCase)
+ && string.Equals(service.PriceModel, "fixed", StringComparison.OrdinalIgnoreCase)
+ && DecimalEquals(amount, "0"))
+ {
+ return new FixedServiceTarget(service.ServiceId, amount, payload);
+ }
+ }
+ Fail("no free fixed-price API service found; set SYNAPSE_E2E_FIXED_SERVICE_ID, SYNAPSE_E2E_FIXED_COST_USDC, and SYNAPSE_E2E_FIXED_PAYLOAD_JSON");
+ throw new InvalidOperationException("unreachable");
+}
+
+static async Task AwaitReceipt(SynapseClient client, string? invocationId, CancellationToken cancellationToken)
+{
+ if (string.IsNullOrWhiteSpace(invocationId))
+ {
+ Fail("invoke returned empty invocationId");
+ throw new InvalidOperationException("unreachable");
+ }
+ var safeInvocationId = invocationId.Trim();
+ var deadline = DateTimeOffset.UtcNow.AddSeconds(EnvInt("SYNAPSE_E2E_RECEIPT_TIMEOUT_S", 60));
+ while (true)
+ {
+ var receipt = await client.GetInvocationAsync(safeInvocationId, cancellationToken);
+ if (!string.IsNullOrWhiteSpace(receipt.InvocationId) && receipt.InvocationId != safeInvocationId)
+ {
+ Fail($"receipt invocationId mismatch: got {receipt.InvocationId} want {safeInvocationId}");
+ }
+ if (Terminal(receipt.Status))
+ {
+ return receipt;
+ }
+ if (DateTimeOffset.UtcNow > deadline)
+ {
+ Fail($"receipt {safeInvocationId} did not reach a terminal status, last status={receipt.Status}");
+ }
+ await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
+ }
+}
+
+static Dictionary Payload(string raw)
+{
+ return JsonSerializer.Deserialize>(raw)
+ ?? throw new InvalidOperationException("payload JSON must be an object");
+}
+
+static string PricingAmount(ServiceRecord service)
+{
+ return service.Pricing.HasValue && service.Pricing.Value.TryGetProperty("amount", out var amount)
+ ? amount.GetString() ?? ""
+ : "";
+}
+
+static bool Terminal(string? status)
+{
+ return string.Equals(status, "SUCCEEDED", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(status, "SETTLED", StringComparison.OrdinalIgnoreCase);
+}
+
+static bool DecimalEquals(string left, string right)
+{
+ return decimal.TryParse(left, NumberStyles.Number, CultureInfo.InvariantCulture, out var leftValue)
+ && decimal.TryParse(right, NumberStyles.Number, CultureInfo.InvariantCulture, out var rightValue)
+ && leftValue == rightValue;
+}
+
+static async Task ExpectFailure(Func action)
+ where TException : Exception
+{
+ try
+ {
+ await action();
+ }
+ catch (TException)
+ {
+ return;
+ }
+ catch (Exception ex)
+ {
+ Fail($"expected {typeof(TException).Name}, got {ex.GetType().Name}: {ex.Message}");
+ }
+ Fail($"expected {typeof(TException).Name}");
+}
+
+static void Emit(
+ string scenario,
+ string? status = null,
+ string? invocationId = null,
+ string? chargedUsdc = null,
+ string? receiptStatus = null,
+ string? serviceId = null)
+{
+ var payload = new Dictionary
+ {
+ ["language"] = "dotnet",
+ ["scenario"] = scenario,
+ ["invocationId"] = invocationId,
+ ["status"] = status,
+ ["chargedUsdc"] = chargedUsdc,
+ ["receiptStatus"] = receiptStatus,
+ ["serviceId"] = serviceId,
+ }.Where(item => !string.IsNullOrWhiteSpace(item.Value)).ToDictionary(item => item.Key, item => item.Value);
+ Console.WriteLine(JsonSerializer.Serialize(payload));
+}
+
+static string RequireEnv(string name)
+{
+ var value = Environment.GetEnvironmentVariable(name);
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ Fail($"{name} is required");
+ throw new InvalidOperationException("unreachable");
+ }
+ return value.Trim();
+}
+
+static string EnvDefault(string name, string fallback)
+{
+ var value = Environment.GetEnvironmentVariable(name);
+ return string.IsNullOrWhiteSpace(value) ? fallback : value;
+}
+
+static int EnvInt(string name, int fallback)
+{
+ var value = Environment.GetEnvironmentVariable(name);
+ return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) && parsed > 0
+ ? parsed
+ : fallback;
+}
+
+static bool EnvBool(string name)
+{
+ var value = Environment.GetEnvironmentVariable(name)?.Trim();
+ return value is "1" or "true" or "TRUE" or "yes" or "YES" or "y" or "Y";
+}
+
+static string FirstNonBlank(params string?[] values)
+{
+ foreach (var value in values)
+ {
+ if (!string.IsNullOrWhiteSpace(value))
+ {
+ return value.Trim();
+ }
+ }
+ return "";
+}
+
+static void Fail(string message)
+{
+ Console.Error.WriteLine("dotnet e2e failed: " + message);
+ Environment.Exit(1);
+}
+
+internal sealed record FixedServiceTarget(string ServiceId, string CostUsdc, Dictionary Payload);
diff --git a/dotnet/examples/e2e/e2e.csproj b/dotnet/examples/e2e/e2e.csproj
new file mode 100644
index 0000000..665c0dc
--- /dev/null
+++ b/dotnet/examples/e2e/e2e.csproj
@@ -0,0 +1,12 @@
+
+
+ Exe
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
diff --git a/dotnet/examples/free-service-smoke/Program.cs b/dotnet/examples/free-service-smoke/Program.cs
new file mode 100644
index 0000000..4ab3255
--- /dev/null
+++ b/dotnet/examples/free-service-smoke/Program.cs
@@ -0,0 +1,30 @@
+using SynapseNetwork.Sdk;
+
+var agentKey = Environment.GetEnvironmentVariable("SYNAPSE_AGENT_KEY");
+if (string.IsNullOrWhiteSpace(agentKey))
+{
+ throw new InvalidOperationException("SYNAPSE_AGENT_KEY is required");
+}
+
+var client = new SynapseClient(new SynapseClientOptions
+{
+ Credential = agentKey,
+ Environment = "staging",
+});
+var services = await client.SearchAsync("free", new SearchOptions { Limit = 10 });
+if (services.Count == 0)
+{
+ throw new InvalidOperationException("no services found");
+}
+
+var service = services[0];
+var price = "0";
+if (service.Pricing.HasValue && service.Pricing.Value.TryGetProperty("amount", out var amount))
+{
+ price = amount.GetString() ?? "0";
+}
+var result = await client.InvokeAsync(
+ service.ServiceId ?? service.Id ?? throw new InvalidOperationException("service id missing"),
+ new Dictionary { ["prompt"] = "hello" },
+ new InvokeOptions { CostUsdc = price });
+Console.WriteLine($"{result.InvocationId} {result.Status} {result.ChargedUsdc}");
diff --git a/dotnet/examples/free-service-smoke/free-service-smoke.csproj b/dotnet/examples/free-service-smoke/free-service-smoke.csproj
new file mode 100644
index 0000000..b0045cd
--- /dev/null
+++ b/dotnet/examples/free-service-smoke/free-service-smoke.csproj
@@ -0,0 +1,11 @@
+
+
+ Exe
+ net8.0
+ enable
+ enable
+
+
+
+
+
diff --git a/dotnet/examples/llm-smoke/Program.cs b/dotnet/examples/llm-smoke/Program.cs
new file mode 100644
index 0000000..2429c0c
--- /dev/null
+++ b/dotnet/examples/llm-smoke/Program.cs
@@ -0,0 +1,21 @@
+using SynapseNetwork.Sdk;
+
+var agentKey = Environment.GetEnvironmentVariable("SYNAPSE_AGENT_KEY");
+if (string.IsNullOrWhiteSpace(agentKey))
+{
+ throw new InvalidOperationException("SYNAPSE_AGENT_KEY is required");
+}
+
+var client = new SynapseClient(new SynapseClientOptions
+{
+ Credential = agentKey,
+ Environment = "staging",
+});
+var result = await client.InvokeLlmAsync(
+ "svc_deepseek_chat",
+ new Dictionary
+ {
+ ["messages"] = new[] { new Dictionary { ["role"] = "user", ["content"] = "hello" } },
+ },
+ new LlmInvokeOptions { MaxCostUsdc = "0.010000" });
+Console.WriteLine($"{result.InvocationId} {result.Status} {result.ChargedUsdc}");
diff --git a/dotnet/examples/llm-smoke/llm-smoke.csproj b/dotnet/examples/llm-smoke/llm-smoke.csproj
new file mode 100644
index 0000000..b0045cd
--- /dev/null
+++ b/dotnet/examples/llm-smoke/llm-smoke.csproj
@@ -0,0 +1,11 @@
+
+
+ Exe
+ net8.0
+ enable
+ enable
+
+
+
+
+
diff --git a/dotnet/src/SynapseNetwork.Sdk/SynapseClient.cs b/dotnet/src/SynapseNetwork.Sdk/SynapseClient.cs
new file mode 100644
index 0000000..d153cc1
--- /dev/null
+++ b/dotnet/src/SynapseNetwork.Sdk/SynapseClient.cs
@@ -0,0 +1,319 @@
+using System.Globalization;
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace SynapseNetwork.Sdk;
+
+public sealed class SynapseClient
+{
+ public const string DefaultEnvironment = "staging";
+ public const string StagingGatewayUrl = "https://api-staging.synapse-network.ai";
+ public const string ProdGatewayUrl = "https://api.synapse-network.ai";
+
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
+
+ private readonly string _credential;
+ private readonly string _gatewayUrl;
+ private readonly HttpClient _httpClient;
+
+ public SynapseClient(SynapseClientOptions options)
+ {
+ _credential = Require(options.Credential, nameof(options.Credential));
+ _gatewayUrl = ResolveGatewayUrl(options.Environment, options.GatewayUrl);
+ _httpClient = options.HttpClient ?? new HttpClient { Timeout = options.Timeout ?? TimeSpan.FromSeconds(30) };
+ }
+
+ public static string ResolveGatewayUrl(string? environment = null, string? gatewayUrl = null)
+ {
+ if (!string.IsNullOrWhiteSpace(gatewayUrl))
+ {
+ return gatewayUrl.Trim().TrimEnd('/');
+ }
+
+ return (string.IsNullOrWhiteSpace(environment) ? DefaultEnvironment : environment.Trim().ToLowerInvariant()) switch
+ {
+ "staging" => StagingGatewayUrl,
+ "prod" => ProdGatewayUrl,
+ var value => throw new ArgumentException($"unsupported Synapse environment: {value}"),
+ };
+ }
+
+ public async Task> SearchAsync(
+ string query,
+ SearchOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ options ??= new SearchOptions();
+ var pageSize = Math.Max(1, options.Limit ?? 20);
+ var offset = Math.Max(0, options.Offset ?? 0);
+ var body = new Dictionary
+ {
+ ["tags"] = options.Tags ?? Array.Empty(),
+ ["page"] = (offset / pageSize) + 1,
+ ["pageSize"] = pageSize,
+ ["sort"] = string.IsNullOrWhiteSpace(options.Sort) ? "best_match" : options.Sort,
+ };
+ if (!string.IsNullOrWhiteSpace(query))
+ {
+ body["query"] = query.Trim();
+ }
+
+ var response = await PostAsync(
+ "/api/v1/agent/discovery/search",
+ body,
+ options.RequestId,
+ cancellationToken).ConfigureAwait(false);
+ return response.Results ?? response.Services ?? Array.Empty();
+ }
+
+ public Task> DiscoverAsync(SearchOptions? options = null, CancellationToken cancellationToken = default)
+ {
+ return SearchAsync("", options, cancellationToken);
+ }
+
+ public Task InvokeAsync(
+ string serviceId,
+ IReadOnlyDictionary? payload,
+ InvokeOptions options,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(options.CostUsdc))
+ {
+ throw new ArgumentException("CostUsdc is required for fixed-price API services; use InvokeLlmAsync for LLM services.");
+ }
+ var body = InvocationBody(serviceId, payload, options.IdempotencyKey, options.ResponseMode);
+ body["costUsdc"] = options.CostUsdc;
+ return PostAsync("/api/v1/agent/invoke", body, options.RequestId, cancellationToken);
+ }
+
+ public Task InvokeLlmAsync(
+ string serviceId,
+ IReadOnlyDictionary? payload,
+ LlmInvokeOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ options ??= new LlmInvokeOptions();
+ if (payload != null && payload.TryGetValue("stream", out var stream) && stream is true)
+ {
+ throw new InvokeException("LLM_STREAMING_NOT_SUPPORTED", "stream=true is not supported for token-metered billing.");
+ }
+ var body = InvocationBody(serviceId, payload, options.IdempotencyKey, "sync");
+ if (!string.IsNullOrWhiteSpace(options.MaxCostUsdc))
+ {
+ body["maxCostUsdc"] = options.MaxCostUsdc;
+ }
+ return PostAsync("/api/v1/agent/invoke", body, options.RequestId, cancellationToken);
+ }
+
+ public Task GetInvocationAsync(string invocationId, CancellationToken cancellationToken = default)
+ {
+ return GetAsync(
+ "/api/v1/agent/invocations/" + Uri.EscapeDataString(Require(invocationId, nameof(invocationId))),
+ cancellationToken);
+ }
+
+ public Task HealthAsync(CancellationToken cancellationToken = default)
+ {
+ return GetAsync("/health", cancellationToken);
+ }
+
+ private Dictionary InvocationBody(
+ string serviceId,
+ IReadOnlyDictionary? payload,
+ string? idempotencyKey,
+ string? responseMode)
+ {
+ return new Dictionary
+ {
+ ["serviceId"] = Require(serviceId, nameof(serviceId)),
+ ["idempotencyKey"] = string.IsNullOrWhiteSpace(idempotencyKey) ? Guid.NewGuid().ToString("N") : idempotencyKey,
+ ["payload"] = new Dictionary { ["body"] = payload ?? new Dictionary() },
+ ["responseMode"] = string.IsNullOrWhiteSpace(responseMode) ? "sync" : responseMode,
+ };
+ }
+
+ private async Task GetAsync(string path, CancellationToken cancellationToken)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Get, _gatewayUrl + path);
+ request.Headers.Add("X-Credential", _credential);
+ return await SendAsync(request, cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task PostAsync(
+ string path,
+ IReadOnlyDictionary body,
+ string? requestId,
+ CancellationToken cancellationToken)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Post, _gatewayUrl + path);
+ request.Headers.Add("X-Credential", _credential);
+ if (!string.IsNullOrWhiteSpace(requestId))
+ {
+ request.Headers.Add("X-Request-Id", requestId);
+ }
+ request.Content = JsonContent.Create(body, options: JsonOptions);
+ return await SendAsync(request, cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
+ var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
+ if (!response.IsSuccessStatusCode)
+ {
+ throw MapError(response.StatusCode, text);
+ }
+ return JsonSerializer.Deserialize(text, JsonOptions)
+ ?? throw new SynapseException("EMPTY_RESPONSE", "Gateway returned an empty response.");
+ }
+
+ private static Exception MapError(HttpStatusCode statusCode, string text)
+ {
+ var detail = ErrorDetail(text);
+ var code = GetStringProperty(detail, "code");
+ var message = GetStringProperty(detail, "message");
+ if (string.IsNullOrWhiteSpace(message))
+ {
+ message = text;
+ }
+ return statusCode switch
+ {
+ HttpStatusCode.Unauthorized => new AuthenticationException(code, message),
+ HttpStatusCode.PaymentRequired => new BudgetException(code, message),
+ HttpStatusCode.UnprocessableEntity when code == "PRICE_MISMATCH" => new PriceMismatchException(
+ message,
+ GetStringProperty(detail, "expectedPriceUsdc"),
+ GetStringProperty(detail, "currentPriceUsdc")),
+ _ => new InvokeException(code, message),
+ };
+ }
+
+ private static JsonElement ErrorDetail(string text)
+ {
+ try
+ {
+ using var document = JsonDocument.Parse(string.IsNullOrWhiteSpace(text) ? "{}" : text);
+ var root = document.RootElement;
+ if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("detail", out var detail))
+ {
+ return detail.Clone();
+ }
+ return root.ValueKind == JsonValueKind.Object ? root.Clone() : EmptyObject();
+ }
+ catch (JsonException)
+ {
+ return EmptyObject();
+ }
+ }
+
+ private static JsonElement EmptyObject()
+ {
+ using var document = JsonDocument.Parse("{}");
+ return document.RootElement.Clone();
+ }
+
+ private static string GetStringProperty(JsonElement value, string name)
+ {
+ return value.ValueKind == JsonValueKind.Object && value.TryGetProperty(name, out var node)
+ ? node.GetString() ?? ""
+ : "";
+ }
+
+ private static string Require(string? value, string name)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ throw new ArgumentException($"{name} is required");
+ }
+ return value.Trim();
+ }
+}
+
+public sealed record SynapseClientOptions
+{
+ public required string Credential { get; init; }
+ public string? Environment { get; init; }
+ public string? GatewayUrl { get; init; }
+ public TimeSpan? Timeout { get; init; }
+ public HttpClient? HttpClient { get; init; }
+}
+
+public sealed record SearchOptions
+{
+ public int? Limit { get; init; }
+ public int? Offset { get; init; }
+ public IReadOnlyList? Tags { get; init; }
+ public string? Sort { get; init; }
+ public string? RequestId { get; init; }
+}
+
+public sealed record InvokeOptions
+{
+ public required string CostUsdc { get; init; }
+ public string? IdempotencyKey { get; init; }
+ public string? ResponseMode { get; init; }
+ public string? RequestId { get; init; }
+}
+
+public sealed record LlmInvokeOptions
+{
+ public string? MaxCostUsdc { get; init; }
+ public string? IdempotencyKey { get; init; }
+ public string? RequestId { get; init; }
+}
+
+public sealed record ServiceRecord
+{
+ public string? ServiceId { get; init; }
+ public string? Id { get; init; }
+ public string? ServiceName { get; init; }
+ public string? Status { get; init; }
+ public string? ServiceKind { get; init; }
+ public string? PriceModel { get; init; }
+ public JsonElement? Pricing { get; init; }
+ public string? Summary { get; init; }
+ public IReadOnlyList? Tags { get; init; }
+}
+
+public sealed record InvocationResult
+{
+ public string? InvocationId { get; init; }
+ public string? Status { get; init; }
+ public string? ChargedUsdc { get; init; }
+ public JsonElement? Result { get; init; }
+ public JsonElement? Usage { get; init; }
+ public JsonElement? Synapse { get; init; }
+ public JsonElement? Error { get; init; }
+ public JsonElement? Receipt { get; init; }
+}
+
+internal sealed record DiscoveryResponse
+{
+ public IReadOnlyList? Services { get; init; }
+ public IReadOnlyList? Results { get; init; }
+}
+
+public class SynapseException(string code, string message) : Exception(message)
+{
+ public string Code { get; } = code;
+}
+
+public sealed class AuthenticationException(string code, string message) : SynapseException(code, message);
+
+public sealed class BudgetException(string code, string message) : SynapseException(code, message);
+
+public class InvokeException(string code, string message) : SynapseException(code, message);
+
+public sealed class PriceMismatchException(string message, string expectedPriceUsdc, string currentPriceUsdc)
+ : InvokeException("PRICE_MISMATCH", message)
+{
+ public decimal ExpectedPriceUsdc { get; } = string.IsNullOrWhiteSpace(expectedPriceUsdc)
+ ? 0m
+ : decimal.Parse(expectedPriceUsdc, CultureInfo.InvariantCulture);
+
+ public decimal CurrentPriceUsdc { get; } = string.IsNullOrWhiteSpace(currentPriceUsdc)
+ ? 0m
+ : decimal.Parse(currentPriceUsdc, CultureInfo.InvariantCulture);
+}
diff --git a/dotnet/src/SynapseNetwork.Sdk/SynapseNetwork.Sdk.csproj b/dotnet/src/SynapseNetwork.Sdk/SynapseNetwork.Sdk.csproj
new file mode 100644
index 0000000..f2cf826
--- /dev/null
+++ b/dotnet/src/SynapseNetwork.Sdk/SynapseNetwork.Sdk.csproj
@@ -0,0 +1,12 @@
+
+
+ net8.0
+ enable
+ enable
+ true
+ SynapseNetwork.Sdk
+ 0.1.0-preview
+ Synapse Network Team
+ Official .NET SDK for SynapseNetwork agent runtime APIs.
+
+
diff --git a/dotnet/tests/SynapseNetwork.Sdk.Tests/SynapseClientTests.cs b/dotnet/tests/SynapseNetwork.Sdk.Tests/SynapseClientTests.cs
new file mode 100644
index 0000000..5c95521
--- /dev/null
+++ b/dotnet/tests/SynapseNetwork.Sdk.Tests/SynapseClientTests.cs
@@ -0,0 +1,114 @@
+using System.Net;
+using System.Text;
+using SynapseNetwork.Sdk;
+using Xunit;
+
+namespace SynapseNetwork.Sdk.Tests;
+
+public sealed class SynapseClientTests
+{
+ [Fact]
+ public void ResolvesGatewayUrlWithStagingDefaultAndExplicitOverride()
+ {
+ Assert.Equal("https://gateway.example.com", SynapseClient.ResolveGatewayUrl(gatewayUrl: "https://gateway.example.com/"));
+ Assert.Equal(SynapseClient.StagingGatewayUrl, SynapseClient.ResolveGatewayUrl());
+ Assert.Throws(() => SynapseClient.ResolveGatewayUrl(environment: "local"));
+ }
+
+ [Fact]
+ public async Task SearchInvokeLlmAndReceiptUseContractFixtures()
+ {
+ var handler = new FixtureHandler();
+ var client = new SynapseClient(new SynapseClientOptions
+ {
+ Credential = "agt_test",
+ GatewayUrl = "https://gateway.test",
+ HttpClient = new HttpClient(handler),
+ });
+
+ var services = await client.SearchAsync("fixture", new SearchOptions { Limit = 10 });
+ Assert.Single(services);
+ Assert.Equal("svc_contract_weather", services[0].ServiceId);
+
+ var result = await client.InvokeLlmAsync(
+ "svc_deepseek_chat",
+ new Dictionary
+ {
+ ["messages"] = new[] { new Dictionary { ["role"] = "user", ["content"] = "hello" } },
+ },
+ new LlmInvokeOptions { MaxCostUsdc = "0.010000", IdempotencyKey = "idem-llm" });
+ Assert.Equal("inv_contract_llm", result.InvocationId);
+ Assert.Equal("0.004200", result.ChargedUsdc);
+ Assert.DoesNotContain("costUsdc", handler.LastInvokeBody);
+ Assert.Contains("\"maxCostUsdc\":\"0.010000\"", handler.LastInvokeBody);
+
+ var receipt = await client.GetInvocationAsync("inv_contract_llm");
+ Assert.Equal("SETTLED", receipt.Status);
+ }
+
+ [Fact]
+ public async Task FixedPriceInvokeRequiresCostAndMapsPriceMismatch()
+ {
+ var handler = new FixtureHandler { PriceMismatch = true };
+ var client = new SynapseClient(new SynapseClientOptions
+ {
+ Credential = "agt_test",
+ GatewayUrl = "https://gateway.test",
+ HttpClient = new HttpClient(handler),
+ });
+
+ await Assert.ThrowsAsync(() => client.InvokeAsync("svc", null, new InvokeOptions { CostUsdc = "" }));
+ var error = await Assert.ThrowsAsync(() =>
+ client.InvokeAsync("svc", null, new InvokeOptions { CostUsdc = "0.010000" }));
+ Assert.Equal(0.012000m, error.CurrentPriceUsdc);
+ }
+
+ private sealed class FixtureHandler : HttpMessageHandler
+ {
+ public bool PriceMismatch { get; init; }
+ public string LastInvokeBody { get; private set; } = "";
+
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ Assert.Equal("agt_test", request.Headers.GetValues("X-Credential").Single());
+ var path = request.RequestUri?.AbsolutePath ?? "";
+ if (path == "/api/v1/agent/discovery/search")
+ {
+ return Json("discovery_search_response.json");
+ }
+ if (path == "/api/v1/agent/invoke")
+ {
+ LastInvokeBody = request.Content == null ? "" : await request.Content.ReadAsStringAsync(cancellationToken);
+ return Json(PriceMismatch ? "error_price_mismatch.json" : "llm_invoke_response.json", PriceMismatch ? HttpStatusCode.UnprocessableEntity : HttpStatusCode.OK);
+ }
+ if (path == "/api/v1/agent/invocations/inv_contract_llm")
+ {
+ return Json("receipt_response.json");
+ }
+ return new HttpResponseMessage(HttpStatusCode.NotFound) { Content = new StringContent("{}") };
+ }
+
+ private static HttpResponseMessage Json(string fixtureName, HttpStatusCode status = HttpStatusCode.OK)
+ {
+ return new HttpResponseMessage(status)
+ {
+ Content = new StringContent(File.ReadAllText(FixturePath(fixtureName)), Encoding.UTF8, "application/json"),
+ };
+ }
+
+ private static string FixturePath(string fixtureName)
+ {
+ var directory = new DirectoryInfo(Environment.CurrentDirectory);
+ while (directory != null)
+ {
+ var candidate = Path.Combine(directory.FullName, "contracts", "sdk", "fixtures", fixtureName);
+ if (File.Exists(candidate))
+ {
+ return candidate;
+ }
+ directory = directory.Parent;
+ }
+ throw new FileNotFoundException(fixtureName);
+ }
+ }
+}
diff --git a/dotnet/tests/SynapseNetwork.Sdk.Tests/SynapseNetwork.Sdk.Tests.csproj b/dotnet/tests/SynapseNetwork.Sdk.Tests/SynapseNetwork.Sdk.Tests.csproj
new file mode 100644
index 0000000..ceada90
--- /dev/null
+++ b/dotnet/tests/SynapseNetwork.Sdk.Tests/SynapseNetwork.Sdk.Tests.csproj
@@ -0,0 +1,15 @@
+
+
+ net8.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
diff --git a/go/examples/e2e/main.go b/go/examples/e2e/main.go
new file mode 100644
index 0000000..cbbb224
--- /dev/null
+++ b/go/examples/e2e/main.go
@@ -0,0 +1,290 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "math/big"
+ "os"
+ "strings"
+ "time"
+
+ synapse "github.com/cliff-personal/Synapse-Network-Sdk/go/synapse"
+)
+
+const (
+ defaultFixedPayload = `{"prompt":"hello"}`
+ defaultLLMPayload = `{"messages":[{"role":"user","content":"hello"}]}`
+)
+
+type e2eEvent struct {
+ Language string `json:"language"`
+ Scenario string `json:"scenario"`
+ InvocationID string `json:"invocationId,omitempty"`
+ Status string `json:"status,omitempty"`
+ ChargedUSDC string `json:"chargedUsdc,omitempty"`
+ ReceiptStatus string `json:"receiptStatus,omitempty"`
+ ServiceID string `json:"serviceId,omitempty"`
+}
+
+func main() {
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancel()
+
+ credential := requireEnv("SYNAPSE_AGENT_KEY")
+ client, err := synapse.NewClient(synapse.Options{
+ Credential: credential,
+ GatewayURL: os.Getenv("SYNAPSE_GATEWAY_URL"),
+ })
+ must(err)
+
+ mustLocalValidation(ctx, client)
+ must(client.CheckGatewayHealth(ctx))
+ emit(e2eEvent{Language: "go", Scenario: "health", Status: "ok"})
+
+ if !envBool("SYNAPSE_E2E_SKIP_AUTH_NEGATIVE") {
+ mustAuthNegative(ctx)
+ }
+
+ fixedServiceID, fixedCost, fixedPayload := fixedService(ctx, client)
+ fixedResult, err := client.Invoke(
+ ctx,
+ fixedServiceID,
+ fixedPayload,
+ synapse.InvokeOptions{CostUSDC: fixedCost, IdempotencyKey: "go-e2e-fixed-" + time.Now().Format("20060102150405")},
+ )
+ must(err)
+ fixedReceipt := awaitReceipt(ctx, client, fixedResult.InvocationID)
+ emit(e2eEvent{
+ Language: "go",
+ Scenario: "fixed-price",
+ InvocationID: fixedResult.InvocationID,
+ Status: fixedResult.Status,
+ ChargedUSDC: fixedReceipt.ChargedUSDC,
+ ReceiptStatus: fixedReceipt.Status,
+ ServiceID: fixedServiceID,
+ })
+
+ if envBool("SYNAPSE_E2E_FREE_ONLY") {
+ return
+ }
+
+ llmServiceID := envDefault("SYNAPSE_E2E_LLM_SERVICE_ID", "svc_deepseek_chat")
+ llmPayload := parsePayload(envDefault("SYNAPSE_E2E_LLM_PAYLOAD_JSON", defaultLLMPayload))
+ maxCost := envDefault("SYNAPSE_E2E_LLM_MAX_COST_USDC", "0.010000")
+ llmResult, err := client.InvokeLLM(
+ ctx,
+ llmServiceID,
+ llmPayload,
+ synapse.LLMInvokeOptions{MaxCostUSDC: maxCost, IdempotencyKey: "go-e2e-llm-" + time.Now().Format("20060102150405")},
+ )
+ must(err)
+ llmReceipt := awaitReceipt(ctx, client, llmResult.InvocationID)
+ charged := firstNonEmpty(llmReceipt.ChargedUSDC, llmResult.ChargedUSDC)
+ if charged == "" {
+ fail("llm invoke did not report chargedUsdc")
+ }
+ if decimalGreater(charged, maxCost) {
+ fail("llm chargedUsdc %s exceeds maxCostUsdc %s", charged, maxCost)
+ }
+ emit(e2eEvent{
+ Language: "go",
+ Scenario: "llm",
+ InvocationID: llmResult.InvocationID,
+ Status: llmResult.Status,
+ ChargedUSDC: charged,
+ ReceiptStatus: llmReceipt.Status,
+ ServiceID: llmServiceID,
+ })
+}
+
+func mustLocalValidation(ctx context.Context, client *synapse.Client) {
+ if _, err := client.Invoke(ctx, "svc_local", nil, synapse.InvokeOptions{}); err == nil {
+ fail("fixed-price invoke without costUSDC should fail locally")
+ }
+ if _, err := client.InvokeLLM(ctx, "svc_llm", map[string]any{"stream": true}, synapse.LLMInvokeOptions{}); err == nil {
+ fail("LLM stream=true should fail locally")
+ }
+ emit(e2eEvent{Language: "go", Scenario: "local-negative", Status: "ok"})
+}
+
+func mustAuthNegative(ctx context.Context) {
+ client, err := synapse.NewClient(synapse.Options{
+ Credential: "agt_invalid",
+ GatewayURL: os.Getenv("SYNAPSE_GATEWAY_URL"),
+ })
+ must(err)
+ _, err = client.Invoke(
+ ctx,
+ "svc_invalid_auth_probe",
+ map[string]any{},
+ synapse.InvokeOptions{CostUSDC: "0"},
+ )
+ var authErr synapse.AuthenticationError
+ if !errors.As(err, &authErr) {
+ fail("invalid credential should return AuthenticationError, got %T: %v", err, err)
+ }
+ emit(e2eEvent{Language: "go", Scenario: "auth-negative", Status: "ok"})
+}
+
+func fixedService(ctx context.Context, client *synapse.Client) (string, string, map[string]any) {
+ payload := parsePayload(envDefault("SYNAPSE_E2E_FIXED_PAYLOAD_JSON", defaultFixedPayload))
+ if serviceID := strings.TrimSpace(os.Getenv("SYNAPSE_E2E_FIXED_SERVICE_ID")); serviceID != "" {
+ cost := strings.TrimSpace(os.Getenv("SYNAPSE_E2E_FIXED_COST_USDC"))
+ if cost == "" {
+ fail("SYNAPSE_E2E_FIXED_COST_USDC is required when SYNAPSE_E2E_FIXED_SERVICE_ID is set")
+ }
+ return serviceID, cost, payload
+ }
+
+ services, err := client.Search(ctx, "free", synapse.SearchOptions{Limit: 25})
+ must(err)
+ for _, service := range services {
+ amount := moneyString(service.Pricing["amount"])
+ if strings.TrimSpace(service.ServiceID) != "" &&
+ strings.EqualFold(service.ServiceKind, "api") &&
+ strings.EqualFold(service.PriceModel, "fixed") &&
+ decimalEqual(amount, "0") {
+ return service.ServiceID, amount, payload
+ }
+ }
+ fail("no free fixed-price API service found; set SYNAPSE_E2E_FIXED_SERVICE_ID, SYNAPSE_E2E_FIXED_COST_USDC, and SYNAPSE_E2E_FIXED_PAYLOAD_JSON")
+ return "", "", nil
+}
+
+func awaitReceipt(ctx context.Context, client *synapse.Client, invocationID string) *synapse.InvocationResult {
+ if strings.TrimSpace(invocationID) == "" {
+ fail("invoke returned empty invocationId")
+ }
+ deadline := time.Now().Add(time.Duration(envInt("SYNAPSE_E2E_RECEIPT_TIMEOUT_S", 60)) * time.Second)
+ for {
+ receipt, err := client.GetInvocation(ctx, invocationID)
+ must(err)
+ if receipt.InvocationID != "" && receipt.InvocationID != invocationID {
+ fail("receipt invocationId mismatch: got %s want %s", receipt.InvocationID, invocationID)
+ }
+ if terminal(receipt.Status) {
+ return receipt
+ }
+ if time.Now().After(deadline) {
+ fail("receipt %s did not reach a terminal status, last status=%s", invocationID, receipt.Status)
+ }
+ time.Sleep(2 * time.Second)
+ }
+}
+
+func parsePayload(raw string) map[string]any {
+ var payload map[string]any
+ if err := json.Unmarshal([]byte(raw), &payload); err != nil {
+ fail("invalid payload JSON: %v", err)
+ }
+ return payload
+}
+
+func terminal(status string) bool {
+ switch strings.ToUpper(strings.TrimSpace(status)) {
+ case "SUCCEEDED", "SETTLED":
+ return true
+ default:
+ return false
+ }
+}
+
+func moneyString(value any) string {
+ switch typed := value.(type) {
+ case string:
+ return typed
+ case json.Number:
+ return typed.String()
+ case nil:
+ return ""
+ default:
+ return fmt.Sprint(typed)
+ }
+}
+
+func decimalEqual(left, right string) bool {
+ l, ok := new(big.Rat).SetString(strings.TrimSpace(left))
+ if !ok {
+ return false
+ }
+ r, ok := new(big.Rat).SetString(strings.TrimSpace(right))
+ return ok && l.Cmp(r) == 0
+}
+
+func decimalGreater(left, right string) bool {
+ l, ok := new(big.Rat).SetString(strings.TrimSpace(left))
+ if !ok {
+ fail("invalid decimal %q", left)
+ }
+ r, ok := new(big.Rat).SetString(strings.TrimSpace(right))
+ if !ok {
+ fail("invalid decimal %q", right)
+ }
+ return l.Cmp(r) > 0
+}
+
+func emit(event e2eEvent) {
+ raw, err := json.Marshal(event)
+ must(err)
+ fmt.Println(string(raw))
+}
+
+func requireEnv(name string) string {
+ value := strings.TrimSpace(os.Getenv(name))
+ if value == "" {
+ fail("%s is required", name)
+ }
+ return value
+}
+
+func envDefault(name, fallback string) string {
+ if value := strings.TrimSpace(os.Getenv(name)); value != "" {
+ return value
+ }
+ return fallback
+}
+
+func envBool(name string) bool {
+ switch strings.ToLower(strings.TrimSpace(os.Getenv(name))) {
+ case "1", "true", "yes", "y":
+ return true
+ default:
+ return false
+ }
+}
+
+func envInt(name string, fallback int) int {
+ raw := strings.TrimSpace(os.Getenv(name))
+ if raw == "" {
+ return fallback
+ }
+ value := fallback
+ if _, err := fmt.Sscanf(raw, "%d", &value); err != nil || value <= 0 {
+ return fallback
+ }
+ return value
+}
+
+func firstNonEmpty(values ...string) string {
+ for _, value := range values {
+ if strings.TrimSpace(value) != "" {
+ return strings.TrimSpace(value)
+ }
+ }
+ return ""
+}
+
+func must(values ...any) {
+ for _, value := range values {
+ if err, ok := value.(error); ok && err != nil {
+ fail("%v", err)
+ }
+ }
+}
+
+func fail(format string, args ...any) {
+ fmt.Fprintf(os.Stderr, "go e2e failed: "+format+"\n", args...)
+ os.Exit(1)
+}
diff --git a/go/examples/free_service_smoke/main.go b/go/examples/free_service_smoke/main.go
new file mode 100644
index 0000000..07fc758
--- /dev/null
+++ b/go/examples/free_service_smoke/main.go
@@ -0,0 +1,37 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ synapse "github.com/cliff-personal/Synapse-Network-Sdk/go/synapse"
+)
+
+func main() {
+ agentKey := os.Getenv("SYNAPSE_AGENT_KEY")
+ if agentKey == "" {
+ panic("SYNAPSE_AGENT_KEY is required")
+ }
+ client, err := synapse.NewClient(synapse.Options{Credential: agentKey, Environment: "staging"})
+ if err != nil {
+ panic(err)
+ }
+ services, err := client.Search(context.Background(), "free", synapse.SearchOptions{Limit: 10})
+ if err != nil {
+ panic(err)
+ }
+ if len(services) == 0 {
+ panic("no services found")
+ }
+ result, err := client.Invoke(
+ context.Background(),
+ services[0].ServiceID,
+ map[string]any{"prompt": "hello"},
+ synapse.InvokeOptions{CostUSDC: fmt.Sprint(services[0].Pricing["amount"])},
+ )
+ if err != nil {
+ panic(err)
+ }
+ fmt.Println(result.InvocationID, result.Status, result.ChargedUSDC)
+}
diff --git a/go/examples/llm_smoke/main.go b/go/examples/llm_smoke/main.go
new file mode 100644
index 0000000..080fb31
--- /dev/null
+++ b/go/examples/llm_smoke/main.go
@@ -0,0 +1,30 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ synapse "github.com/cliff-personal/Synapse-Network-Sdk/go/synapse"
+)
+
+func main() {
+ agentKey := os.Getenv("SYNAPSE_AGENT_KEY")
+ if agentKey == "" {
+ panic("SYNAPSE_AGENT_KEY is required")
+ }
+ client, err := synapse.NewClient(synapse.Options{Credential: agentKey, Environment: "staging"})
+ if err != nil {
+ panic(err)
+ }
+ result, err := client.InvokeLLM(
+ context.Background(),
+ "svc_deepseek_chat",
+ map[string]any{"messages": []map[string]string{{"role": "user", "content": "hello"}}},
+ synapse.LLMInvokeOptions{MaxCostUSDC: "0.010000"},
+ )
+ if err != nil {
+ panic(err)
+ }
+ fmt.Println(result.InvocationID, result.Status, result.ChargedUSDC)
+}
diff --git a/go/go.mod b/go/go.mod
new file mode 100644
index 0000000..0a56bf0
--- /dev/null
+++ b/go/go.mod
@@ -0,0 +1,3 @@
+module github.com/cliff-personal/Synapse-Network-Sdk/go
+
+go 1.22
diff --git a/go/synapse/client.go b/go/synapse/client.go
new file mode 100644
index 0000000..6e23c3f
--- /dev/null
+++ b/go/synapse/client.go
@@ -0,0 +1,354 @@
+package synapse
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+const (
+ DefaultEnvironment = "staging"
+ StagingGatewayURL = "https://api-staging.synapse-network.ai"
+ ProdGatewayURL = "https://api.synapse-network.ai"
+)
+
+type Client struct {
+ credential string
+ gatewayURL string
+ timeout time.Duration
+ httpClient *http.Client
+}
+
+type Options struct {
+ Credential string
+ Environment string
+ GatewayURL string
+ Timeout time.Duration
+ HTTPClient *http.Client
+}
+
+type SearchOptions struct {
+ Limit int
+ Offset int
+ Tags []string
+ Sort string
+ RequestID string
+}
+
+type InvokeOptions struct {
+ CostUSDC string
+ IdempotencyKey string
+ ResponseMode string
+ RequestID string
+}
+
+type LLMInvokeOptions struct {
+ MaxCostUSDC string
+ IdempotencyKey string
+ RequestID string
+}
+
+type ServiceRecord struct {
+ ServiceID string `json:"serviceId,omitempty"`
+ ID string `json:"id,omitempty"`
+ ServiceName string `json:"serviceName,omitempty"`
+ Status string `json:"status,omitempty"`
+ ServiceKind string `json:"serviceKind,omitempty"`
+ PriceModel string `json:"priceModel,omitempty"`
+ Pricing map[string]any `json:"pricing,omitempty"`
+ Summary string `json:"summary,omitempty"`
+ Tags []string `json:"tags,omitempty"`
+}
+
+type InvocationResult struct {
+ InvocationID string `json:"invocationId,omitempty"`
+ Status string `json:"status,omitempty"`
+ ChargedUSDC string `json:"chargedUsdc,omitempty"`
+ Result map[string]any `json:"result,omitempty"`
+ Usage map[string]any `json:"usage,omitempty"`
+ Synapse map[string]any `json:"synapse,omitempty"`
+ Error map[string]any `json:"error,omitempty"`
+ Receipt map[string]any `json:"receipt,omitempty"`
+}
+
+type APIError struct {
+ StatusCode int
+ Code string
+ Message string
+}
+
+func (e APIError) Error() string {
+ if e.Code == "" {
+ return e.Message
+ }
+ return fmt.Sprintf("%s: %s", e.Code, e.Message)
+}
+
+type AuthenticationError struct{ APIError }
+type BudgetError struct{ APIError }
+type DiscoveryError struct{ APIError }
+type InvokeError struct{ APIError }
+
+type PriceMismatchError struct {
+ APIError
+ ExpectedPriceUSDC string
+ CurrentPriceUSDC string
+}
+
+func NewClient(opts Options) (*Client, error) {
+ credential := strings.TrimSpace(opts.Credential)
+ if credential == "" {
+ return nil, errors.New("credential is required")
+ }
+ gatewayURL, err := ResolveGatewayURL(opts.Environment, opts.GatewayURL)
+ if err != nil {
+ return nil, err
+ }
+ timeout := opts.Timeout
+ if timeout == 0 {
+ timeout = 30 * time.Second
+ }
+ httpClient := opts.HTTPClient
+ if httpClient == nil {
+ httpClient = &http.Client{Timeout: timeout}
+ }
+ return &Client{credential: credential, gatewayURL: gatewayURL, timeout: timeout, httpClient: httpClient}, nil
+}
+
+func ResolveGatewayURL(environment, gatewayURL string) (string, error) {
+ if trimmed := strings.TrimRight(strings.TrimSpace(gatewayURL), "/"); trimmed != "" {
+ return trimmed, nil
+ }
+ switch strings.ToLower(strings.TrimSpace(defaultString(environment, DefaultEnvironment))) {
+ case "staging":
+ return StagingGatewayURL, nil
+ case "prod":
+ return ProdGatewayURL, nil
+ default:
+ return "", fmt.Errorf("unsupported Synapse environment %q", environment)
+ }
+}
+
+func (c *Client) Search(ctx context.Context, query string, opts SearchOptions) ([]ServiceRecord, error) {
+ pageSize := maxInt(1, defaultInt(opts.Limit, 20))
+ tags := opts.Tags
+ if tags == nil {
+ tags = []string{}
+ }
+ body := map[string]any{
+ "tags": tags,
+ "page": (maxInt(0, opts.Offset) / pageSize) + 1,
+ "pageSize": pageSize,
+ "sort": defaultString(opts.Sort, "best_match"),
+ }
+ if strings.TrimSpace(query) != "" {
+ body["query"] = strings.TrimSpace(query)
+ }
+ var response struct {
+ Services []ServiceRecord `json:"services"`
+ Results []ServiceRecord `json:"results"`
+ }
+ if err := c.post(ctx, "/api/v1/agent/discovery/search", body, opts.RequestID, &response); err != nil {
+ return nil, mapDiscoveryError(err)
+ }
+ if response.Results != nil {
+ return response.Results, nil
+ }
+ return response.Services, nil
+}
+
+func (c *Client) Discover(ctx context.Context, opts SearchOptions) ([]ServiceRecord, error) {
+ return c.Search(ctx, "", opts)
+}
+
+func (c *Client) Invoke(ctx context.Context, serviceID string, payload map[string]any, opts InvokeOptions) (*InvocationResult, error) {
+ if strings.TrimSpace(serviceID) == "" {
+ return nil, errors.New("serviceID is required")
+ }
+ if strings.TrimSpace(opts.CostUSDC) == "" {
+ return nil, errors.New("costUSDC is required for fixed-price API services; use InvokeLLM for LLM services")
+ }
+ body := invocationBody(serviceID, payload, defaultString(opts.ResponseMode, "sync"), opts.IdempotencyKey)
+ body["costUsdc"] = opts.CostUSDC
+ return c.invoke(ctx, body, opts.RequestID)
+}
+
+func (c *Client) InvokeLLM(ctx context.Context, serviceID string, payload map[string]any, opts LLMInvokeOptions) (*InvocationResult, error) {
+ if payload != nil {
+ if stream, ok := payload["stream"].(bool); ok && stream {
+ return nil, InvokeError{APIError{Code: "LLM_STREAMING_NOT_SUPPORTED", Message: "stream=true is not supported"}}
+ }
+ }
+ body := invocationBody(serviceID, payload, "sync", opts.IdempotencyKey)
+ if strings.TrimSpace(opts.MaxCostUSDC) != "" {
+ body["maxCostUsdc"] = opts.MaxCostUSDC
+ }
+ return c.invoke(ctx, body, opts.RequestID)
+}
+
+func (c *Client) GetInvocation(ctx context.Context, invocationID string) (*InvocationResult, error) {
+ if strings.TrimSpace(invocationID) == "" {
+ return nil, errors.New("invocationID is required")
+ }
+ var result InvocationResult
+ err := c.get(ctx, "/api/v1/agent/invocations/"+url.PathEscape(strings.TrimSpace(invocationID)), &result)
+ return &result, err
+}
+
+func (c *Client) CheckGatewayHealth(ctx context.Context) (map[string]any, error) {
+ var result map[string]any
+ err := c.get(ctx, "/health", &result)
+ return result, err
+}
+
+func (c *Client) invoke(ctx context.Context, body map[string]any, requestID string) (*InvocationResult, error) {
+ var result InvocationResult
+ if err := c.post(ctx, "/api/v1/agent/invoke", body, requestID, &result); err != nil {
+ return nil, mapInvokeError(err)
+ }
+ return &result, nil
+}
+
+func invocationBody(serviceID string, payload map[string]any, responseMode, idempotencyKey string) map[string]any {
+ if payload == nil {
+ payload = map[string]any{}
+ }
+ if strings.TrimSpace(idempotencyKey) == "" {
+ idempotencyKey = fmt.Sprintf("go-%d", time.Now().UnixNano())
+ }
+ return map[string]any{
+ "serviceId": strings.TrimSpace(serviceID),
+ "idempotencyKey": idempotencyKey,
+ "payload": map[string]any{"body": payload},
+ "responseMode": responseMode,
+ }
+}
+
+func (c *Client) post(ctx context.Context, path string, body map[string]any, requestID string, target any) error {
+ raw, err := json.Marshal(body)
+ if err != nil {
+ return err
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.gatewayURL+path, bytes.NewReader(raw))
+ if err != nil {
+ return err
+ }
+ return c.do(req, requestID, target)
+}
+
+func (c *Client) get(ctx context.Context, path string, target any) error {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.gatewayURL+path, nil)
+ if err != nil {
+ return err
+ }
+ return c.do(req, "", target)
+}
+
+func (c *Client) do(req *http.Request, requestID string, target any) error {
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-Credential", c.credential)
+ if requestID != "" {
+ req.Header.Set("X-Request-Id", requestID)
+ }
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return err
+ }
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return parseAPIError(resp.StatusCode, raw)
+ }
+ if len(raw) == 0 {
+ return nil
+ }
+ return json.Unmarshal(raw, target)
+}
+
+func parseAPIError(status int, raw []byte) error {
+ var payload struct {
+ Detail map[string]any `json:"detail"`
+ }
+ _ = json.Unmarshal(raw, &payload)
+ code := stringValue(payload.Detail["code"])
+ message := defaultString(stringValue(payload.Detail["message"]), string(raw))
+ base := APIError{StatusCode: status, Code: code, Message: message}
+ if status == http.StatusUnauthorized {
+ return AuthenticationError{base}
+ }
+ if status == http.StatusPaymentRequired {
+ return BudgetError{base}
+ }
+ if status == http.StatusUnprocessableEntity && code == "PRICE_MISMATCH" {
+ return PriceMismatchError{
+ APIError: base,
+ ExpectedPriceUSDC: stringValue(payload.Detail["expectedPriceUsdc"]),
+ CurrentPriceUSDC: stringValue(payload.Detail["currentPriceUsdc"]),
+ }
+ }
+ return base
+}
+
+func mapDiscoveryError(err error) error {
+ var auth AuthenticationError
+ var budget BudgetError
+ var price PriceMismatchError
+ if errors.As(err, &auth) || errors.As(err, &budget) || errors.As(err, &price) {
+ return err
+ }
+ return DiscoveryError{APIError{Message: err.Error()}}
+}
+
+func mapInvokeError(err error) error {
+ var auth AuthenticationError
+ var budget BudgetError
+ var price PriceMismatchError
+ if errors.As(err, &auth) || errors.As(err, &budget) || errors.As(err, &price) {
+ return err
+ }
+ return InvokeError{APIError{Message: err.Error()}}
+}
+
+func defaultString(value, fallback string) string {
+ if strings.TrimSpace(value) == "" {
+ return fallback
+ }
+ return value
+}
+
+func defaultInt(value, fallback int) int {
+ if value == 0 {
+ return fallback
+ }
+ return value
+}
+
+func maxInt(a, b int) int {
+ if a > b {
+ return a
+ }
+ return b
+}
+
+func stringValue(value any) string {
+ switch typed := value.(type) {
+ case string:
+ return typed
+ case fmt.Stringer:
+ return typed.String()
+ case nil:
+ return ""
+ default:
+ return fmt.Sprint(typed)
+ }
+}
diff --git a/go/synapse/client_test.go b/go/synapse/client_test.go
new file mode 100644
index 0000000..9e238d4
--- /dev/null
+++ b/go/synapse/client_test.go
@@ -0,0 +1,117 @@
+package synapse
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestResolveGatewayURL(t *testing.T) {
+ got, err := ResolveGatewayURL("", "https://gateway.example.com/")
+ if err != nil || got != "https://gateway.example.com" {
+ t.Fatalf("explicit gateway url should win, got=%q err=%v", got, err)
+ }
+ got, err = ResolveGatewayURL("", "")
+ if err != nil || got != StagingGatewayURL {
+ t.Fatalf("default environment should be staging, got=%q err=%v", got, err)
+ }
+ if _, err = ResolveGatewayURL("local", ""); err == nil {
+ t.Fatal("unsupported environment should fail")
+ }
+}
+
+func TestSearchInvokeLLMAndReceiptUseContractFixtures(t *testing.T) {
+ var seenInvoke map[string]any
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("X-Credential") != "agt_test" {
+ t.Fatalf("missing X-Credential header")
+ }
+ switch r.URL.Path {
+ case "/api/v1/agent/discovery/search":
+ writeFixture(t, w, "discovery_search_response.json")
+ case "/api/v1/agent/invoke":
+ if err := json.NewDecoder(r.Body).Decode(&seenInvoke); err != nil {
+ t.Fatal(err)
+ }
+ writeFixture(t, w, "llm_invoke_response.json")
+ case "/api/v1/agent/invocations/inv_contract_llm":
+ writeFixture(t, w, "receipt_response.json")
+ default:
+ t.Fatalf("unexpected path %s", r.URL.Path)
+ }
+ }))
+ defer server.Close()
+
+ client, err := NewClient(Options{Credential: "agt_test", GatewayURL: server.URL})
+ if err != nil {
+ t.Fatal(err)
+ }
+ services, err := client.Search(context.Background(), "fixture", SearchOptions{Limit: 10})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(services) != 1 || services[0].ServiceID != "svc_contract_weather" {
+ t.Fatalf("unexpected services: %#v", services)
+ }
+ result, err := client.InvokeLLM(
+ context.Background(),
+ "svc_deepseek_chat",
+ map[string]any{"messages": []any{map[string]any{"role": "user", "content": "hello"}}},
+ LLMInvokeOptions{MaxCostUSDC: "0.010000", IdempotencyKey: "idem-llm"},
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.InvocationID != "inv_contract_llm" || result.ChargedUSDC != "0.004200" {
+ t.Fatalf("unexpected llm result: %#v", result)
+ }
+ if seenInvoke["costUsdc"] != nil || seenInvoke["maxCostUsdc"] != "0.010000" {
+ t.Fatalf("unexpected llm invoke body: %#v", seenInvoke)
+ }
+ receipt, err := client.GetInvocation(context.Background(), "inv_contract_llm")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if receipt.Status != "SETTLED" {
+ t.Fatalf("unexpected receipt: %#v", receipt)
+ }
+}
+
+func TestInvokeRequiresStringCostAndMapsPriceMismatch(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusUnprocessableEntity)
+ writeFixture(t, w, "error_price_mismatch.json")
+ }))
+ defer server.Close()
+
+ client, err := NewClient(Options{Credential: "agt_test", GatewayURL: server.URL})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = client.Invoke(context.Background(), "svc", nil, InvokeOptions{}); err == nil {
+ t.Fatal("missing cost should fail")
+ }
+ _, err = client.Invoke(context.Background(), "svc", nil, InvokeOptions{CostUSDC: "0.010000"})
+ var priceErr PriceMismatchError
+ if !errors.As(err, &priceErr) {
+ t.Fatalf("expected PriceMismatchError, got %T %v", err, err)
+ }
+ if priceErr.CurrentPriceUSDC != "0.012000" {
+ t.Fatalf("unexpected current price: %#v", priceErr)
+ }
+}
+
+func writeFixture(t *testing.T, w http.ResponseWriter, name string) {
+ t.Helper()
+ raw, err := os.ReadFile(filepath.Join("..", "..", "contracts", "sdk", "fixtures", name))
+ if err != nil {
+ t.Fatal(err)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write(raw)
+}
diff --git a/java/examples/pom.xml b/java/examples/pom.xml
new file mode 100644
index 0000000..2255328
--- /dev/null
+++ b/java/examples/pom.xml
@@ -0,0 +1,53 @@
+
+ 4.0.0
+
+ ai.synapsenetwork
+ synapse-network-sdk-examples
+ 0.1.0-SNAPSHOT
+ SynapseNetwork Java SDK Examples
+
+
+ 17
+ UTF-8
+ 2.18.3
+
+
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ ${jackson.version}
+
+
+
+
+
+
+ org.codehaus.mojo
+ build-helper-maven-plugin
+ 3.6.0
+
+
+ add-sdk-source
+ generate-sources
+
+ add-source
+
+
+
+ ../src/main/java
+
+
+
+
+
+
+ org.codehaus.mojo
+ exec-maven-plugin
+ 3.5.0
+
+
+
+
diff --git a/java/examples/src/main/java/ai/synapsenetwork/sdk/examples/E2eSmoke.java b/java/examples/src/main/java/ai/synapsenetwork/sdk/examples/E2eSmoke.java
new file mode 100644
index 0000000..0e80d7b
--- /dev/null
+++ b/java/examples/src/main/java/ai/synapsenetwork/sdk/examples/E2eSmoke.java
@@ -0,0 +1,82 @@
+package ai.synapsenetwork.sdk.examples;
+
+import ai.synapsenetwork.sdk.SynapseClient;
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.Map;
+
+public final class E2eSmoke {
+ private E2eSmoke() {}
+
+ public static void main(String[] args) {
+ SynapseClient client = ExampleSupport.client();
+
+ localNegative(client);
+ client.health();
+ ExampleSupport.emit("health", "ok", "", "", "", "");
+
+ if (!ExampleSupport.envBool("SYNAPSE_E2E_SKIP_AUTH_NEGATIVE")) {
+ authNegative();
+ }
+
+ ExampleSupport.FixedTarget fixed = ExampleSupport.fixedTarget(client);
+ SynapseClient.InvokeOptions fixedOptions = new SynapseClient.InvokeOptions();
+ fixedOptions.costUsdc = fixed.costUsdc();
+ fixedOptions.idempotencyKey = "java-e2e-fixed-" + System.currentTimeMillis();
+ SynapseClient.InvocationResult fixedResult = client.invoke(fixed.serviceId(), fixed.payload(), fixedOptions);
+ SynapseClient.InvocationResult fixedReceipt = ExampleSupport.awaitReceipt(client, fixedResult.invocationId());
+ ExampleSupport.emit(
+ "fixed-price",
+ fixedResult.status(),
+ fixedResult.invocationId(),
+ fixedReceipt.chargedUsdc(),
+ fixedReceipt.status(),
+ fixed.serviceId());
+
+ if (ExampleSupport.envBool("SYNAPSE_E2E_FREE_ONLY")) {
+ return;
+ }
+
+ String llmServiceId = ExampleSupport.envDefault("SYNAPSE_E2E_LLM_SERVICE_ID", "svc_deepseek_chat");
+ String maxCost = ExampleSupport.envDefault("SYNAPSE_E2E_LLM_MAX_COST_USDC", "0.010000");
+ SynapseClient.LlmInvokeOptions llmOptions = new SynapseClient.LlmInvokeOptions();
+ llmOptions.maxCostUsdc = maxCost;
+ llmOptions.idempotencyKey = "java-e2e-llm-" + System.currentTimeMillis();
+ SynapseClient.InvocationResult llmResult =
+ client.invokeLlm(
+ llmServiceId,
+ ExampleSupport.payload(
+ "SYNAPSE_E2E_LLM_PAYLOAD_JSON",
+ Map.of("messages", List.of(Map.of("role", "user", "content", "hello")))),
+ llmOptions);
+ SynapseClient.InvocationResult llmReceipt = ExampleSupport.awaitReceipt(client, llmResult.invocationId());
+ String charged = ExampleSupport.firstNonBlank(llmReceipt.chargedUsdc(), llmResult.chargedUsdc());
+ if (charged.isBlank()) {
+ ExampleSupport.fail("llm invoke did not report chargedUsdc");
+ }
+ if (new BigDecimal(charged).compareTo(new BigDecimal(maxCost)) > 0) {
+ ExampleSupport.fail("llm chargedUsdc " + charged + " exceeds maxCostUsdc " + maxCost);
+ }
+ ExampleSupport.emit("llm", llmResult.status(), llmResult.invocationId(), charged, llmReceipt.status(), llmServiceId);
+ }
+
+ private static void localNegative(SynapseClient client) {
+ SynapseClient.InvokeOptions invokeOptions = new SynapseClient.InvokeOptions();
+ ExampleSupport.expectFailure(
+ () -> client.invoke("svc_local", Map.of(), invokeOptions), IllegalArgumentException.class);
+ ExampleSupport.expectFailure(
+ () -> client.invokeLlm("svc_llm", Map.of("stream", true), new SynapseClient.LlmInvokeOptions()),
+ SynapseClient.InvokeException.class);
+ ExampleSupport.emit("local-negative", "ok", "", "", "", "");
+ }
+
+ private static void authNegative() {
+ SynapseClient invalidClient = ExampleSupport.client("agt_invalid");
+ SynapseClient.InvokeOptions options = new SynapseClient.InvokeOptions();
+ options.costUsdc = "0";
+ ExampleSupport.expectFailure(
+ () -> invalidClient.invoke("svc_invalid_auth_probe", Map.of(), options),
+ SynapseClient.AuthenticationException.class);
+ ExampleSupport.emit("auth-negative", "ok", "", "", "", "");
+ }
+}
diff --git a/java/examples/src/main/java/ai/synapsenetwork/sdk/examples/ExampleSupport.java b/java/examples/src/main/java/ai/synapsenetwork/sdk/examples/ExampleSupport.java
new file mode 100644
index 0000000..dc04f91
--- /dev/null
+++ b/java/examples/src/main/java/ai/synapsenetwork/sdk/examples/ExampleSupport.java
@@ -0,0 +1,207 @@
+package ai.synapsenetwork.sdk.examples;
+
+import ai.synapsenetwork.sdk.SynapseClient;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.math.BigDecimal;
+import java.time.Duration;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+final class ExampleSupport {
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private ExampleSupport() {}
+
+ static SynapseClient client() {
+ return client(requireEnv("SYNAPSE_AGENT_KEY"));
+ }
+
+ static SynapseClient client(String credential) {
+ SynapseClient.Options options = SynapseClient.options(credential);
+ String gatewayUrl = System.getenv("SYNAPSE_GATEWAY_URL");
+ if (gatewayUrl != null && !gatewayUrl.isBlank()) {
+ options.gatewayUrl(gatewayUrl);
+ } else {
+ options.environment("staging");
+ }
+ return new SynapseClient(options);
+ }
+
+ static FixedTarget fixedTarget(SynapseClient client) {
+ Map configuredPayload = payload("SYNAPSE_E2E_FIXED_PAYLOAD_JSON", Map.of("prompt", "hello"));
+ String configuredServiceId = System.getenv("SYNAPSE_E2E_FIXED_SERVICE_ID");
+ if (configuredServiceId != null && !configuredServiceId.isBlank()) {
+ String cost = System.getenv("SYNAPSE_E2E_FIXED_COST_USDC");
+ if (cost == null || cost.isBlank()) {
+ fail("SYNAPSE_E2E_FIXED_COST_USDC is required when SYNAPSE_E2E_FIXED_SERVICE_ID is set");
+ }
+ return new FixedTarget(configuredServiceId.trim(), cost.trim(), configuredPayload);
+ }
+
+ SynapseClient.SearchOptions options = new SynapseClient.SearchOptions();
+ options.limit = 25;
+ List services = client.search("free", options);
+ for (SynapseClient.ServiceRecord service : services) {
+ String amount = pricingAmount(service);
+ if (isFreeFixedApiService(service)) {
+ return new FixedTarget(service.serviceId(), amount, configuredPayload);
+ }
+ }
+ fail(
+ "no free fixed-price API service found; set SYNAPSE_E2E_FIXED_SERVICE_ID, "
+ + "SYNAPSE_E2E_FIXED_COST_USDC, and SYNAPSE_E2E_FIXED_PAYLOAD_JSON");
+ throw new IllegalStateException("unreachable");
+ }
+
+ static boolean isFreeFixedApiService(SynapseClient.ServiceRecord service) {
+ return service.serviceId() != null
+ && "api".equalsIgnoreCase(service.serviceKind())
+ && "fixed".equalsIgnoreCase(service.priceModel())
+ && decimalEquals(pricingAmount(service), "0");
+ }
+
+ static SynapseClient.InvocationResult awaitReceipt(SynapseClient client, String invocationId) {
+ if (invocationId == null || invocationId.isBlank()) {
+ fail("invoke returned empty invocationId");
+ }
+ long deadline = System.currentTimeMillis() + Duration.ofSeconds(envInt("SYNAPSE_E2E_RECEIPT_TIMEOUT_S", 60)).toMillis();
+ while (true) {
+ SynapseClient.InvocationResult receipt = client.getInvocation(invocationId);
+ if (receipt.invocationId() != null && !receipt.invocationId().isBlank() && !receipt.invocationId().equals(invocationId)) {
+ fail("receipt invocationId mismatch: got " + receipt.invocationId() + " want " + invocationId);
+ }
+ if (terminal(receipt.status())) {
+ return receipt;
+ }
+ if (System.currentTimeMillis() > deadline) {
+ fail("receipt " + invocationId + " did not reach a terminal status, last status=" + receipt.status());
+ }
+ try {
+ Thread.sleep(2000);
+ } catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ fail("interrupted while waiting for receipt");
+ }
+ }
+ }
+
+ static Map payload(String name, Map fallback) {
+ String raw = System.getenv(name);
+ if (raw == null || raw.isBlank()) {
+ return fallback;
+ }
+ try {
+ return MAPPER.readValue(raw, new TypeReference>() {});
+ } catch (Exception ex) {
+ throw new IllegalArgumentException(name + " must be a JSON object", ex);
+ }
+ }
+
+ static void emit(
+ String scenario,
+ String status,
+ String invocationId,
+ String chargedUsdc,
+ String receiptStatus,
+ String serviceId) {
+ try {
+ Map event = new LinkedHashMap<>();
+ event.put("language", "java");
+ event.put("scenario", scenario);
+ putIfPresent(event, "invocationId", invocationId);
+ putIfPresent(event, "status", status);
+ putIfPresent(event, "chargedUsdc", chargedUsdc);
+ putIfPresent(event, "receiptStatus", receiptStatus);
+ putIfPresent(event, "serviceId", serviceId);
+ System.out.println(MAPPER.writeValueAsString(event));
+ } catch (Exception ex) {
+ fail(ex.getMessage());
+ }
+ }
+
+ static String envDefault(String name, String fallback) {
+ String value = System.getenv(name);
+ return value == null || value.isBlank() ? fallback : value;
+ }
+
+ static int envInt(String name, int fallback) {
+ String value = System.getenv(name);
+ if (value == null || value.isBlank()) {
+ return fallback;
+ }
+ try {
+ int parsed = Integer.parseInt(value);
+ return parsed > 0 ? parsed : fallback;
+ } catch (NumberFormatException ex) {
+ return fallback;
+ }
+ }
+
+ static boolean envBool(String name) {
+ String value = System.getenv(name);
+ return value != null
+ && ("1".equals(value.trim())
+ || "true".equalsIgnoreCase(value.trim())
+ || "yes".equalsIgnoreCase(value.trim())
+ || "y".equalsIgnoreCase(value.trim()));
+ }
+
+ static String firstNonBlank(String... values) {
+ for (String value : values) {
+ if (value != null && !value.isBlank()) {
+ return value.trim();
+ }
+ }
+ return "";
+ }
+
+ static void expectFailure(Runnable action, Class extends Throwable> expectedType) {
+ try {
+ action.run();
+ } catch (Throwable ex) {
+ if (expectedType.isInstance(ex)) {
+ return;
+ }
+ fail("expected " + expectedType.getSimpleName() + ", got " + ex.getClass().getSimpleName() + ": " + ex.getMessage());
+ }
+ fail("expected " + expectedType.getSimpleName());
+ }
+
+ static void fail(String message) {
+ System.err.println("java example failed: " + message);
+ System.exit(1);
+ }
+
+ private static String requireEnv(String name) {
+ String value = System.getenv(name);
+ if (value == null || value.isBlank()) {
+ fail(name + " is required");
+ }
+ return value.trim();
+ }
+
+ private static boolean terminal(String status) {
+ return "SUCCEEDED".equalsIgnoreCase(status) || "SETTLED".equalsIgnoreCase(status);
+ }
+
+ private static boolean decimalEquals(String left, String right) {
+ if (left == null || left.isBlank()) {
+ return false;
+ }
+ return new BigDecimal(left).compareTo(new BigDecimal(right)) == 0;
+ }
+
+ private static String pricingAmount(SynapseClient.ServiceRecord service) {
+ return service.pricing() == null ? "" : service.pricing().path("amount").asText("");
+ }
+
+ private static void putIfPresent(Map target, String key, String value) {
+ if (value != null && !value.isBlank()) {
+ target.put(key, value);
+ }
+ }
+
+ record FixedTarget(String serviceId, String costUsdc, Map payload) {}
+}
diff --git a/java/examples/src/main/java/ai/synapsenetwork/sdk/examples/FreeServiceSmoke.java b/java/examples/src/main/java/ai/synapsenetwork/sdk/examples/FreeServiceSmoke.java
new file mode 100644
index 0000000..49d7c15
--- /dev/null
+++ b/java/examples/src/main/java/ai/synapsenetwork/sdk/examples/FreeServiceSmoke.java
@@ -0,0 +1,34 @@
+package ai.synapsenetwork.sdk.examples;
+
+import ai.synapsenetwork.sdk.SynapseClient;
+import java.util.Map;
+
+public final class FreeServiceSmoke {
+ private FreeServiceSmoke() {}
+
+ public static void main(String[] args) {
+ SynapseClient client = ExampleSupport.client();
+ var services = client.search("free", new SynapseClient.SearchOptions());
+ var service =
+ services.stream()
+ .filter(ExampleSupport::isFreeFixedApiService)
+ .findFirst()
+ .orElseThrow(
+ () ->
+ new IllegalStateException(
+ "no free fixed-price API service found; set SYNAPSE_E2E_FIXED_SERVICE_ID and "
+ + "SYNAPSE_E2E_FIXED_COST_USDC for paid smoke tests"));
+ SynapseClient.InvokeOptions options = new SynapseClient.InvokeOptions();
+ options.costUsdc = service.pricing().path("amount").asText("0");
+ options.idempotencyKey = "java-free-smoke-" + System.currentTimeMillis();
+ var result = client.invoke(service.serviceId(), Map.of("prompt", "hello"), options);
+ var receipt = ExampleSupport.awaitReceipt(client, result.invocationId());
+ ExampleSupport.emit(
+ "free-service-smoke",
+ result.status(),
+ result.invocationId(),
+ receipt.chargedUsdc(),
+ receipt.status(),
+ service.serviceId());
+ }
+}
diff --git a/java/examples/src/main/java/ai/synapsenetwork/sdk/examples/LlmSmoke.java b/java/examples/src/main/java/ai/synapsenetwork/sdk/examples/LlmSmoke.java
new file mode 100644
index 0000000..ab5bed5
--- /dev/null
+++ b/java/examples/src/main/java/ai/synapsenetwork/sdk/examples/LlmSmoke.java
@@ -0,0 +1,35 @@
+package ai.synapsenetwork.sdk.examples;
+
+import ai.synapsenetwork.sdk.SynapseClient;
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.Map;
+
+public final class LlmSmoke {
+ private LlmSmoke() {}
+
+ public static void main(String[] args) {
+ SynapseClient client = ExampleSupport.client();
+ String serviceId = ExampleSupport.envDefault("SYNAPSE_E2E_LLM_SERVICE_ID", "svc_deepseek_chat");
+ String maxCost = ExampleSupport.envDefault("SYNAPSE_E2E_LLM_MAX_COST_USDC", "0.010000");
+ SynapseClient.LlmInvokeOptions options = new SynapseClient.LlmInvokeOptions();
+ options.maxCostUsdc = maxCost;
+ options.idempotencyKey = "java-llm-smoke-" + System.currentTimeMillis();
+ var result =
+ client.invokeLlm(
+ serviceId,
+ ExampleSupport.payload(
+ "SYNAPSE_E2E_LLM_PAYLOAD_JSON",
+ Map.of("messages", List.of(Map.of("role", "user", "content", "hello")))),
+ options);
+ var receipt = ExampleSupport.awaitReceipt(client, result.invocationId());
+ String charged = ExampleSupport.firstNonBlank(receipt.chargedUsdc(), result.chargedUsdc());
+ if (charged.isBlank()) {
+ ExampleSupport.fail("llm invoke did not report chargedUsdc");
+ }
+ if (new BigDecimal(charged).compareTo(new BigDecimal(maxCost)) > 0) {
+ ExampleSupport.fail("llm chargedUsdc " + charged + " exceeds maxCostUsdc " + maxCost);
+ }
+ ExampleSupport.emit("llm-smoke", result.status(), result.invocationId(), charged, receipt.status(), serviceId);
+ }
+}
diff --git a/java/pom.xml b/java/pom.xml
new file mode 100644
index 0000000..f57d103
--- /dev/null
+++ b/java/pom.xml
@@ -0,0 +1,45 @@
+
+ 4.0.0
+
+ ai.synapsenetwork
+ synapse-network-sdk
+ 0.1.0-SNAPSHOT
+ SynapseNetwork Java SDK
+ Official Java SDK for SynapseNetwork agent runtime APIs.
+
+
+ 17
+ UTF-8
+ 5.11.4
+ 2.18.3
+
+
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ ${jackson.version}
+
+
+ org.junit.jupiter
+ junit-jupiter
+ ${junit.version}
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.5.2
+
+ false
+
+
+
+
+
diff --git a/java/src/main/java/ai/synapsenetwork/sdk/SynapseClient.java b/java/src/main/java/ai/synapsenetwork/sdk/SynapseClient.java
new file mode 100644
index 0000000..dac71e3
--- /dev/null
+++ b/java/src/main/java/ai/synapsenetwork/sdk/SynapseClient.java
@@ -0,0 +1,317 @@
+package ai.synapsenetwork.sdk;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.net.URI;
+import java.net.URLEncoder;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.UUID;
+
+public final class SynapseClient {
+ public static final String DEFAULT_ENVIRONMENT = "staging";
+ public static final String STAGING_GATEWAY_URL = "https://api-staging.synapse-network.ai";
+ public static final String PROD_GATEWAY_URL = "https://api.synapse-network.ai";
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private final String credential;
+ private final String gatewayUrl;
+ private final Duration timeout;
+ private final HttpClient httpClient;
+
+ public SynapseClient(Options options) {
+ this.credential = requireNonBlank(options.credential, "credential");
+ this.gatewayUrl = resolveGatewayUrl(options.environment, options.gatewayUrl);
+ this.timeout = options.timeout == null ? Duration.ofSeconds(30) : options.timeout;
+ this.httpClient = options.httpClient == null ? HttpClient.newHttpClient() : options.httpClient;
+ }
+
+ public static Options options(String credential) {
+ return new Options(credential);
+ }
+
+ public static String resolveGatewayUrl(String environment, String gatewayUrl) {
+ if (gatewayUrl != null && !gatewayUrl.isBlank()) {
+ return stripTrailingSlash(gatewayUrl.trim());
+ }
+ String selected = environment == null || environment.isBlank() ? DEFAULT_ENVIRONMENT : environment.trim().toLowerCase();
+ return switch (selected) {
+ case "staging" -> STAGING_GATEWAY_URL;
+ case "prod" -> PROD_GATEWAY_URL;
+ default -> throw new IllegalArgumentException("unsupported Synapse environment: " + selected);
+ };
+ }
+
+ public List search(String query, SearchOptions options) {
+ SearchOptions safeOptions = options == null ? new SearchOptions() : options;
+ int pageSize = Math.max(1, safeOptions.limit == null ? 20 : safeOptions.limit);
+ int offset = Math.max(0, safeOptions.offset == null ? 0 : safeOptions.offset);
+ Map body = new java.util.LinkedHashMap<>();
+ if (query != null && !query.isBlank()) {
+ body.put("query", query.trim());
+ }
+ body.put("tags", safeOptions.tags == null ? List.of() : safeOptions.tags);
+ body.put("page", (offset / pageSize) + 1);
+ body.put("pageSize", pageSize);
+ body.put("sort", safeOptions.sort == null || safeOptions.sort.isBlank() ? "best_match" : safeOptions.sort);
+ DiscoveryResponse response = post("/api/v1/agent/discovery/search", body, safeOptions.requestId, DiscoveryResponse.class);
+ return response.serviceList();
+ }
+
+ public List discover(SearchOptions options) {
+ return search("", options);
+ }
+
+ public InvocationResult invoke(String serviceId, Map payload, InvokeOptions options) {
+ InvokeOptions safeOptions = Objects.requireNonNull(options, "options is required");
+ if (safeOptions.costUsdc == null || safeOptions.costUsdc.isBlank()) {
+ throw new IllegalArgumentException("costUsdc is required for fixed-price API services; use invokeLlm for LLM services");
+ }
+ Map body = invocationBody(serviceId, payload, safeOptions.idempotencyKey, safeOptions.responseMode);
+ body.put("costUsdc", safeOptions.costUsdc);
+ return post("/api/v1/agent/invoke", body, safeOptions.requestId, InvocationResult.class);
+ }
+
+ public InvocationResult invokeLlm(String serviceId, Map payload, LlmInvokeOptions options) {
+ LlmInvokeOptions safeOptions = options == null ? new LlmInvokeOptions() : options;
+ if (Boolean.TRUE.equals(payload == null ? null : payload.get("stream"))) {
+ throw new InvokeException("LLM_STREAMING_NOT_SUPPORTED", "stream=true is not supported for token-metered billing");
+ }
+ Map body = invocationBody(serviceId, payload, safeOptions.idempotencyKey, "sync");
+ if (safeOptions.maxCostUsdc != null && !safeOptions.maxCostUsdc.isBlank()) {
+ body.put("maxCostUsdc", safeOptions.maxCostUsdc);
+ }
+ return post("/api/v1/agent/invoke", body, safeOptions.requestId, InvocationResult.class);
+ }
+
+ public InvocationResult getInvocation(String invocationId) {
+ String encoded = URLEncoder.encode(requireNonBlank(invocationId, "invocationId"), StandardCharsets.UTF_8);
+ return get("/api/v1/agent/invocations/" + encoded, InvocationResult.class);
+ }
+
+ public JsonNode health() {
+ return get("/health", JsonNode.class);
+ }
+
+ private Map invocationBody(
+ String serviceId, Map payload, String idempotencyKey, String responseMode) {
+ return new java.util.LinkedHashMap<>(
+ Map.of(
+ "serviceId", requireNonBlank(serviceId, "serviceId"),
+ "idempotencyKey", idempotencyKey == null || idempotencyKey.isBlank() ? UUID.randomUUID().toString() : idempotencyKey,
+ "payload", Map.of("body", payload == null ? Map.of() : payload),
+ "responseMode", responseMode == null || responseMode.isBlank() ? "sync" : responseMode));
+ }
+
+ private T get(String path, Class responseType) {
+ HttpRequest request =
+ HttpRequest.newBuilder(URI.create(gatewayUrl + path)).timeout(timeout).header("X-Credential", credential).GET().build();
+ return send(request, responseType);
+ }
+
+ private T post(String path, Map body, String requestId, Class responseType) {
+ try {
+ HttpRequest.Builder builder =
+ HttpRequest.newBuilder(URI.create(gatewayUrl + path))
+ .timeout(timeout)
+ .header("Content-Type", "application/json")
+ .header("X-Credential", credential)
+ .POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(body)));
+ if (requestId != null && !requestId.isBlank()) {
+ builder.header("X-Request-Id", requestId);
+ }
+ return send(builder.build(), responseType);
+ } catch (IOException ex) {
+ throw new SynapseException("REQUEST_SERIALIZATION_FAILED", ex.getMessage(), ex);
+ }
+ }
+
+ private T send(HttpRequest request, Class responseType) {
+ try {
+ HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
+ if (response.statusCode() < 200 || response.statusCode() >= 300) {
+ throw mapError(response.statusCode(), response.body());
+ }
+ return MAPPER.readValue(response.body(), responseType);
+ } catch (IOException ex) {
+ throw new SynapseException("HTTP_PARSE_FAILED", ex.getMessage(), ex);
+ } catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ throw new SynapseException("HTTP_INTERRUPTED", ex.getMessage(), ex);
+ }
+ }
+
+ private RuntimeException mapError(int status, String body) {
+ JsonNode detail = errorDetail(body);
+ String code = detail.path("code").asText("");
+ String message = detail.path("message").asText(body);
+ if (status == 401) {
+ return new AuthenticationException(code, message);
+ }
+ if (status == 402) {
+ return new BudgetException(code, message);
+ }
+ if (status == 422 && "PRICE_MISMATCH".equals(code)) {
+ return new PriceMismatchException(
+ code, message, detail.path("expectedPriceUsdc").asText(""), detail.path("currentPriceUsdc").asText(""));
+ }
+ return new InvokeException(code, message);
+ }
+
+ private JsonNode errorDetail(String body) {
+ try {
+ JsonNode root = MAPPER.readTree(body);
+ return root.path("detail");
+ } catch (IOException ex) {
+ return MAPPER.createObjectNode();
+ }
+ }
+
+ private static String requireNonBlank(String value, String name) {
+ if (value == null || value.isBlank()) {
+ throw new IllegalArgumentException(name + " is required");
+ }
+ return value.trim();
+ }
+
+ private static String stripTrailingSlash(String value) {
+ return value.endsWith("/") ? value.substring(0, value.length() - 1) : value;
+ }
+
+ public static final class Options {
+ private final String credential;
+ private String environment;
+ private String gatewayUrl;
+ private Duration timeout;
+ private HttpClient httpClient;
+
+ private Options(String credential) {
+ this.credential = credential;
+ }
+
+ public Options environment(String value) {
+ this.environment = value;
+ return this;
+ }
+
+ public Options gatewayUrl(String value) {
+ this.gatewayUrl = value;
+ return this;
+ }
+
+ public Options timeout(Duration value) {
+ this.timeout = value;
+ return this;
+ }
+
+ public Options httpClient(HttpClient value) {
+ this.httpClient = value;
+ return this;
+ }
+ }
+
+ public static final class SearchOptions {
+ public Integer limit;
+ public Integer offset;
+ public List tags;
+ public String sort;
+ public String requestId;
+ }
+
+ public static final class InvokeOptions {
+ public String costUsdc;
+ public String idempotencyKey;
+ public String responseMode;
+ public String requestId;
+ }
+
+ public static final class LlmInvokeOptions {
+ public String maxCostUsdc;
+ public String idempotencyKey;
+ public String requestId;
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public record ServiceRecord(
+ @JsonProperty("serviceId") String serviceId,
+ String id,
+ @JsonProperty("serviceName") String serviceName,
+ String status,
+ @JsonProperty("serviceKind") String serviceKind,
+ @JsonProperty("priceModel") String priceModel,
+ JsonNode pricing,
+ String summary,
+ List tags) {}
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public record InvocationResult(
+ @JsonProperty("invocationId") String invocationId,
+ String status,
+ @JsonProperty("chargedUsdc") String chargedUsdc,
+ JsonNode result,
+ JsonNode usage,
+ JsonNode synapse,
+ JsonNode error,
+ JsonNode receipt) {}
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ private record DiscoveryResponse(List services, List results) {
+ List serviceList() {
+ return results == null ? (services == null ? List.of() : services) : results;
+ }
+ }
+
+ public static class SynapseException extends RuntimeException {
+ public final String code;
+
+ public SynapseException(String code, String message) {
+ super(message);
+ this.code = code;
+ }
+
+ public SynapseException(String code, String message, Throwable cause) {
+ super(message, cause);
+ this.code = code;
+ }
+ }
+
+ public static final class AuthenticationException extends SynapseException {
+ public AuthenticationException(String code, String message) {
+ super(code, message);
+ }
+ }
+
+ public static class BudgetException extends SynapseException {
+ public BudgetException(String code, String message) {
+ super(code, message);
+ }
+ }
+
+ public static class InvokeException extends SynapseException {
+ public InvokeException(String code, String message) {
+ super(code, message);
+ }
+ }
+
+ public static final class PriceMismatchException extends InvokeException {
+ public final BigDecimal expectedPriceUsdc;
+ public final BigDecimal currentPriceUsdc;
+
+ public PriceMismatchException(String code, String message, String expectedPriceUsdc, String currentPriceUsdc) {
+ super(code, message);
+ this.expectedPriceUsdc = expectedPriceUsdc.isBlank() ? BigDecimal.ZERO : new BigDecimal(expectedPriceUsdc);
+ this.currentPriceUsdc = currentPriceUsdc.isBlank() ? BigDecimal.ZERO : new BigDecimal(currentPriceUsdc);
+ }
+ }
+}
diff --git a/java/src/test/java/ai/synapsenetwork/sdk/SynapseClientTest.java b/java/src/test/java/ai/synapsenetwork/sdk/SynapseClientTest.java
new file mode 100644
index 0000000..ca68038
--- /dev/null
+++ b/java/src/test/java/ai/synapsenetwork/sdk/SynapseClientTest.java
@@ -0,0 +1,114 @@
+package ai.synapsenetwork.sdk;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+final class SynapseClientTest {
+ @Test
+ void resolvesGatewayUrlWithStagingDefaultAndExplicitOverride() {
+ assertEquals(
+ "https://gateway.example.com",
+ SynapseClient.resolveGatewayUrl("staging", "https://gateway.example.com/"));
+ assertEquals(SynapseClient.STAGING_GATEWAY_URL, SynapseClient.resolveGatewayUrl(null, null));
+ assertThrows(IllegalArgumentException.class, () -> SynapseClient.resolveGatewayUrl("local", null));
+ }
+
+ @Test
+ void searchInvokeLlmAndReceiptUseContractFixtures() throws Exception {
+ try (FixtureServer server = new FixtureServer()) {
+ SynapseClient client = new SynapseClient(SynapseClient.options("agt_test").gatewayUrl(server.url()));
+
+ var services = client.search("fixture", new SynapseClient.SearchOptions());
+ assertEquals(1, services.size());
+ assertEquals("svc_contract_weather", services.get(0).serviceId());
+
+ SynapseClient.LlmInvokeOptions llmOptions = new SynapseClient.LlmInvokeOptions();
+ llmOptions.maxCostUsdc = "0.010000";
+ llmOptions.idempotencyKey = "idem-llm";
+ var result = client.invokeLlm(
+ "svc_deepseek_chat",
+ Map.of("messages", java.util.List.of(Map.of("role", "user", "content", "hello"))),
+ llmOptions);
+ assertEquals("inv_contract_llm", result.invocationId());
+ assertEquals("0.004200", result.chargedUsdc());
+ assertFalse(server.lastInvokeBody.contains("costUsdc"));
+ assertTrue(server.lastInvokeBody.contains("\"maxCostUsdc\":\"0.010000\""));
+
+ var receipt = client.getInvocation("inv_contract_llm");
+ assertEquals("SETTLED", receipt.status());
+ }
+ }
+
+ @Test
+ void fixedPriceInvokeRequiresCostAndMapsPriceMismatch() throws Exception {
+ try (FixtureServer server = new FixtureServer()) {
+ server.priceMismatch = true;
+ SynapseClient client = new SynapseClient(SynapseClient.options("agt_test").gatewayUrl(server.url()));
+ assertThrows(IllegalArgumentException.class, () -> client.invoke("svc", Map.of(), new SynapseClient.InvokeOptions()));
+
+ SynapseClient.InvokeOptions opts = new SynapseClient.InvokeOptions();
+ opts.costUsdc = "0.010000";
+ SynapseClient.PriceMismatchException err =
+ assertThrows(SynapseClient.PriceMismatchException.class, () -> client.invoke("svc", Map.of(), opts));
+ assertEquals(new BigDecimal("0.012000"), err.currentPriceUsdc);
+ }
+ }
+
+ private static final class FixtureServer implements AutoCloseable {
+ private final HttpServer server;
+ boolean priceMismatch;
+ String lastInvokeBody = "";
+
+ FixtureServer() throws IOException {
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ server.createContext("/", this::handle);
+ server.start();
+ }
+
+ String url() {
+ return "http://127.0.0.1:" + server.getAddress().getPort();
+ }
+
+ private void handle(HttpExchange exchange) throws IOException {
+ assertEquals("agt_test", exchange.getRequestHeaders().getFirst("X-Credential"));
+ String path = exchange.getRequestURI().getPath();
+ if (path.equals("/api/v1/agent/discovery/search")) {
+ write(exchange, 200, fixture("discovery_search_response.json"));
+ } else if (path.equals("/api/v1/agent/invoke")) {
+ lastInvokeBody = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
+ write(exchange, priceMismatch ? 422 : 200, fixture(priceMismatch ? "error_price_mismatch.json" : "llm_invoke_response.json"));
+ } else if (path.equals("/api/v1/agent/invocations/inv_contract_llm")) {
+ write(exchange, 200, fixture("receipt_response.json"));
+ } else {
+ write(exchange, 404, "{}");
+ }
+ }
+
+ private String fixture(String name) throws IOException {
+ return Files.readString(Path.of("..", "contracts", "sdk", "fixtures", name));
+ }
+
+ private void write(HttpExchange exchange, int status, String body) throws IOException {
+ byte[] raw = body.getBytes(StandardCharsets.UTF_8);
+ exchange.getResponseHeaders().set("Content-Type", "application/json");
+ exchange.sendResponseHeaders(status, raw.length);
+ exchange.getResponseBody().write(raw);
+ exchange.close();
+ }
+
+ @Override
+ public void close() {
+ server.stop(0);
+ }
+ }
+}
diff --git a/python/examples/e2e.py b/python/examples/e2e.py
new file mode 100644
index 0000000..3155fd5
--- /dev/null
+++ b/python/examples/e2e.py
@@ -0,0 +1,214 @@
+from __future__ import annotations
+
+import json
+import os
+import sys
+import time
+from decimal import Decimal
+from typing import Any
+from uuid import uuid4
+
+from synapse_client import AuthenticationError, InvokeError, SynapseClient
+
+DEFAULT_FIXED_PAYLOAD = {"prompt": "hello"}
+DEFAULT_LLM_PAYLOAD = {"messages": [{"role": "user", "content": "hello"}]}
+
+
+def main() -> None:
+ client = new_client(require_env("SYNAPSE_AGENT_KEY"))
+ local_negative(client)
+ client.check_gateway_health()
+ emit("health", status="ok")
+
+ if not env_bool("SYNAPSE_E2E_SKIP_AUTH_NEGATIVE"):
+ auth_negative()
+
+ service_id, cost_usdc, fixed_payload = fixed_target(client)
+ fixed_result = client.invoke(
+ service_id,
+ fixed_payload,
+ cost_usdc=cost_usdc,
+ idempotency_key=f"python-e2e-fixed-{uuid4().hex}",
+ )
+ fixed_receipt = await_receipt(client, fixed_result.invocation_id)
+ emit(
+ "fixed-price",
+ status=fixed_result.status,
+ invocation_id=fixed_result.invocation_id,
+ charged_usdc=str(fixed_receipt.charged_usdc),
+ receipt_status=fixed_receipt.status,
+ service_id=service_id,
+ )
+
+ if env_bool("SYNAPSE_E2E_FREE_ONLY"):
+ return
+
+ llm_service_id = env_default("SYNAPSE_E2E_LLM_SERVICE_ID", "svc_deepseek_chat")
+ max_cost_usdc = env_default("SYNAPSE_E2E_LLM_MAX_COST_USDC", "0.010000")
+ llm_result = client.invoke_llm(
+ llm_service_id,
+ json_payload("SYNAPSE_E2E_LLM_PAYLOAD_JSON", DEFAULT_LLM_PAYLOAD),
+ max_cost_usdc=max_cost_usdc,
+ idempotency_key=f"python-e2e-llm-{uuid4().hex}",
+ )
+ llm_receipt = await_receipt(client, llm_result.invocation_id)
+ charged_usdc = str(llm_receipt.charged_usdc or llm_result.charged_usdc)
+ if Decimal(charged_usdc) > Decimal(max_cost_usdc):
+ fail(f"llm chargedUsdc {charged_usdc} exceeds maxCostUsdc {max_cost_usdc}")
+ emit(
+ "llm",
+ status=llm_result.status,
+ invocation_id=llm_result.invocation_id,
+ charged_usdc=charged_usdc,
+ receipt_status=llm_receipt.status,
+ service_id=llm_service_id,
+ )
+
+
+def new_client(credential: str) -> SynapseClient:
+ return SynapseClient(
+ api_key=credential,
+ gateway_url=os.getenv("SYNAPSE_GATEWAY_URL") or None,
+ environment="staging",
+ )
+
+
+def local_negative(client: SynapseClient) -> None:
+ expect_error(
+ lambda: client.invoke("svc_local", {}, cost_usdc=None),
+ ValueError,
+ "fixed-price invoke without cost_usdc should fail locally",
+ )
+ expect_error(
+ lambda: client.invoke_llm("svc_llm", {"stream": True}),
+ InvokeError,
+ "LLM stream=True should fail locally",
+ )
+ emit("local-negative", status="ok")
+
+
+def auth_negative() -> None:
+ invalid_client = new_client("agt_invalid")
+ expect_error(
+ lambda: invalid_client.invoke("svc_invalid_auth_probe", {}, cost_usdc="0"),
+ AuthenticationError,
+ "invalid credential should fail with AuthenticationError",
+ )
+ emit("auth-negative", status="ok")
+
+
+def fixed_target(client: SynapseClient) -> tuple[str, str, dict[str, Any]]:
+ payload = json_payload("SYNAPSE_E2E_FIXED_PAYLOAD_JSON", DEFAULT_FIXED_PAYLOAD)
+ configured_service_id = os.getenv("SYNAPSE_E2E_FIXED_SERVICE_ID", "").strip()
+ if configured_service_id:
+ configured_cost = os.getenv("SYNAPSE_E2E_FIXED_COST_USDC", "").strip()
+ if not configured_cost:
+ fail("SYNAPSE_E2E_FIXED_COST_USDC is required when SYNAPSE_E2E_FIXED_SERVICE_ID is set")
+ return configured_service_id, configured_cost, payload
+
+ services = client.search("free", limit=25)
+ for service in services:
+ amount = str(service.pricing.amount)
+ if (
+ service.service_id
+ and service.service_kind.lower() == "api"
+ and service.price_model.lower() == "fixed"
+ and Decimal(amount) == Decimal("0")
+ ):
+ return service.service_id, amount, payload
+ fail(
+ "no free fixed-price API service found; set SYNAPSE_E2E_FIXED_SERVICE_ID, "
+ "SYNAPSE_E2E_FIXED_COST_USDC, and SYNAPSE_E2E_FIXED_PAYLOAD_JSON"
+ )
+ raise RuntimeError("unreachable")
+
+
+def await_receipt(client: SynapseClient, invocation_id: str):
+ if not invocation_id:
+ fail("invoke returned empty invocationId")
+ deadline = time.monotonic() + env_int("SYNAPSE_E2E_RECEIPT_TIMEOUT_S", 60)
+ while True:
+ receipt = client.get_invocation(invocation_id)
+ if receipt.invocation_id and receipt.invocation_id != invocation_id:
+ fail(f"receipt invocationId mismatch: got {receipt.invocation_id} want {invocation_id}")
+ if receipt.status in {"SUCCEEDED", "SETTLED"}:
+ return receipt
+ if time.monotonic() > deadline:
+ fail(f"receipt {invocation_id} did not reach a terminal status, last status={receipt.status}")
+ time.sleep(2)
+
+
+def json_payload(name: str, fallback: dict[str, Any]) -> dict[str, Any]:
+ raw = os.getenv(name)
+ if not raw:
+ return fallback
+ value = json.loads(raw)
+ if not isinstance(value, dict):
+ fail(f"{name} must be a JSON object")
+ return value
+
+
+def emit(
+ scenario: str,
+ *,
+ status: str,
+ invocation_id: str = "",
+ charged_usdc: str = "",
+ receipt_status: str = "",
+ service_id: str = "",
+) -> None:
+ event = {
+ "language": "python",
+ "scenario": scenario,
+ "status": status,
+ "invocationId": invocation_id,
+ "chargedUsdc": charged_usdc,
+ "receiptStatus": receipt_status,
+ "serviceId": service_id,
+ }
+ print(json.dumps({key: value for key, value in event.items() if value}, separators=(",", ":")))
+
+
+def expect_error(action, expected_type: type[BaseException], message: str) -> None:
+ try:
+ action()
+ except expected_type:
+ return
+ except Exception as exc:
+ fail(f"{message}; got {type(exc).__name__}: {exc}")
+ fail(message)
+
+
+def require_env(name: str) -> str:
+ value = os.getenv(name, "").strip()
+ if not value:
+ fail(f"{name} is required")
+ return value
+
+
+def env_default(name: str, fallback: str) -> str:
+ return os.getenv(name, "").strip() or fallback
+
+
+def env_int(name: str, fallback: int) -> int:
+ raw = os.getenv(name, "").strip()
+ if not raw:
+ return fallback
+ try:
+ value = int(raw)
+ except ValueError:
+ return fallback
+ return value if value > 0 else fallback
+
+
+def env_bool(name: str) -> bool:
+ return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "y"}
+
+
+def fail(message: str) -> None:
+ print(f"python e2e failed: {message}", file=sys.stderr)
+ raise SystemExit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/python/examples/free_service_smoke.py b/python/examples/free_service_smoke.py
new file mode 100644
index 0000000..56de811
--- /dev/null
+++ b/python/examples/free_service_smoke.py
@@ -0,0 +1,64 @@
+from __future__ import annotations
+
+import json
+import os
+from uuid import uuid4
+
+from synapse_client import SynapseClient
+
+
+def main() -> None:
+ client = SynapseClient(
+ api_key=require_env("SYNAPSE_AGENT_KEY"),
+ gateway_url=os.getenv("SYNAPSE_GATEWAY_URL") or None,
+ environment="staging",
+ )
+ services = client.search("free", limit=25)
+ service = next(
+ (
+ candidate
+ for candidate in services
+ if candidate.service_kind.lower() == "api"
+ and candidate.price_model.lower() == "fixed"
+ and str(candidate.pricing.amount) == "0"
+ ),
+ None,
+ )
+ if service is None:
+ raise RuntimeError(
+ "no free fixed-price API service found; set SYNAPSE_E2E_FIXED_SERVICE_ID "
+ "and SYNAPSE_E2E_FIXED_COST_USDC for paid smoke tests"
+ )
+
+ result = client.invoke(
+ service.service_id,
+ {"prompt": "hello"},
+ cost_usdc=str(service.pricing.amount),
+ idempotency_key=f"python-free-smoke-{uuid4().hex}",
+ )
+ receipt = client.get_invocation(result.invocation_id)
+ print(
+ json.dumps(
+ {
+ "language": "python",
+ "scenario": "free-service-smoke",
+ "serviceId": service.service_id,
+ "invocationId": result.invocation_id,
+ "status": result.status,
+ "chargedUsdc": str(receipt.charged_usdc),
+ "receiptStatus": receipt.status,
+ },
+ separators=(",", ":"),
+ )
+ )
+
+
+def require_env(name: str) -> str:
+ value = os.getenv(name, "").strip()
+ if not value:
+ raise RuntimeError(f"{name} is required")
+ return value
+
+
+if __name__ == "__main__":
+ main()
diff --git a/python/examples/llm_smoke.py b/python/examples/llm_smoke.py
new file mode 100644
index 0000000..8a7eb44
--- /dev/null
+++ b/python/examples/llm_smoke.py
@@ -0,0 +1,63 @@
+from __future__ import annotations
+
+import json
+import os
+from decimal import Decimal
+from uuid import uuid4
+
+from synapse_client import SynapseClient
+
+
+def main() -> None:
+ client = SynapseClient(
+ api_key=require_env("SYNAPSE_AGENT_KEY"),
+ gateway_url=os.getenv("SYNAPSE_GATEWAY_URL") or None,
+ environment="staging",
+ )
+ service_id = os.getenv("SYNAPSE_E2E_LLM_SERVICE_ID", "svc_deepseek_chat")
+ max_cost_usdc = os.getenv("SYNAPSE_E2E_LLM_MAX_COST_USDC", "0.010000")
+ result = client.invoke_llm(
+ service_id,
+ payload({"messages": [{"role": "user", "content": "hello"}]}),
+ max_cost_usdc=max_cost_usdc,
+ idempotency_key=f"python-llm-smoke-{uuid4().hex}",
+ )
+ receipt = client.get_invocation(result.invocation_id)
+ charged = Decimal(str(receipt.charged_usdc or result.charged_usdc))
+ if charged > Decimal(max_cost_usdc):
+ raise RuntimeError(f"chargedUsdc {charged} exceeds maxCostUsdc {max_cost_usdc}")
+ print(
+ json.dumps(
+ {
+ "language": "python",
+ "scenario": "llm-smoke",
+ "serviceId": service_id,
+ "invocationId": result.invocation_id,
+ "status": result.status,
+ "chargedUsdc": str(charged),
+ "receiptStatus": receipt.status,
+ },
+ separators=(",", ":"),
+ )
+ )
+
+
+def payload(default_value: dict) -> dict:
+ raw = os.getenv("SYNAPSE_E2E_LLM_PAYLOAD_JSON")
+ if not raw:
+ return default_value
+ value = json.loads(raw)
+ if not isinstance(value, dict):
+ raise RuntimeError("SYNAPSE_E2E_LLM_PAYLOAD_JSON must be a JSON object")
+ return value
+
+
+def require_env(name: str) -> str:
+ value = os.getenv(name, "").strip()
+ if not value:
+ raise RuntimeError(f"{name} is required")
+ return value
+
+
+if __name__ == "__main__":
+ main()
diff --git a/python/synapse_client/test/test_client_unit.py b/python/synapse_client/test/test_client_unit.py
index 00e45cf..94b3f69 100644
--- a/python/synapse_client/test/test_client_unit.py
+++ b/python/synapse_client/test/test_client_unit.py
@@ -1,5 +1,8 @@
from __future__ import annotations
+import json
+from pathlib import Path
+
import pytest
from synapse_client import AgentWallet, SynapseClient, resolve_gateway_url
@@ -26,6 +29,11 @@ def json(self):
return self._json_data
+def _contract_fixture(name: str):
+ path = Path(__file__).resolve().parents[3] / "contracts" / "sdk" / "fixtures" / name
+ return json.loads(path.read_text(encoding="utf-8"))
+
+
def test_client_requires_api_key(monkeypatch):
monkeypatch.delenv("SYNAPSE_AGENT_KEY", raising=False)
monkeypatch.delenv("SYNAPSE_API_KEY", raising=False)
@@ -529,3 +537,45 @@ def test_gateway_health_and_empty_discovery_diagnostics(monkeypatch):
diagnostics = client.explain_discovery_empty_result(query="quotes", tags=["text"])
assert diagnostics["query"] == "quotes"
assert "suggestions" in diagnostics
+
+
+def test_contract_fixtures_cover_python_search_llm_receipt_and_price_mismatch(monkeypatch):
+ post_calls = []
+
+ def fake_post(url, headers, json, timeout):
+ post_calls.append({"url": url, "headers": headers, "json": json})
+ if url.endswith("/api/v1/agent/discovery/search"):
+ return DummyResponse(json_data=_contract_fixture("discovery_search_response.json"))
+ if url.endswith("/api/v1/agent/invoke") and "costUsdc" not in json:
+ return DummyResponse(json_data=_contract_fixture("llm_invoke_response.json"))
+ return DummyResponse(status_code=422, ok=False, json_data=_contract_fixture("error_price_mismatch.json"))
+
+ def fake_get(url, headers=None, timeout=None):
+ return DummyResponse(json_data=_contract_fixture("receipt_response.json"))
+
+ monkeypatch.setattr("synapse_client.client.requests.post", fake_post)
+ monkeypatch.setattr("synapse_client.client.requests.get", fake_get)
+
+ client = SynapseClient(api_key="agt_test", gateway_url="https://gateway.example")
+ services = client.search("fixture", limit=10)
+ assert services[0].service_id == "svc_contract_weather"
+ assert str(services[0].price_usdc) == "0.010000"
+
+ result = client.invoke_llm(
+ "svc_deepseek_chat",
+ {"messages": [{"role": "user", "content": "hello"}]},
+ max_cost_usdc="0.010000",
+ idempotency_key="idem-llm",
+ )
+ assert result.invocation_id == "inv_contract_llm"
+ assert result.synapse is not None
+ assert result.synapse.price_model == "token_metered"
+ assert "costUsdc" not in post_calls[1]["json"]
+ assert post_calls[1]["json"]["maxCostUsdc"] == "0.010000"
+
+ receipt = client.get_invocation("inv_contract_fixed")
+ assert receipt.status == "SETTLED"
+
+ with pytest.raises(PriceMismatchError) as exc_info:
+ client.invoke("svc_contract_weather", {}, cost_usdc="0.010000")
+ assert exc_info.value.current_price_usdc == pytest.approx(0.012)
diff --git a/scripts/ci/dotnet_checks.sh b/scripts/ci/dotnet_checks.sh
new file mode 100755
index 0000000..ec39fed
--- /dev/null
+++ b/scripts/ci/dotnet_checks.sh
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+cd "$ROOT_DIR"
+
+LOCAL_DOTNET_DIR="${SYNAPSE_E2E_DOTNET_DIR:-$HOME/.synapse-network-sdk-e2e/dotnet}"
+if ! command -v dotnet >/dev/null 2>&1 && [[ -x "$LOCAL_DOTNET_DIR/dotnet" ]]; then
+ export DOTNET_ROOT="$LOCAL_DOTNET_DIR"
+ export PATH="$DOTNET_ROOT:$PATH"
+fi
+
+if ! command -v dotnet >/dev/null 2>&1; then
+ echo "[ci:dotnet] .NET SDK not found; skipping local .NET checks"
+ exit 0
+fi
+
+echo "[ci:dotnet] running .NET SDK tests"
+dotnet test dotnet/tests/SynapseNetwork.Sdk.Tests/SynapseNetwork.Sdk.Tests.csproj --configuration Release
+
+echo "[ci:dotnet] .NET SDK checks passed"
diff --git a/scripts/ci/go_checks.sh b/scripts/ci/go_checks.sh
new file mode 100755
index 0000000..0e82767
--- /dev/null
+++ b/scripts/ci/go_checks.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+cd "$ROOT_DIR"
+
+if ! command -v go >/dev/null 2>&1; then
+ echo "[ci:go] Go toolchain not found; skipping local Go checks"
+ exit 0
+fi
+
+echo "[ci:go] running Go SDK tests"
+go -C go test ./...
+
+echo "[ci:go] Go SDK checks passed"
diff --git a/scripts/ci/java_checks.sh b/scripts/ci/java_checks.sh
new file mode 100755
index 0000000..dc78c4f
--- /dev/null
+++ b/scripts/ci/java_checks.sh
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+cd "$ROOT_DIR"
+
+if ! command -v mvn >/dev/null 2>&1; then
+ echo "[ci:java] Maven not found; skipping local Java checks"
+ exit 0
+fi
+
+echo "[ci:java] running Java SDK tests and package build"
+mvn -q -f java/pom.xml test package
+mvn -q -f java/examples/pom.xml compile
+
+echo "[ci:java] Java SDK checks passed"
diff --git a/scripts/ci/pr_checks.sh b/scripts/ci/pr_checks.sh
index e9fe9ff..28f94a9 100755
--- a/scripts/ci/pr_checks.sh
+++ b/scripts/ci/pr_checks.sh
@@ -9,6 +9,9 @@ echo "[ci:pr] running SDK pull request quality gates"
bash scripts/ci/repo_hygiene_checks.sh
bash scripts/ci/python_checks.sh
bash scripts/ci/typescript_checks.sh
+bash scripts/ci/go_checks.sh
+bash scripts/ci/java_checks.sh
+bash scripts/ci/dotnet_checks.sh
bash scripts/ci/security_checks.sh
echo "[ci:pr] all SDK quality gates passed"
diff --git a/scripts/ci/python_checks.sh b/scripts/ci/python_checks.sh
index 61cdc21..390ff16 100755
--- a/scripts/ci/python_checks.sh
+++ b/scripts/ci/python_checks.sh
@@ -30,6 +30,9 @@ echo "[ci:python] running Python lint"
echo "[ci:python] running Python type checks"
"$PYTHON_BIN" -m mypy --config-file python/pyproject.toml python/synapse_client
+echo "[ci:python] compiling Python examples"
+"$PYTHON_BIN" -m py_compile python/examples/*.py
+
echo "[ci:python] running Python unit tests with 80% coverage gate"
"$PYTHON_BIN" -m pytest -q \
python/synapse_client/test/test_auth_unit.py \
diff --git a/scripts/ci/typescript_checks.sh b/scripts/ci/typescript_checks.sh
index 3a451a6..bf927e4 100755
--- a/scripts/ci/typescript_checks.sh
+++ b/scripts/ci/typescript_checks.sh
@@ -10,6 +10,9 @@ npm_config_registry="${NPM_CONFIG_REGISTRY:-https://registry.npmjs.org}" npm ci
echo "[ci:typescript] running TypeScript format, lint, and type checks"
npm run lint --prefix typescript
+echo "[ci:typescript] type-checking TypeScript examples"
+npm run typecheck:examples --prefix typescript
+
echo "[ci:typescript] building TypeScript package"
npm run build --prefix typescript
diff --git a/scripts/e2e/sdk_wave1_local.sh b/scripts/e2e/sdk_wave1_local.sh
new file mode 100755
index 0000000..5145910
--- /dev/null
+++ b/scripts/e2e/sdk_wave1_local.sh
@@ -0,0 +1,269 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+cd "$ROOT_DIR"
+
+LANGUAGES="python,typescript,go,java,dotnet"
+FREE_ONLY=false
+INSTALL_MISSING=true
+SKIP_AUTH_NEGATIVE=false
+
+usage() {
+ cat <<'EOF'
+Usage: bash scripts/e2e/sdk_wave1_local.sh [options]
+
+Options:
+ --languages python,typescript,go,java,dotnet
+ Comma-separated SDKs to verify. Default: all SDKs
+ --free-only Skip token-metered LLM invoke
+ --skip-install Do not install missing local toolchains
+ --skip-auth-negative Skip invalid credential negative checks
+ -h, --help Show this help
+
+Required:
+ SYNAPSE_AGENT_KEY Staging Agent Key, e.g. agt_xxx
+
+Optional:
+ SYNAPSE_GATEWAY_URL Explicit Gateway URL; defaults to SDK staging
+ SYNAPSE_E2E_FIXED_SERVICE_ID Fixed-price API service override
+ SYNAPSE_E2E_FIXED_COST_USDC Required with SYNAPSE_E2E_FIXED_SERVICE_ID
+ SYNAPSE_E2E_FIXED_PAYLOAD_JSON
+ SYNAPSE_E2E_LLM_SERVICE_ID Default: svc_deepseek_chat
+ SYNAPSE_E2E_LLM_MAX_COST_USDC Default: 0.010000
+ SYNAPSE_E2E_LLM_PAYLOAD_JSON
+EOF
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --languages)
+ LANGUAGES="${2:-}"
+ shift 2
+ ;;
+ --free-only)
+ FREE_ONLY=true
+ shift
+ ;;
+ --skip-install)
+ INSTALL_MISSING=false
+ shift
+ ;;
+ --skip-auth-negative)
+ SKIP_AUTH_NEGATIVE=true
+ shift
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "[e2e:sdk-wave1] unknown option: $1" >&2
+ usage >&2
+ exit 2
+ ;;
+ esac
+done
+
+if [[ -z "${SYNAPSE_AGENT_KEY:-}" ]]; then
+ echo "[e2e:sdk-wave1] SYNAPSE_AGENT_KEY is required for real staging E2E" >&2
+ exit 2
+fi
+
+export SYNAPSE_E2E_FREE_ONLY="$FREE_ONLY"
+export SYNAPSE_E2E_SKIP_AUTH_NEGATIVE="$SKIP_AUTH_NEGATIVE"
+
+if [[ -n "${SYNAPSE_GATEWAY_URL:-}" ]]; then
+ echo "[e2e:sdk-wave1] using explicit SYNAPSE_GATEWAY_URL=$SYNAPSE_GATEWAY_URL"
+else
+ echo "[e2e:sdk-wave1] using SDK default staging Gateway"
+fi
+
+fail_missing_tool() {
+ local name="$1"
+ echo "[e2e:sdk-wave1] missing $name and --skip-install was set" >&2
+ exit 2
+}
+
+require_brew() {
+ if ! command -v brew >/dev/null 2>&1; then
+ echo "[e2e:sdk-wave1] Homebrew is required to auto-install $*" >&2
+ exit 2
+ fi
+}
+
+brew_install() {
+ if [[ "$INSTALL_MISSING" != "true" ]]; then
+ fail_missing_tool "$*"
+ fi
+ require_brew "$@"
+ echo "[e2e:sdk-wave1] installing $*"
+ brew install "$@"
+}
+
+ensure_go() {
+ if command -v go >/dev/null 2>&1; then
+ return
+ fi
+ brew_install go
+}
+
+java_major_version() {
+ if ! command -v java >/dev/null 2>&1; then
+ return 1
+ fi
+ local spec
+ spec="$(java -XshowSettings:properties -version 2>&1 | awk -F= '/java.specification.version/ {gsub(/[[:space:]]/, "", $2); print $2; exit}')"
+ if [[ "$spec" == 1.* ]]; then
+ echo "${spec#1.}"
+ else
+ echo "$spec"
+ fi
+}
+
+ensure_java() {
+ if ! command -v mvn >/dev/null 2>&1; then
+ brew_install maven
+ fi
+
+ local major
+ major="$(java_major_version || true)"
+ if [[ -z "$major" || "$major" -lt 17 ]]; then
+ brew_install openjdk@17
+ if /usr/libexec/java_home -v 17 >/dev/null 2>&1; then
+ export JAVA_HOME
+ JAVA_HOME="$(/usr/libexec/java_home -v 17)"
+ elif [[ -d "/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home" ]]; then
+ export JAVA_HOME="/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home"
+ export PATH="$JAVA_HOME/bin:$PATH"
+ fi
+ fi
+}
+
+has_dotnet_8() {
+ command -v dotnet >/dev/null 2>&1 && dotnet --list-sdks 2>/dev/null | grep -q '^8\.'
+}
+
+ensure_dotnet() {
+ if has_dotnet_8; then
+ return
+ fi
+ if [[ "$INSTALL_MISSING" != "true" ]]; then
+ fail_missing_tool ".NET SDK 8.0"
+ fi
+
+ local dotnet_dir="${SYNAPSE_E2E_DOTNET_DIR:-$HOME/.synapse-network-sdk-e2e/dotnet}"
+ mkdir -p "$dotnet_dir"
+ echo "[e2e:sdk-wave1] installing .NET SDK 8.0 into $dotnet_dir"
+ curl -fsSL https://dot.net/v1/dotnet-install.sh -o "$dotnet_dir/dotnet-install.sh"
+ bash "$dotnet_dir/dotnet-install.sh" --channel 8.0 --install-dir "$dotnet_dir" --no-path
+ export DOTNET_ROOT="$dotnet_dir"
+ export PATH="$DOTNET_ROOT:$PATH"
+}
+
+ensure_python3() {
+ if command -v python3 >/dev/null 2>&1; then
+ return
+ fi
+ brew_install python
+}
+
+ensure_node() {
+ if command -v npm >/dev/null 2>&1; then
+ return
+ fi
+ brew_install node
+}
+
+validate_events() {
+ local output_file="$1"
+ local language="$2"
+ python3 - "$output_file" "$language" "$FREE_ONLY" "$SKIP_AUTH_NEGATIVE" <<'PY'
+import json
+import sys
+
+path, language, free_only, skip_auth_negative = sys.argv[1:5]
+required = {"health", "local-negative", "fixed-price"}
+if free_only != "true":
+ required.add("llm")
+if skip_auth_negative != "true":
+ required.add("auth-negative")
+
+seen = set()
+with open(path, "r", encoding="utf-8") as handle:
+ for line in handle:
+ line = line.strip()
+ if not line.startswith("{"):
+ continue
+ event = json.loads(line)
+ if event.get("language") != language:
+ raise SystemExit(f"unexpected language event: {event}")
+ scenario = event.get("scenario")
+ if scenario:
+ seen.add(scenario)
+
+missing = sorted(required - seen)
+if missing:
+ raise SystemExit(f"{language} e2e missing scenarios: {', '.join(missing)}")
+PY
+}
+
+run_and_validate() {
+ local language="$1"
+ shift
+ local output_file
+ output_file="$(mktemp)"
+ echo "[e2e:sdk-wave1] running $language real Gateway E2E"
+ "$@" | tee "$output_file"
+ validate_events "$output_file" "$language"
+ rm -f "$output_file"
+}
+
+run_language() {
+ local language="$1"
+ case "$language" in
+ python)
+ ensure_python3
+ bash scripts/ci/python_checks.sh
+ run_and_validate python env PYTHONPATH="$ROOT_DIR/python" python3 python/examples/e2e.py
+ ;;
+ typescript|ts)
+ ensure_node
+ bash scripts/ci/typescript_checks.sh
+ run_and_validate typescript npm run example:e2e --prefix typescript
+ ;;
+ go)
+ ensure_go
+ bash scripts/ci/go_checks.sh
+ run_and_validate go go -C go run ./examples/e2e
+ ;;
+ java)
+ ensure_java
+ bash scripts/ci/java_checks.sh
+ run_and_validate java mvn -q -f java/examples/pom.xml \
+ org.codehaus.mojo:exec-maven-plugin:3.5.0:java \
+ -Dexec.mainClass=ai.synapsenetwork.sdk.examples.E2eSmoke
+ ;;
+ dotnet)
+ ensure_dotnet
+ bash scripts/ci/dotnet_checks.sh
+ run_and_validate dotnet dotnet run --project dotnet/examples/e2e/e2e.csproj --configuration Release
+ ;;
+ *)
+ echo "[e2e:sdk-wave1] unsupported language: $language" >&2
+ exit 2
+ ;;
+ esac
+}
+
+ensure_python3
+
+IFS=',' read -r -a SELECTED_LANGUAGES <<< "$LANGUAGES"
+for language in "${SELECTED_LANGUAGES[@]}"; do
+ language="$(echo "$language" | tr -d '[:space:]')"
+ if [[ -n "$language" ]]; then
+ run_language "$language"
+ fi
+done
+
+echo "[e2e:sdk-wave1] all selected SDKs passed real Gateway E2E"
diff --git a/typescript/examples/_shared.ts b/typescript/examples/_shared.ts
new file mode 100644
index 0000000..0efffe4
--- /dev/null
+++ b/typescript/examples/_shared.ts
@@ -0,0 +1,179 @@
+import { AuthenticationError, InvokeError, SynapseClient } from "../src";
+import type { ServiceRecord } from "../src";
+
+export const DEFAULT_FIXED_PAYLOAD: Record = { prompt: "hello" };
+export const DEFAULT_LLM_PAYLOAD: Record = {
+ messages: [{ role: "user", content: "hello" }],
+};
+
+export interface E2eEvent {
+ language: "typescript";
+ scenario: string;
+ invocationId?: string;
+ status?: string;
+ chargedUsdc?: string;
+ receiptStatus?: string;
+ serviceId?: string;
+}
+
+export function client(credential = requireEnv("SYNAPSE_AGENT_KEY")): SynapseClient {
+ const gatewayUrl = process.env.SYNAPSE_GATEWAY_URL?.trim();
+ return new SynapseClient({
+ credential,
+ gatewayUrl: gatewayUrl || undefined,
+ environment: gatewayUrl ? undefined : "staging",
+ });
+}
+
+export function requireEnv(name: string): string {
+ const value = process.env[name]?.trim();
+ if (!value) throw new Error(`${name} is required`);
+ return value;
+}
+
+export function envDefault(name: string, fallback: string): string {
+ return process.env[name]?.trim() || fallback;
+}
+
+export function envBool(name: string): boolean {
+ return ["1", "true", "yes", "y"].includes((process.env[name] ?? "").trim().toLowerCase());
+}
+
+export function envInt(name: string, fallback: number): number {
+ const value = Number.parseInt((process.env[name] ?? "").trim(), 10);
+ return Number.isFinite(value) && value > 0 ? value : fallback;
+}
+
+export function jsonPayload(name: string, fallback: Record): Record {
+ const raw = process.env[name]?.trim();
+ if (!raw) return fallback;
+ const parsed = JSON.parse(raw) as unknown;
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+ throw new Error(`${name} must be a JSON object`);
+ }
+ return parsed as Record;
+}
+
+export async function fixedTarget(
+ synapse: SynapseClient
+): Promise<{ serviceId: string; costUsdc: string; payload: Record }> {
+ const payload = jsonPayload("SYNAPSE_E2E_FIXED_PAYLOAD_JSON", DEFAULT_FIXED_PAYLOAD);
+ const configuredServiceId = process.env.SYNAPSE_E2E_FIXED_SERVICE_ID?.trim();
+ if (configuredServiceId) {
+ const configuredCost = process.env.SYNAPSE_E2E_FIXED_COST_USDC?.trim();
+ if (!configuredCost) {
+ throw new Error("SYNAPSE_E2E_FIXED_COST_USDC is required when SYNAPSE_E2E_FIXED_SERVICE_ID is set");
+ }
+ return { serviceId: configuredServiceId, costUsdc: configuredCost, payload };
+ }
+
+ const services = await synapse.search("free", { limit: 25 });
+ const service = services.find(isFreeFixedApiService);
+ if (!service) {
+ throw new Error(
+ "no free fixed-price API service found; set SYNAPSE_E2E_FIXED_SERVICE_ID, " +
+ "SYNAPSE_E2E_FIXED_COST_USDC, and SYNAPSE_E2E_FIXED_PAYLOAD_JSON"
+ );
+ }
+ return {
+ serviceId: service.serviceId ?? service.id ?? "",
+ costUsdc: pricingAmount(service),
+ payload,
+ };
+}
+
+export function pricingAmount(service: ServiceRecord): string {
+ const pricing = service.pricing;
+ if (typeof pricing === "string") return pricing;
+ if (pricing && typeof pricing === "object" && "amount" in pricing) {
+ return String(pricing.amount ?? "");
+ }
+ return "";
+}
+
+export function isFreeFixedApiService(service: ServiceRecord): boolean {
+ return (
+ Boolean(service.serviceId ?? service.id) &&
+ String(service.serviceKind ?? "").toLowerCase() === "api" &&
+ String(service.priceModel ?? "").toLowerCase() === "fixed" &&
+ decimalEquals(pricingAmount(service), "0")
+ );
+}
+
+export async function awaitReceipt(synapse: SynapseClient, invocationId: string) {
+ if (!invocationId.trim()) throw new Error("invoke returned empty invocationId");
+ const deadline = Date.now() + envInt("SYNAPSE_E2E_RECEIPT_TIMEOUT_S", 60) * 1000;
+ while (true) {
+ const receipt = await synapse.getInvocation(invocationId);
+ if (receipt.invocationId && receipt.invocationId !== invocationId) {
+ throw new Error(`receipt invocationId mismatch: got ${receipt.invocationId} want ${invocationId}`);
+ }
+ if (receipt.status === "SUCCEEDED" || receipt.status === "SETTLED") return receipt;
+ if (Date.now() > deadline) {
+ throw new Error(`receipt ${invocationId} did not reach a terminal status, last status=${receipt.status}`);
+ }
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+ }
+}
+
+export async function localNegative(synapse: SynapseClient): Promise {
+ await expectError(
+ () =>
+ synapse.invoke(
+ "svc_local",
+ {},
+ {
+ costUsdc: "",
+ }
+ ),
+ Error
+ );
+ await expectError(() => synapse.invokeLlm("svc_llm", { stream: true }), InvokeError);
+ emit({ language: "typescript", scenario: "local-negative", status: "ok" });
+}
+
+export async function authNegative(): Promise {
+ await expectError(
+ () =>
+ client("agt_invalid").invoke(
+ "svc_invalid_auth_probe",
+ {},
+ {
+ costUsdc: "0",
+ }
+ ),
+ AuthenticationError
+ );
+ emit({ language: "typescript", scenario: "auth-negative", status: "ok" });
+}
+
+export async function expectError(
+ action: () => Promise,
+ expectedType: new (...args: any[]) => T
+): Promise {
+ try {
+ await action();
+ } catch (err) {
+ if (err instanceof expectedType) return;
+ throw err;
+ }
+ throw new Error(`expected ${expectedType.name}`);
+}
+
+export function emit(event: E2eEvent): void {
+ const compact = Object.fromEntries(Object.entries(event).filter(([, value]) => value !== undefined && value !== ""));
+ console.log(JSON.stringify(compact));
+}
+
+export function decimalEquals(left: string, right: string): boolean {
+ return decimalCents(left) === decimalCents(right);
+}
+
+export function decimalLessThanOrEqual(left: string, right: string): boolean {
+ return decimalCents(left) <= decimalCents(right);
+}
+
+function decimalCents(value: string): bigint {
+ const [whole, fraction = ""] = value.trim().split(".");
+ return BigInt(whole || "0") * 1_000_000n + BigInt((fraction + "000000").slice(0, 6));
+}
diff --git a/typescript/examples/e2e.ts b/typescript/examples/e2e.ts
new file mode 100644
index 0000000..02bd444
--- /dev/null
+++ b/typescript/examples/e2e.ts
@@ -0,0 +1,69 @@
+import {
+ authNegative,
+ awaitReceipt,
+ client,
+ decimalLessThanOrEqual,
+ DEFAULT_LLM_PAYLOAD,
+ emit,
+ envBool,
+ envDefault,
+ fixedTarget,
+ jsonPayload,
+ localNegative,
+} from "./_shared";
+
+async function main(): Promise {
+ const synapse = client();
+ await localNegative(synapse);
+ await synapse.checkGatewayHealth();
+ emit({ language: "typescript", scenario: "health", status: "ok" });
+
+ if (!envBool("SYNAPSE_E2E_SKIP_AUTH_NEGATIVE")) {
+ await authNegative();
+ }
+
+ const target = await fixedTarget(synapse);
+ const fixedResult = await synapse.invoke(target.serviceId, target.payload, {
+ costUsdc: target.costUsdc,
+ idempotencyKey: `typescript-e2e-fixed-${Date.now()}`,
+ });
+ const fixedReceipt = await awaitReceipt(synapse, fixedResult.invocationId);
+ emit({
+ language: "typescript",
+ scenario: "fixed-price",
+ serviceId: target.serviceId,
+ invocationId: fixedResult.invocationId,
+ status: fixedResult.status,
+ chargedUsdc: String(fixedReceipt.chargedUsdc),
+ receiptStatus: fixedReceipt.status,
+ });
+
+ if (envBool("SYNAPSE_E2E_FREE_ONLY")) return;
+
+ const llmServiceId = envDefault("SYNAPSE_E2E_LLM_SERVICE_ID", "svc_deepseek_chat");
+ const maxCostUsdc = envDefault("SYNAPSE_E2E_LLM_MAX_COST_USDC", "0.010000");
+ const llmResult = await synapse.invokeLlm(
+ llmServiceId,
+ jsonPayload("SYNAPSE_E2E_LLM_PAYLOAD_JSON", DEFAULT_LLM_PAYLOAD),
+ {
+ maxCostUsdc,
+ idempotencyKey: `typescript-e2e-llm-${Date.now()}`,
+ }
+ );
+ const llmReceipt = await awaitReceipt(synapse, llmResult.invocationId);
+ const chargedUsdc = String(llmReceipt.chargedUsdc || llmResult.chargedUsdc);
+ if (!decimalLessThanOrEqual(chargedUsdc, maxCostUsdc)) {
+ throw new Error(`chargedUsdc ${chargedUsdc} exceeds maxCostUsdc ${maxCostUsdc}`);
+ }
+ emit({
+ language: "typescript",
+ scenario: "llm",
+ serviceId: llmServiceId,
+ invocationId: llmResult.invocationId,
+ status: llmResult.status,
+ chargedUsdc,
+ receiptStatus: llmReceipt.status,
+ });
+}
+
+void main();
diff --git a/typescript/examples/free-service-smoke.ts b/typescript/examples/free-service-smoke.ts
new file mode 100644
index 0000000..fca6fec
--- /dev/null
+++ b/typescript/examples/free-service-smoke.ts
@@ -0,0 +1,22 @@
+import { awaitReceipt, client, emit, fixedTarget } from "./_shared";
+
+async function main(): Promise {
+ const synapse = client();
+ const target = await fixedTarget(synapse);
+ const result = await synapse.invoke(target.serviceId, target.payload, {
+ costUsdc: target.costUsdc,
+ idempotencyKey: `typescript-free-smoke-${Date.now()}`,
+ });
+ const receipt = await awaitReceipt(synapse, result.invocationId);
+ emit({
+ language: "typescript",
+ scenario: "free-service-smoke",
+ serviceId: target.serviceId,
+ invocationId: result.invocationId,
+ status: result.status,
+ chargedUsdc: String(receipt.chargedUsdc),
+ receiptStatus: receipt.status,
+ });
+}
+
+void main();
diff --git a/typescript/examples/llm-smoke.ts b/typescript/examples/llm-smoke.ts
new file mode 100644
index 0000000..e71dee5
--- /dev/null
+++ b/typescript/examples/llm-smoke.ts
@@ -0,0 +1,35 @@
+import {
+ awaitReceipt,
+ client,
+ decimalLessThanOrEqual,
+ DEFAULT_LLM_PAYLOAD,
+ emit,
+ envDefault,
+ jsonPayload,
+} from "./_shared";
+
+async function main(): Promise {
+ const synapse = client();
+ const serviceId = envDefault("SYNAPSE_E2E_LLM_SERVICE_ID", "svc_deepseek_chat");
+ const maxCostUsdc = envDefault("SYNAPSE_E2E_LLM_MAX_COST_USDC", "0.010000");
+ const result = await synapse.invokeLlm(serviceId, jsonPayload("SYNAPSE_E2E_LLM_PAYLOAD_JSON", DEFAULT_LLM_PAYLOAD), {
+ maxCostUsdc,
+ idempotencyKey: `typescript-llm-smoke-${Date.now()}`,
+ });
+ const receipt = await awaitReceipt(synapse, result.invocationId);
+ const chargedUsdc = String(receipt.chargedUsdc || result.chargedUsdc);
+ if (!decimalLessThanOrEqual(chargedUsdc, maxCostUsdc)) {
+ throw new Error(`chargedUsdc ${chargedUsdc} exceeds maxCostUsdc ${maxCostUsdc}`);
+ }
+ emit({
+ language: "typescript",
+ scenario: "llm-smoke",
+ serviceId,
+ invocationId: result.invocationId,
+ status: result.status,
+ chargedUsdc,
+ receiptStatus: receipt.status,
+ });
+}
+
+void main();
diff --git a/typescript/package-lock.json b/typescript/package-lock.json
index ce5a2e4..0fbdef2 100644
--- a/typescript/package-lock.json
+++ b/typescript/package-lock.json
@@ -22,6 +22,7 @@
"jscpd": "^4.0.9",
"prettier": "^3.8.3",
"ts-jest": "^29.1.4",
+ "tsx": "^4.20.6",
"typescript": "^5.4.2"
},
"peerDependencies": {
@@ -542,6 +543,448 @@
"node": ">=0.1.90"
}
},
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
+ "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
+ "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
+ "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
+ "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
+ "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
+ "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
+ "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
+ "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
+ "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
+ "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
+ "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
+ "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
+ "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
+ "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
+ "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
+ "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
+ "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
+ "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
+ "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
+ "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
+ "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
@@ -2613,6 +3056,48 @@
"node": ">= 0.4"
}
},
+ "node_modules/esbuild": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
+ "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.7",
+ "@esbuild/android-arm": "0.27.7",
+ "@esbuild/android-arm64": "0.27.7",
+ "@esbuild/android-x64": "0.27.7",
+ "@esbuild/darwin-arm64": "0.27.7",
+ "@esbuild/darwin-x64": "0.27.7",
+ "@esbuild/freebsd-arm64": "0.27.7",
+ "@esbuild/freebsd-x64": "0.27.7",
+ "@esbuild/linux-arm": "0.27.7",
+ "@esbuild/linux-arm64": "0.27.7",
+ "@esbuild/linux-ia32": "0.27.7",
+ "@esbuild/linux-loong64": "0.27.7",
+ "@esbuild/linux-mips64el": "0.27.7",
+ "@esbuild/linux-ppc64": "0.27.7",
+ "@esbuild/linux-riscv64": "0.27.7",
+ "@esbuild/linux-s390x": "0.27.7",
+ "@esbuild/linux-x64": "0.27.7",
+ "@esbuild/netbsd-arm64": "0.27.7",
+ "@esbuild/netbsd-x64": "0.27.7",
+ "@esbuild/openbsd-arm64": "0.27.7",
+ "@esbuild/openbsd-x64": "0.27.7",
+ "@esbuild/openharmony-arm64": "0.27.7",
+ "@esbuild/sunos-x64": "0.27.7",
+ "@esbuild/win32-arm64": "0.27.7",
+ "@esbuild/win32-ia32": "0.27.7",
+ "@esbuild/win32-x64": "0.27.7"
+ }
+ },
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -3300,6 +3785,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/get-tsconfig": {
+ "version": "4.14.0",
+ "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
+ "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "resolve-pkg-maps": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
+ }
+ },
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
@@ -5316,6 +5814,16 @@
"node": ">=8"
}
},
+ "node_modules/resolve-pkg-maps": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
+ "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
+ }
+ },
"node_modules/resolve.exports": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz",
@@ -5756,6 +6264,26 @@
"dev": true,
"license": "0BSD"
},
+ "node_modules/tsx": {
+ "version": "4.21.0",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
+ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "~0.27.0",
+ "get-tsconfig": "^4.7.5"
+ },
+ "bin": {
+ "tsx": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ }
+ },
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
diff --git a/typescript/package.json b/typescript/package.json
index b442314..da50b2d 100644
--- a/typescript/package.json
+++ b/typescript/package.json
@@ -12,10 +12,14 @@
"test:e2e": "jest --testPathPattern='tests/e2e/consumer' --runInBand --forceExit",
"test:new-consumer": "jest --testPathPattern='tests/e2e/new-consumer' --runInBand --forceExit --verbose",
"test:provider": "jest --testPathPattern='tests/e2e/provider' --runInBand --forceExit --verbose",
- "format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\"",
+ "format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\" \"examples/**/*.ts\"",
"lint:eslint": "eslint \"src/**/*.ts\"",
"typecheck": "tsc --noEmit",
+ "typecheck:examples": "tsc -p tsconfig.examples.json",
"lint": "npm run format:check && npm run lint:eslint && npm run typecheck",
+ "example:free": "tsx examples/free-service-smoke.ts",
+ "example:llm": "tsx examples/llm-smoke.ts",
+ "example:e2e": "tsx examples/e2e.ts",
"duplication": "jscpd"
},
"dependencies": {
@@ -36,6 +40,7 @@
"jscpd": "^4.0.9",
"prettier": "^3.8.3",
"ts-jest": "^29.1.4",
+ "tsx": "^4.20.6",
"typescript": "^5.4.2"
}
}
diff --git a/typescript/src/client.ts b/typescript/src/client.ts
index 60b96cd..dca0914 100644
--- a/typescript/src/client.ts
+++ b/typescript/src/client.ts
@@ -111,6 +111,12 @@ export class SynapseClient {
payload: Record = {},
opts: InvokeOptions
): Promise {
+ if (!serviceId.trim()) {
+ throw new Error("serviceId is required");
+ }
+ if (opts.costUsdc === undefined || String(opts.costUsdc).trim() === "") {
+ throw new Error("costUsdc is required for fixed-price API services. Use invokeLlm() for LLM services.");
+ }
try {
const resp = await this._fetch>(
`${this.gatewayUrl}/api/v1/agent/invoke`,
diff --git a/typescript/tests/unit/client.test.ts b/typescript/tests/unit/client.test.ts
index b394f78..6b7d7a8 100644
--- a/typescript/tests/unit/client.test.ts
+++ b/typescript/tests/unit/client.test.ts
@@ -5,6 +5,8 @@
* via jest.spyOn / globalThis.fetch mock.
*/
+import fs from "fs";
+import path from "path";
import { SynapseClient } from "../../src/client";
import { SynapseAuth } from "../../src/auth";
import { resolveGatewayUrl } from "../../src/config";
@@ -33,6 +35,10 @@ function makeFetchMock(responses: Array<{ status: number; body: unknown }>): jes
});
}
+function contractFixture(name: string): unknown {
+ return JSON.parse(fs.readFileSync(path.resolve(__dirname, "../../../contracts/sdk/fixtures", name), "utf8"));
+}
+
beforeEach(() => {
jest.restoreAllMocks();
});
@@ -638,3 +644,57 @@ test("invokeWithRediscovery fails clearly when rediscovery cannot provide a pric
await expect(client.invokeWithRediscovery("svc_1", {}, { costUsdc: 0.05 })).rejects.toThrow(PriceMismatchError);
expect(invokeCount).toBe(1);
});
+
+test("contract fixtures cover TypeScript search, llm invoke, receipt, and price mismatch", async () => {
+ const calls: Array<{ url: string; body?: Record }> = [];
+ (globalThis as unknown as Record).fetch = jest.fn(async (url: string, init?: RequestInit) => {
+ const body = init?.body ? (JSON.parse(init.body as string) as Record) : undefined;
+ calls.push({ url, body });
+ if (url.endsWith("/api/v1/agent/discovery/search")) {
+ return {
+ ok: true,
+ status: 200,
+ text: async () => JSON.stringify(contractFixture("discovery_search_response.json")),
+ } as Response;
+ }
+ if (url.endsWith("/api/v1/agent/invoke") && !body?.costUsdc) {
+ return {
+ ok: true,
+ status: 200,
+ text: async () => JSON.stringify(contractFixture("llm_invoke_response.json")),
+ } as Response;
+ }
+ if (url.endsWith("/api/v1/agent/invocations/inv_contract_fixed")) {
+ return {
+ ok: true,
+ status: 200,
+ text: async () => JSON.stringify(contractFixture("receipt_response.json")),
+ } as Response;
+ }
+ return {
+ ok: false,
+ status: 422,
+ text: async () => JSON.stringify(contractFixture("error_price_mismatch.json")),
+ } as Response;
+ });
+
+ const client = new SynapseClient({ credential: "agt_test", gatewayUrl: "https://gateway.example" });
+ const services = await client.search("fixture", { limit: 10 });
+ expect(services[0].serviceId).toBe("svc_contract_weather");
+ expect((services[0].pricing as { amount?: string }).amount).toBe("0.010000");
+
+ const llm = await client.invokeLlm(
+ "svc_deepseek_chat",
+ { messages: [{ role: "user", content: "hello" }] },
+ { maxCostUsdc: "0.010000", idempotencyKey: "idem-llm" }
+ );
+ expect(llm.invocationId).toBe("inv_contract_llm");
+ expect(llm.synapse?.priceModel).toBe("token_metered");
+ expect(calls[1].body?.costUsdc).toBeUndefined();
+ expect(calls[1].body?.maxCostUsdc).toBe("0.010000");
+
+ const receipt = await client.getInvocation("inv_contract_fixed");
+ expect(receipt.status).toBe("SETTLED");
+
+ await expect(client.invoke("svc_contract_weather", {}, { costUsdc: "0.010000" })).rejects.toThrow(PriceMismatchError);
+});
diff --git a/typescript/tsconfig.examples.json b/typescript/tsconfig.examples.json
new file mode 100644
index 0000000..72a9097
--- /dev/null
+++ b/typescript/tsconfig.examples.json
@@ -0,0 +1,9 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "noEmit": true,
+ "rootDir": "."
+ },
+ "include": ["src/**/*", "examples/**/*.ts"],
+ "exclude": ["dist", "node_modules", "tests"]
+}