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 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"] +}