diff --git a/.env.example b/.env.example index d187c91..fe69f21 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,3 @@ SYNAPSE_ENV=staging SYNAPSE_GATEWAY= -SYNAPSE_API_KEY=agt_xxx +SYNAPSE_AGENT_KEY=agt_xxx diff --git a/README.md b/README.md index 989b9f2..0c1b462 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,24 @@ SynapseNetwork lets an agent discover services, invoke them through a gateway, a 3. Search, invoke, and read the receipt. > Public Preview default: SDK examples use `staging`, backed by `https://api-staging.synapse-network.ai`. -> The `prod` preset points to `https://api.synapse-network.ai`, but production should only be used after official DNS and `/health` are live. +> After production launch, replace the public examples and tests with the `prod` environment. + +## Choose Your Integration Path + +| Goal | Use | +|---|---| +| Connect SynapseNetwork to an agent framework such as Cursor, Claude Desktop, or LangChain | Official MCP server: `@synapse-network/mcp-server` with `SYNAPSE_AGENT_KEY=agt_xxx` | +| Write application code that invokes services directly | This SDK with `SynapseClient` | +| Issue Agent Keys or publish provider APIs | Advanced owner/provider APIs: `SynapseAuth` and `auth.provider()` | + +## Two Invocation Modes + +| Mode | Use for | SDK method | Required cost parameter | Billing result | +|---|---|---|---|---| +| Fixed-price API invoke | Normal API services discovered from the marketplace | Python `invoke()` / TypeScript `invoke()` | Pass latest discovery price as `cost_usdc` / `costUsdc` | Gateway rejects with `PRICE_MISMATCH` if the live price changed | +| Token-metered LLM invoke | LLM services registered with `serviceKind=llm` and `priceModel=token_metered` | Python `invoke_llm()` / TypeScript `invokeLlm()` | Do not pass `cost_usdc` / `costUsdc`; optional cap is `max_cost_usdc` / `maxCostUsdc` | Gateway holds a cap, then charges final provider-reported token usage | + +Do not recompute money with floating-point math. Pass discovered prices and spend caps through exactly; prefer string amounts such as `"0.05"` when the SDK method accepts strings. ## Gateway Docs @@ -40,8 +57,6 @@ SynapseNetwork lets an agent discover services, invoke them through a gateway, a | Environment | Gateway URL | Intended use | |---|---|---| | `staging` | `https://api-staging.synapse-network.ai` | Public preview, test assets, integration trials | -| `local` | `http://127.0.0.1:8000` | Local gateway development | -| `prod` | `https://api.synapse-network.ai` | Production preset, pending official DNS and health verification | Resolution rules: @@ -74,7 +89,7 @@ Step 2: let your agent discover and work. ```bash pip install synapse-client export SYNAPSE_ENV=staging -export SYNAPSE_API_KEY=agt_xxx +export SYNAPSE_AGENT_KEY=agt_xxx ``` ```python @@ -88,7 +103,7 @@ service = services[0] result = client.invoke( service.service_id, {"prompt": "hello"}, - cost_usdc=float(service.price_usdc), + cost_usdc=str(service.price_usdc), idempotency_key="agent-job-001", ) @@ -113,8 +128,13 @@ npm install @synapse-network/sdk ```ts import { SynapseClient } from "@synapse-network/sdk"; +const agentKey = process.env.SYNAPSE_AGENT_KEY; +if (!agentKey) { + throw new Error("SYNAPSE_AGENT_KEY is required"); +} + const client = new SynapseClient({ - credential: "agt_xxx", + credential: agentKey, environment: "staging", }); @@ -127,7 +147,7 @@ const result = await client.invoke( service.serviceId ?? service.id!, { prompt: "hello" }, { - costUsdc: Number(service.pricing?.amount ?? 0), + costUsdc: String(service.pricing?.amount ?? "0"), idempotencyKey: "agent-job-001", } ); @@ -138,6 +158,8 @@ console.log(receipt.invocationId, receipt.status, receipt.chargedUsdc); TypeScript does not read environment variables by itself. Read them in your app and pass `environment` or `gatewayUrl` explicitly. +Python reads `SYNAPSE_AGENT_KEY` by default. `SYNAPSE_API_KEY` remains a legacy compatibility alias, but new examples should use `SYNAPSE_AGENT_KEY`. + ### LLM token-metered calls LLM services registered with `serviceKind=llm` and `priceModel=token_metered` use `invoke_llm()` / `invokeLlm()`. Do not pass `cost_usdc` / `costUsdc`; pass optional `max_cost_usdc` / `maxCostUsdc` or let Gateway compute the automatic hold. Streaming is rejected in V1 so Gateway can capture final usage safely. @@ -267,7 +289,7 @@ PYTHONPATH="$PWD" .venv/bin/python examples/provider_staging_onboarding.py \ Call a provider service with an existing Agent Key: ```bash -export SYNAPSE_API_KEY=agt_xxx +export SYNAPSE_AGENT_KEY=agt_xxx PYTHONPATH="$PWD" .venv/bin/python examples/consumer_call_provider.py \ --service-id "weather_api" \ --payload-json '{"prompt":"hello"}' diff --git a/README.zh-CN.md b/README.zh-CN.md index 536bd5a..6d59ff5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -24,7 +24,24 @@ SynapseNetwork 让 Agent 可以发现服务、通过 gateway 调用服务,并 3. 搜索服务、调用服务、读取 receipt。 > Public Preview 默认使用 `staging`,对应 `https://api-staging.synapse-network.ai`。 -> `prod` 预设指向 `https://api.synapse-network.ai`,但只有在官方 DNS 和 `/health` 验证通过后才应使用生产环境。 +> 生产环境上线后,再把公开示例和测试统一切换到 `prod`。 + +## 选择你的接入路径 + +| 目标 | 使用 | +|---|---| +| 把 SynapseNetwork 接入 Cursor、Claude Desktop、LangChain 等 Agent framework | 官方 MCP server:`@synapse-network/mcp-server`,设置 `SYNAPSE_AGENT_KEY=agt_xxx` | +| 在应用代码里直接调用服务 | 本 SDK 的 `SynapseClient` | +| 签发 Agent Key 或发布 Provider API | 高级 owner/provider API:`SynapseAuth` 和 `auth.provider()` | + +## 两种调用模式 + +| 模式 | 适用场景 | SDK 方法 | 必传费用参数 | 计费结果 | +|---|---|---|---|---| +| Fixed-price API invoke | marketplace 发现的普通 API 服务 | Python `invoke()` / TypeScript `invoke()` | 传最新 discovery price:`cost_usdc` / `costUsdc` | 如果 live price 已变化,Gateway 用 `PRICE_MISMATCH` 拒绝 | +| Token-metered LLM invoke | `serviceKind=llm` 且 `priceModel=token_metered` 的 LLM 服务 | Python `invoke_llm()` / TypeScript `invokeLlm()` | 不传 `cost_usdc` / `costUsdc`;可选上限是 `max_cost_usdc` / `maxCostUsdc` | Gateway 先冻结上限,再按 Provider 返回的 final token usage 扣费 | + +不要用浮点数重新计算金额。调用时传 discovery 得到的价格或预算上限;SDK 方法支持时优先使用字符串金额,例如 `"0.05"`。 ## Gateway 文档 @@ -40,8 +57,6 @@ SynapseNetwork 让 Agent 可以发现服务、通过 gateway 调用服务,并 | 环境 | Gateway URL | 用途 | |---|---|---| | `staging` | `https://api-staging.synapse-network.ai` | Public preview、测试资产和接入试跑 | -| `local` | `http://127.0.0.1:8000` | 本地 gateway 开发 | -| `prod` | `https://api.synapse-network.ai` | 生产预设,等待官方 DNS 和 health 验证 | 解析优先级: @@ -74,7 +89,7 @@ Provider 是 owner scope 下的供给侧角色,不是第二套根账户体系 ```bash pip install synapse-client export SYNAPSE_ENV=staging -export SYNAPSE_API_KEY=agt_xxx +export SYNAPSE_AGENT_KEY=agt_xxx ``` ```python @@ -88,7 +103,7 @@ service = services[0] result = client.invoke( service.service_id, {"prompt": "hello"}, - cost_usdc=float(service.price_usdc), + cost_usdc=str(service.price_usdc), idempotency_key="agent-job-001", ) @@ -113,8 +128,13 @@ npm install @synapse-network/sdk ```ts import { SynapseClient } from "@synapse-network/sdk"; +const agentKey = process.env.SYNAPSE_AGENT_KEY; +if (!agentKey) { + throw new Error("SYNAPSE_AGENT_KEY is required"); +} + const client = new SynapseClient({ - credential: "agt_xxx", + credential: agentKey, environment: "staging", }); @@ -127,7 +147,7 @@ const result = await client.invoke( service.serviceId ?? service.id!, { prompt: "hello" }, { - costUsdc: Number(service.pricing?.amount ?? 0), + costUsdc: String(service.pricing?.amount ?? "0"), idempotencyKey: "agent-job-001", } ); @@ -138,6 +158,8 @@ console.log(receipt.invocationId, receipt.status, receipt.chargedUsdc); TypeScript SDK 不会自动读取环境变量。请在你的应用中读取环境变量,然后显式传入 `environment` 或 `gatewayUrl`。 +Python 默认读取 `SYNAPSE_AGENT_KEY`。`SYNAPSE_API_KEY` 只作为 legacy 兼容别名保留,新示例统一使用 `SYNAPSE_AGENT_KEY`。 + ### LLM 按 token 计费调用 使用 `serviceKind=llm` 和 `priceModel=token_metered` 注册的 LLM 服务,需要调用 `invoke_llm()` / `invokeLlm()`。不要传 `cost_usdc` / `costUsdc`;可以传可选的 `max_cost_usdc` / `maxCostUsdc`,也可以交给 Gateway 自动冻结。V1 会拒绝 streaming,确保 Gateway 能拿到 final usage 后再扣费。 @@ -267,7 +289,7 @@ PYTHONPATH="$PWD" .venv/bin/python examples/provider_staging_onboarding.py \ 使用已有 Agent Key 调用 provider service: ```bash -export SYNAPSE_API_KEY=agt_xxx +export SYNAPSE_AGENT_KEY=agt_xxx PYTHONPATH="$PWD" .venv/bin/python examples/consumer_call_provider.py \ --service-id "weather_api" \ --payload-json '{"prompt":"hello"}' diff --git a/docs/agent-map/README.md b/docs/agent-map/README.md index c218296..b499e19 100644 --- a/docs/agent-map/README.md +++ b/docs/agent-map/README.md @@ -64,7 +64,7 @@ Domain 摘要。 | `sdk_runtime_client` | discovery, invoke, receipt, usage, runtime errors | | `sdk_owner_auth` | wallet auth, credential issue/list/status/quota, owner control plane | | `sdk_provider_lifecycle` | provider facade, provider secrets, service registration, service lifecycle, provider health | -| `sdk_environment_config` | staging/prod/local presets, gateway URL resolution, public preview defaults | +| `sdk_environment_config` | staging defaults, future prod switch, gateway URL resolution, public preview defaults | | `sdk_public_docs` | README, integration guides, capability inventory, examples | | `sdk_ci_quality_gates` | GitHub Actions, shell CI scripts, coverage gates | | `sdk_examples_and_e2e` | examples, smoke tests, onboarding e2e plans | diff --git a/docs/agent-map/index.json b/docs/agent-map/index.json index 7c368c4..3522d8a 100644 --- a/docs/agent-map/index.json +++ b/docs/agent-map/index.json @@ -42,7 +42,8 @@ "bash scripts/ci/typescript_checks.sh" ], "notes": [ - "Canonical runtime flow is discovery/search plus price-asserted invoke plus receipt.", + "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.", "Do not restore old quote-first gateway calls." ] }, @@ -108,7 +109,7 @@ "id": "sdk_environment_config", "title": "Gateway environment configuration", "use_when": [ - "local, staging, prod, gateway URL, environment resolver, or public preview defaults change", + "staging, future prod, gateway URL, environment resolver, or public preview defaults change", "README or SECURITY guidance for staging/prod changes" ], "primary_files": [ @@ -132,6 +133,7 @@ ], "notes": [ "Default environment is staging.", + "Do not reintroduce removed non-staging gateway presets or gateway setup docs.", "Do not reintroduce the retired gateway domain from the old .network namespace.", "Explicit gateway URL always wins over environment preset." ] @@ -167,7 +169,8 @@ "notes": [ "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 Agent Key -> SynapseClient first, with owner auth -> JWT -> credential issuance under Advanced." + "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." ] }, { @@ -226,7 +229,7 @@ "bash scripts/ci/typescript_checks.sh" ], "notes": [ - "Provider staging examples require a public HTTPS endpoint, not localhost.", + "Provider staging examples require a public HTTPS endpoint reachable by the staging gateway.", "Consumer wallet-to-invoke examples default to free services unless --allow-paid is explicit.", "E2E flows may require real staging credentials; PR CI should keep e2e out of the default gate unless explicitly configured." ] diff --git a/docs/bugfix/typeScript/bugs.md b/docs/bugfix/typeScript/bugs.md index e2f5c63..1409367 100644 --- a/docs/bugfix/typeScript/bugs.md +++ b/docs/bugfix/typeScript/bugs.md @@ -1,158 +1,11 @@ # TypeScript SDK Bug Log -## BUG-SDK-001 — PROVIDER_KEY invalid length (63 hex chars, ethers v6 rejects) +Historical TypeScript E2E bugs from the retired non-staging test harness have been archived out of the public SDK guidance. -**Status:** FIXED -**Severity:** error (blocks beforeAll setup) -**File:** `sdk/typescript/tests/e2e/consumer.test.ts` +Current SDK validation uses: -### Symptom -``` -TypeError: invalid BytesLike value (argument="value", value="0x5de4...0ecf", -code=INVALID_ARGUMENT, version=6.16.0) -``` +1. Unit tests in the default PR gate. +2. Staging-gated consumer E2E tests when `RUN_STAGING_E2E=1`. +3. Staging-gated provider E2E tests when `RUN_STAGING_PROVIDER_E2E=1`. -### Root Cause -`PROVIDER_KEY = "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ea870594801966b8ea0ecf"` is 63 hex chars (odd length, not 32 bytes). - -Python `eth_account` silently zero-pads the key; ethers v6 is strict and rejects it. - -### Fix -Use the correct 64-char Hardhat #2 private key: -``` -0x5de4111afa1a4b94908f83103eb1f1706367c2e68ea870594801966b8ea0ec4f -``` -(The `4` before the final `f` was missing in the tests.) - ---- - -## BUG-SDK-002 — Deposit nonce conflict (NONCE_EXPIRED) on re-runs - -**Status:** FIXED -**Severity:** error (blocks beforeAll setup) -**File:** `sdk/typescript/tests/e2e/consumer.test.ts` - -### Symptom -``` -NONCE_EXPIRED: nonce has already been used -code=-32003, message="nonce too low" -``` - -### Root Cause -If Python E2E tests ran first, the owner account's on-chain nonce advanced. -Ethers v6 uses a caching nonce manager and the previous run may have left -the nonce tracking dirty. Submitting a fresh deposit conflicts with already-used nonces. - -### Fix -Before attempting on-chain deposit, check the current gateway balance. -If `consumerAvailableBalance >= DEPOSIT_USDC / 2` (i.e., enough balance already -credited from a prior run), skip the blockchain deposit entirely. -This makes the test idempotent and re-run safe. - ---- - -## BUG-SDK-004 — Deployer nonce conflict between ETH transfer and mint (Hardhat) - -**Status:** FIXED -**Severity:** error (blocks beforeAll setup) -**File:** `sdk/typescript/tests/e2e/new-consumer.test.ts` - -### Symptom -``` -NONCE_EXPIRED: nonce has already been used -code=-32003, message="nonce too low" -``` -Transaction is the `mint()` call to MockUSDC (nonce 0x3d = 61). - -### Root Cause -In ethers v6, when `deployerWallet.sendTransaction({value: 0.5 ETH})` is sent -and mined (via `await ethTx.wait()`), the subsequent `usdc.mint()` call queries -`eth_getTransactionCount(deployer, "latest")` internally. Hardhat's auto-mine -mode can expose a brief window where the transaction has been mined but the -`eth_getTransactionCount` response hasn't propagated, causing the next call to -receive the stale nonce (N) instead of N+1. Both the ETH transfer and mint end -up broadcast with the same nonce, causing the second to fail. - -### Fix -Fetch the deployer's pending nonce once before the first transaction, then -pass explicit `{ nonce: deployerNonce++ }` overrides to every deployer tx: -```typescript -let deployerNonce = await rpcProvider.getTransactionCount(deployerWallet.address, "pending"); -await deployerWallet.sendTransaction({..., nonce: deployerNonce++}); -await (usdc as Contract).mint(fresh, amount, { nonce: deployerNonce++ }); -``` - - - -**Status:** FIXED -**Severity:** assertion error (1 test fails) -**File:** `sdk/typescript/tests/e2e/consumer.test.ts` - -### Symptom -``` -expect(received).toBeLessThan(expected) -Expected: < 10 (DEPOSIT_USDC constant) -Received: 59.9934 -``` - -### Root Cause -Test asserts `balance < DEPOSIT_USDC (10)` but the existing gateway balance was ~60 USDC -from previous Python E2E runs. The deposit was correctly skipped (BUG-002 fix), -so the starting balance was 60, not 10. - -### Fix -Capture `initialBalance` before invocations run and assert -`finalBalance < initialBalance` instead of `finalBalance < DEPOSIT_USDC`. - ---- - -## BUG-SDK-004 — Deployer nonce conflict between ETH transfer and mint (new-consumer test) - -**Status:** FIXED -**Severity:** error (NONCE_EXPIRED, blocks beforeAll) -**File:** `sdk/typescript/tests/e2e/new-consumer.test.ts` - -### Symptom -``` -NONCE_EXPIRED: nonce has already been used, code=-32003, message="nonce too low" -``` -Transaction is the `mint()` call with nonce 0x3d = 61. - -### Root Cause -After `deployerWallet.sendTransaction({ETH})` and `await ethTx.wait()`, the subsequent -`usdc.mint()` call queries `eth_getTransactionCount(deployer, "latest")` internally. -Hardhat auto-mine can expose a brief stale window where that query still returns N -instead of N+1, causing both transactions to use the same nonce. - -### Fix -Fetch deployer's pending nonce ONCE before first tx, then pass explicit -`{ nonce: deployerNonce++ }` to every deployer transaction: -```typescript -let deployerNonce = await rpcProvider.getTransactionCount(deployerWallet.address, "pending"); -await deployerWallet.sendTransaction({..., nonce: deployerNonce++}); -await (usdc as Contract).mint(fresh, amount, { nonce: deployerNonce++ }); -``` - ---- - -## BUG-SDK-005 — confirmDeposit wrong endpoint URL and body - -**Status:** FIXED -**Severity:** error (HTTP 404 — blocks deposit confirmation) -**File:** `sdk/typescript/src/auth.ts` - -### Symptom -``` -HTTP 404: Not Found -``` -Thrown from `SynapseAuth.confirmDeposit()` during beforeAll setup. - -### Root Cause -`confirmDeposit` was calling `POST /api/v1/balance/deposit/confirm` with body -`{ intentId, eventKey }`. The actual gateway endpoint is: -`POST /api/v1/balance/deposit/intents/{intentId}/confirm` with body `{ eventKey, confirmations: 1 }`. - -### Fix -Updated `confirmDeposit` in `auth.ts`: -- URL: `/api/v1/balance/deposit/intents/${intentId}/confirm` -- Body: `{ eventKey, confirmations: 1 }` +Do not reintroduce non-staging gateway setup instructions into public SDK docs or tests. diff --git a/docs/ops/SDK_Python_Local_Development.md b/docs/ops/SDK_Python_Local_Development.md index 82fc972..a3d9d45 100644 --- a/docs/ops/SDK_Python_Local_Development.md +++ b/docs/ops/SDK_Python_Local_Development.md @@ -1,6 +1,6 @@ -# SDK Python Local Development +# SDK Python Staging Development -`sdk/python/` 是 Synapse 的官方 Python SDK 分发与联调目录。 +`python/` 是 SynapseNetwork 的官方 Python SDK 分发与联调目录。当前所有公开验证都指向 staging;生产环境上线后再统一切换到 prod。 ## 安装 @@ -10,66 +10,44 @@ pip install synapse-client ``` -本地开发安装: +开发安装: ```bash -cd /home/alex/Documents/cliff/Synapse-Network-Sdk/python +cd /Users/cliff/workspace/agent/Synapse-Network-Sdk/python python3.11 -m venv .venv source .venv/bin/activate python -m pip install -e ".[dev]" ``` -## 常用验证 - -### Live smoke test +## Staging Smoke Test ```bash -cd /home/alex/Documents/cliff/Synapse-Network-Sdk/python -export SYNAPSE_API_KEY="agt_xxxxx..." -PYTHONPATH="$PWD" .venv/bin/python examples/smoke_test.py --query "名人名言" +cd /Users/cliff/workspace/agent/Synapse-Network-Sdk/python +export SYNAPSE_ENV=staging +export SYNAPSE_AGENT_KEY="agt_xxxxx..." +PYTHONPATH="$PWD" .venv/bin/python examples/smoke_test.py --query "quotes" ``` 如需输出可复现 curl: ```bash -cd /home/alex/Documents/cliff/Synapse-Network-Sdk/python -export SYNAPSE_API_KEY="agt_xxxxx..." -PYTHONPATH="$PWD" .venv/bin/python examples/smoke_test.py --query "名人名言" --print-curl +PYTHONPATH="$PWD" .venv/bin/python examples/smoke_test.py --query "quotes" --print-curl ``` 如已知 `service_id`: ```bash -cd /home/alex/Documents/cliff/Synapse-Network-Sdk/python -export SYNAPSE_API_KEY="agt_xxxxx..." PYTHONPATH="$PWD" .venv/bin/python examples/smoke_test.py \ --service-id svc_quotes_famous_top3 \ - --cost-usdc 0.001 -``` - -## 端到端本地联调 - -```bash -bash /home/alex/Documents/cliff/Synapse-Network/scripts/local/restart_gateway.sh - -cd /home/alex/Documents/cliff/Synapse-Network-Sdk/python -export SYNAPSE_API_KEY="agt_xxxxx..." -export SYNAPSE_GATEWAY="http://127.0.0.1:8000" -PYTHONPATH="$PWD" .venv/bin/python examples/smoke_test.py \ - --query "名人名言" \ - --text "想要放弃的时候,请给我一句关于坚持的名人名言" + --cost-usdc "0.001" ``` -成功时脚本会打印 `Invocation succeeded`、`request_id`、`idempotency_key` 和最终 `invocationId`。 - ## 环境变量 -1. `SYNAPSE_API_KEY`: Agent credential。 -2. `SYNAPSE_GATEWAY`: 默认 gateway 地址。 +1. `SYNAPSE_ENV=staging` +2. `SYNAPSE_AGENT_KEY`: Agent credential。`SYNAPSE_API_KEY` 仅作为 legacy Python fallback。 ## 相关文档 -1. [README.md](./README.md) -2. [../../sdk/README.md](../../sdk/README.md) -3. [../../06_Reference/04_Development_Testing_and_Integration_Reference.md](../../06_Reference/04_Development_Testing_and_Integration_Reference.md) -4. [../../06_Reference/01_API_Contract_Index.md](../../06_Reference/01_API_Contract_Index.md) +1. [README.md](../../README.md) +2. [SDK Docs](../sdk/README.md) diff --git a/docs/sdk/README.md b/docs/sdk/README.md index 115bb53..294a513 100644 --- a/docs/sdk/README.md +++ b/docs/sdk/README.md @@ -15,7 +15,7 @@ This directory is the SDK-side source of truth for capabilities, integration gui 5. [TypeScript Provider Integration Guide](./typescript_provider_integration.md) 6. [Python Integration Guide](./python_integration.md) 7. [Python Provider Integration Guide](./python_provider_integration.md) -8. [Python Local Development](../ops/SDK_Python_Local_Development.md) +8. [Python Staging Development](../ops/SDK_Python_Local_Development.md) 9. [TypeScript Consumer E2E Plan](../test/consumer-e2e-plan.md) 10. [TypeScript Provider Onboarding E2E Plan](../test/typescript-provider-onboarding-e2e-plan.md) 11. [Python Consumer Cold-Start E2E Plan](../test/python-consumer-cold-start-e2e-plan.md) @@ -37,6 +37,13 @@ Python quote-first methods `create_quote()`, `create_invocation()`, and `invoke_ LLM services use `serviceKind=llm` + `priceModel=token_metered`. Runtime code should call `invoke_llm()` / `invokeLlm()` and read `usage` plus `synapse` billing metadata. Do not pass `cost_usdc` / `costUsdc` for LLM calls; pass optional `max_cost_usdc` / `maxCostUsdc` or let Gateway compute the automatic hold. Streaming is disabled in V1. +Consumer docs should present two invocation modes: + +| Mode | SDK method | Cost input | +|---|---|---| +| 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` | + ## Staging Docs The productized Gateway runbook lives in staging docs: @@ -76,13 +83,13 @@ Provider publishing is a separate owner-authenticated flow: Default environment is public preview/staging: -- `local`: `http://127.0.0.1:8000` - `staging`: `https://api-staging.synapse-network.ai` -- `prod`: `https://api.synapse-network.ai`, only for real funds after official production DNS and `/health` verification. + +Production launch will switch public examples and tests from `staging` to `prod`. Python: -- `api_key`: explicit parameter first, then `SYNAPSE_API_KEY`. +- `api_key`: explicit parameter first, then `SYNAPSE_AGENT_KEY`, then legacy `SYNAPSE_API_KEY`. - `gateway_url`: explicit parameter first, then `SYNAPSE_GATEWAY`. - `environment`: explicit parameter first, then `SYNAPSE_ENV`, then `staging`. - `AgentWallet.connect()` no longer uses demo credential fallback; missing real credentials fail. @@ -106,10 +113,10 @@ Runtime calls should include: ### `api_key is required` -No `api_key` was passed and `SYNAPSE_API_KEY` is not set. New users should issue an agent credential first, then pass the returned token to `SynapseClient`. +No `api_key` was passed and `SYNAPSE_AGENT_KEY` is not set. New users should issue an agent credential first, then pass the returned token to `SynapseClient`. `SYNAPSE_API_KEY` remains a legacy fallback for older Python users, but new docs and examples should use `SYNAPSE_AGENT_KEY`. ```bash -export SYNAPSE_API_KEY='agt_xxx_your_real_key' +export SYNAPSE_AGENT_KEY='agt_xxx_your_real_key' ``` ### Discovery returns 0 results @@ -132,7 +139,7 @@ The SDK maps `402` to balance, budget, or credential credit limit errors. Check ```bash cd /Users/cliff/workspace/agent/Synapse-Network-Sdk/python PYTHONPATH="$PWD" .venv/bin/python -m pytest synapse_client/test/test_client_unit.py -q -export SYNAPSE_API_KEY='agt_xxx_your_real_key' +export SYNAPSE_AGENT_KEY='agt_xxx_your_real_key' PYTHONPATH="$PWD" .venv/bin/python examples/smoke_test.py --query 'quotes' ``` diff --git a/docs/sdk/README.zh-CN.md b/docs/sdk/README.zh-CN.md index 13bf6f6..d0d9874 100644 --- a/docs/sdk/README.zh-CN.md +++ b/docs/sdk/README.zh-CN.md @@ -15,7 +15,7 @@ 5. [TypeScript Provider Integration Guide](./typescript_provider_integration.md) 6. [Python Integration Guide](./python_integration.md) 7. [Python Provider Integration Guide](./python_provider_integration.md) -8. [Python Local Development](../ops/SDK_Python_Local_Development.md) +8. [Python Staging Development](../ops/SDK_Python_Local_Development.md) 9. [TypeScript Consumer E2E Plan](../test/consumer-e2e-plan.md) 10. [TypeScript Provider Onboarding E2E Plan](../test/typescript-provider-onboarding-e2e-plan.md) 11. [Python Consumer Cold-Start E2E Plan](../test/python-consumer-cold-start-e2e-plan.md) @@ -37,6 +37,13 @@ Python 旧的 quote-first 方法 `create_quote()`、`create_invocation()`、`inv LLM 服务使用 `serviceKind=llm` + `priceModel=token_metered`。Runtime 代码应调用 `invoke_llm()` / `invokeLlm()`,并读取返回里的 `usage` 与 `synapse` 计费元数据。LLM 调用不要传 `cost_usdc` / `costUsdc`;可以传可选的 `max_cost_usdc` / `maxCostUsdc`,也可以交给 Gateway 自动冻结。V1 禁用 streaming。 +Consumer 文档应统一呈现两种调用模式: + +| 模式 | SDK 方法 | 费用输入 | +|---|---|---| +| 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` | + ## Staging 产品文档 Gateway 的产品化 runbook 以 staging docs 为准: @@ -76,13 +83,13 @@ Provider publishing 是另一条 owner-authenticated flow: 默认环境是 public preview/staging: -- `local`: `http://127.0.0.1:8000`,用于本地 gateway 开发。 - `staging`: `https://api-staging.synapse-network.ai`,用于 public preview、测试资产和接入试跑。 -- `prod`: `https://api.synapse-network.ai`,需等官方 production DNS 和 `/health` 验证后再用于真实资金流。 + +生产环境上线后,再把公开示例和测试从 `staging` 统一切到 `prod`。 Python: -- `api_key`: 显式参数优先,其次 `SYNAPSE_API_KEY`。 +- `api_key`: 显式参数优先,其次 `SYNAPSE_AGENT_KEY`,最后是 legacy `SYNAPSE_API_KEY`。 - `gateway_url`: 显式参数优先,其次 `SYNAPSE_GATEWAY`。 - `environment`: 显式参数优先,其次 `SYNAPSE_ENV`,最后 `staging`。 - `AgentWallet.connect()` 不再使用 demo credential fallback;缺少真实 credential 会失败。 @@ -106,10 +113,10 @@ TypeScript: ### `api_key is required` -没有传 `api_key`,也没有设置 `SYNAPSE_API_KEY`。新用户应先签发 agent credential,再把返回的 token 交给 `SynapseClient`。 +没有传 `api_key`,也没有设置 `SYNAPSE_AGENT_KEY`。新用户应先签发 agent credential,再把返回的 token 交给 `SynapseClient`。`SYNAPSE_API_KEY` 仅作为旧 Python 用户的 legacy fallback 保留,新文档和示例统一使用 `SYNAPSE_AGENT_KEY`。 ```bash -export SYNAPSE_API_KEY='agt_xxx_your_real_key' +export SYNAPSE_AGENT_KEY='agt_xxx_your_real_key' ``` ### Discovery 返回 0 个结果 @@ -132,7 +139,7 @@ SDK 会把 `402` 映射到余额、预算或 credential credit limit 相关异 ```bash cd /Users/cliff/workspace/agent/Synapse-Network-Sdk/python PYTHONPATH="$PWD" .venv/bin/python -m pytest synapse_client/test/test_client_unit.py -q -export SYNAPSE_API_KEY='agt_xxx_your_real_key' +export SYNAPSE_AGENT_KEY='agt_xxx_your_real_key' PYTHONPATH="$PWD" .venv/bin/python examples/smoke_test.py --query '名人名言' ``` diff --git a/docs/sdk/capability_inventory.md b/docs/sdk/capability_inventory.md index c396cc4..0e304bd 100644 --- a/docs/sdk/capability_inventory.md +++ b/docs/sdk/capability_inventory.md @@ -8,8 +8,11 @@ Consumer runtime is: 1. owner auth / credential issue 2. agent discovery/search -3. `POST /api/v1/agent/invoke` -4. `GET /api/v1/agent/invocations/{id}` +3. fixed-price API invoke through `POST /api/v1/agent/invoke` with latest discovery price as `cost_usdc` / `costUsdc` +4. token-metered LLM invoke through `invoke_llm()` / `invokeLlm()` with optional `max_cost_usdc` / `maxCostUsdc` +5. `GET /api/v1/agent/invocations/{id}` + +Agent runtime examples use `SYNAPSE_AGENT_KEY=agt_xxx`. Python keeps `SYNAPSE_API_KEY` only as a legacy fallback. The old quote-first flow is not a current SDK main path. Python keeps deprecated compatibility methods that raise a clear error instead of calling removed endpoints. @@ -34,17 +37,18 @@ Supported: 13. check credential status alias 14. ensure credential 15. discovery/search -16. invoke +16. fixed-price invoke 17. invoke with rediscovery on `PRICE_MISMATCH` -18. invocation receipt -19. gateway health check -20. empty discovery diagnostics -21. owner profile -22. usage logs -23. voucher redeem -24. finance audit logs -25. finance risk overview -26. spending limit through `AgentWallet` +18. token-metered LLM invoke +19. invocation receipt +20. gateway health check +21. empty discovery diagnostics +22. owner profile +23. usage logs +24. voucher redeem +25. finance audit logs +26. finance risk overview +27. spending limit through `AgentWallet` Deprecated: @@ -93,17 +97,18 @@ Supported: 9. credential quota update 10. credential audit logs 11. discovery/search -12. invoke +12. fixed-price invoke 13. invoke with rediscovery on `PRICE_MISMATCH` -14. invocation receipt -15. gateway health check -16. empty discovery diagnostics -17. owner profile -18. usage logs -19. voucher redeem -20. finance audit logs -21. finance risk overview -22. spending limit through issued credential settings +14. token-metered LLM invoke +15. invocation receipt +16. gateway health check +17. empty discovery diagnostics +18. owner profile +19. usage logs +20. voucher redeem +21. finance audit logs +22. finance risk overview +23. spending limit through issued credential settings Discovery/search sends the current gateway request body: `query`, `tags`, `page`, `pageSize`, and `sort`. `limit/offset` remain SDK convenience inputs and are converted to `page/pageSize`. diff --git a/docs/sdk/python_integration.md b/docs/sdk/python_integration.md index 1a9c3c4..9a287e1 100644 --- a/docs/sdk/python_integration.md +++ b/docs/sdk/python_integration.md @@ -11,7 +11,7 @@ Consumer runtime 主链固定为: 3. discovery/search 4. fixed API: `invoke(service_id, payload, cost_usdc=...)` 5. LLM service: `invoke_llm(service_id, payload, max_cost_usdc=...)` -5. receipt 查询 +6. receipt 查询 旧的 quote-first 方法 `create_quote()`、`create_invocation()`、`invoke_service()` 已废弃,不再访问旧 endpoint。调用这些方法会直接提示改用 discovery/search + price-asserted invoke。 @@ -36,7 +36,8 @@ python -m pip install -e ".[dev]" `SynapseClient` 读取顺序: 1. `api_key` 显式参数 -2. `SYNAPSE_API_KEY` +2. `SYNAPSE_AGENT_KEY` +3. legacy fallback: `SYNAPSE_API_KEY` `gateway_url` 读取顺序: @@ -51,15 +52,15 @@ python -m pip install -e ".[dev]" 环境 preset: -1. `local`: `http://127.0.0.1:8000` -2. `staging`: `https://api-staging.synapse-network.ai` -3. `prod`: `https://api.synapse-network.ai`,需等官方 production DNS 和 `/health` 验证后再用于真实资金流 +1. `staging`: `https://api-staging.synapse-network.ai` + +生产环境上线后,公开示例和测试再统一切换到 `prod`。 `AgentWallet.connect()` 不再使用 `demo_key` fallback。没有真实 credential 时会失败。 ## Agent-first 接入链路 -Fresh setup 不应从 `SYNAPSE_API_KEY` 开始。`SYNAPSE_API_KEY` 是 owner wallet 签发 agent credential 之后得到的 runtime token。 +Fresh setup 不应从硬编码 credential 开始。`SYNAPSE_AGENT_KEY` 是 owner wallet 签发 agent credential 之后得到的 runtime token。`SYNAPSE_API_KEY` 只作为 legacy alias 保留。 固定顺序: @@ -121,7 +122,7 @@ service = services[0] result = client.invoke( service.service_id, {"prompt": "hello"}, - cost_usdc=float(service.price_usdc), + cost_usdc=str(service.price_usdc), idempotency_key="job-001", poll_timeout_sec=60, ) @@ -163,7 +164,7 @@ 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_API_KEY=agt_xxx` 调用 provider service。 +2. `consumer_call_provider.py`:使用已有 `SYNAPSE_AGENT_KEY=agt_xxx` 调用 provider service。 3. `consumer_wallet_to_invoke.py`:创建新的 staging wallet,签发 credential,再调用免费服务。 示例命令: @@ -178,7 +179,7 @@ PYTHONPATH="$PWD" .venv/bin/python examples/provider_staging_onboarding.py \ --description "Returns weather data for a city." \ --price-usdc 0 -export SYNAPSE_API_KEY=agt_xxx +export SYNAPSE_AGENT_KEY=agt_xxx PYTHONPATH="$PWD" .venv/bin/python examples/consumer_call_provider.py \ --service-id "weather_api" \ --payload-json '{"prompt":"hello"}' diff --git a/docs/sdk/python_provider_integration.md b/docs/sdk/python_provider_integration.md index 1091d4f..6a3dc29 100644 --- a/docs/sdk/python_provider_integration.md +++ b/docs/sdk/python_provider_integration.md @@ -54,7 +54,7 @@ PYTHONPATH="$PWD" .venv/bin/python examples/provider_staging_onboarding.py \ --price-usdc 0 ``` -注意:staging gateway 必须能访问 provider endpoint,所以 `--endpoint-url` 应使用公网 HTTPS URL,不能使用 `localhost`。 +注意:staging gateway 必须能访问 provider endpoint,所以 `--endpoint-url` 应使用公网 HTTPS URL。 ```python from synapse_client import SynapseAuth diff --git a/docs/sdk/typescript_integration.md b/docs/sdk/typescript_integration.md index 32a703b..fdcbf01 100644 --- a/docs/sdk/typescript_integration.md +++ b/docs/sdk/typescript_integration.md @@ -11,7 +11,7 @@ Consumer runtime 主链固定为: 3. discovery/search 4. fixed API: `invoke(serviceId, payload, { costUsdc })` 5. LLM service: `invokeLlm(serviceId, payload, { maxCostUsdc })` -5. receipt 查询 +6. receipt 查询 TypeScript SDK 不暴露 quote public API。当前 gateway 的正式运行时入口是单步 price-asserted invoke。 @@ -48,12 +48,19 @@ const client = new SynapseClient({ SDK 库内部不隐式读取环境变量;Node、browser、worker runtime 都由应用层决定如何传入配置。可用环境 preset: -1. `local`: `http://127.0.0.1:8000` -2. `staging`: `https://api-staging.synapse-network.ai` -3. `prod`: `https://api.synapse-network.ai`,需等官方 production DNS 和 `/health` 验证后再用于真实资金流 +1. `staging`: `https://api-staging.synapse-network.ai` + +生产环境上线后,公开示例和测试再统一切换到 `prod`。 显式 `gatewayUrl` 会覆盖 `environment`。 +公开示例统一使用 `SYNAPSE_AGENT_KEY`: + +```ts +const agentKey = process.env.SYNAPSE_AGENT_KEY; +if (!agentKey) throw new Error("SYNAPSE_AGENT_KEY is required"); +``` + ## Agent-first 接入链路 Fresh setup 不应从 `credential: "agt_xxx"` 开始。agent credential 必须先由 owner wallet 通过 `SynapseAuth` 签发,然后再交给 `SynapseClient`。 @@ -150,7 +157,7 @@ const result = await client.invoke( serviceId, { prompt: "hello" }, { - costUsdc: Number(service.pricing?.amount ?? 0), + costUsdc: String(service.pricing?.amount ?? "0"), idempotencyKey: "job-001", pollTimeoutMs: 60_000, } @@ -166,7 +173,7 @@ const result = await client.invoke( process.env.SYNAPSE_SERVICE_ID!, { prompt: "hello" }, { - costUsdc: Number(process.env.SYNAPSE_SERVICE_PRICE_USDC!), + costUsdc: process.env.SYNAPSE_SERVICE_PRICE_USDC!, idempotencyKey: "job-known-service-001", } ); diff --git a/docs/test/consumer-e2e-plan.md b/docs/test/consumer-e2e-plan.md index 48e5176..b0bb877 100644 --- a/docs/test/consumer-e2e-plan.md +++ b/docs/test/consumer-e2e-plan.md @@ -1,126 +1,33 @@ -# TypeScript SDK — Consumer E2E 自动化测试方案 +# TypeScript SDK — Staging Consumer E2E Plan -> **目标**:从零创建一个空钱包,全程通过 TypeScript SDK 完成 Consumer 完整链路: -> 链下钱包创建 → 链上充值 → 网关认证 → Credential 颁发 → 服务发现 → 单步调用 → 余额验证 +## Goal ---- +Validate that an existing staging Agent Key can discover services, run a fixed-price API invoke, optionally run a token-metered LLM invoke, and read receipts against the staging gateway. -## 1. 测试范围 +## Required Environment -| # | 阶段 | 验证点 | -|---|------|--------| -| 0 | 环境检查 | Hardhat 节点可达、Gateway 健康、前端 3000/3002 可访问 | -| 1 | 创建钱包 | 使用 `ethers.Wallet.createRandom()` 生成全新 EOA 地址 | -| 2 | 链上 ETH 注入 | Deployer 向新钱包转 0.5 ETH,验证 `getBalance()` > 0 | -| 3 | 链上 USDC Mint | Deployer 调用 `MockUSDC.mint()` 向新钱包注 10 USDC,验证 `balanceOf()` = 10e18 | -| 4 | 链上充值 | 新钱包 `approve(SynapseCore, amount)` + `SynapseCore.deposit(amount)` | -| 5 | 网关认证 | `SynapseAuth.fromWallet()` → challenge → EIP-191 签名 → JWT,缓存验证 | -| 6 | 充值登记 | `registerDepositIntent(txHash, amount)` + `confirmDeposit(intentId, eventKey)` | -| 7 | 余额确认 | `getBalance().consumerAvailableBalance >= DEPOSIT_USDC` | -| 8 | Credential 颁发 | `issueCredential({ name, maxCalls, creditLimit })` → agentToken 不为空 | -| 9 | 服务发现 | `SynapseClient.discover()` 返回列表;能找到本次注册的测试服务 | -| 10 | 通过 discovery 选定 serviceId 调用 | `client.invoke(discoveredServiceId, payload, { costUsdc })` → status SUCCEEDED/SETTLED | -| 11 | 通过已知 serviceId 直接调用 | `client.invoke(serviceId, payload, { costUsdc })` → status SUCCEEDED/SETTLED | -| 12 | 费用扣除 | `invocationResult.chargedUsdc > 0` | -| 13 | 调用凭证 | `client.getInvocation(invocationId)` → 状态可查 | -| 14 | 结算后余额 | `getBalance().consumerAvailableBalance < balance_before_invoke` | +| Variable | Purpose | +|---|---| +| `RUN_STAGING_E2E=1` | Opt in to live staging tests | +| `SYNAPSE_AGENT_KEY=agt_xxx` | Agent runtime credential | +| `SYNAPSE_STAGING_SERVICE_ID` | Fixed-price staging service ID | +| `SYNAPSE_STAGING_SERVICE_PRICE_USDC` | Latest discovery price for that service | +| `SYNAPSE_STAGING_LLM_SERVICE_ID` | Optional token-metered LLM service ID | +| `SYNAPSE_STAGING_LLM_MAX_COST_USDC` | Optional LLM spend cap | ---- +## Flow -## 2. 服务依赖 +1. Create `SynapseClient({ credential, environment: "staging" })`. +2. Call `discover()` to confirm staging discovery responds. +3. Call fixed-price `invoke(serviceId, payload, { costUsdc })`. +4. Read the receipt with `getInvocation(invocationId)`. +5. If LLM env is present, call `invokeLlm(serviceId, payload, { maxCostUsdc })`. -``` -Hardhat local node http://127.0.0.1:8545 (npx hardhat node) -Gateway http://127.0.0.1:8000 (sh scripts/local/restart_gateway.sh) -Frontend (可选) http://localhost:3000 (yarn dev in apps/frontend) -Mock Provider http://127.0.0.1:9299 (测试内部 Node.js HttpServer 自启动) -``` - -合约地址来自 `apps/frontend/src/contract-config.json`(由 Hardhat 部署脚本写入): -- `MockUSDC` — ERC-20,有 `mint(address, uint256)` 方法,仅 Deployer 可调用 -- `SynapseCore` — `deposit(uint256)` 接收 USDC,调用前需 approve - ---- - -## 3. 关键角色 - -| 角色 | 私钥来源 | 作用 | -|------|----------|------| -| Deployer | Hardhat #0 | 拥有 MockUSDC mint 权限;向新钱包转 ETH | -| Fresh Consumer | `Wallet.createRandom()` | 本次测试主角 | -| Provider | Hardhat #2 | 注册测试服务(供发现和调用使用) | - ---- - -## 4. 测试流程图 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ beforeAll (一次性 setup) │ -│ │ -│ [deployer] ──ETH 0.5──► [fresh wallet] │ -│ [deployer] ──mint 10 USDC──► [fresh wallet] │ -│ [fresh wallet] ──approve──► SynapseCore │ -│ [fresh wallet] ──deposit──► SynapseCore ──txHash──► │ -│ │ -│ [fresh wallet] ──auth──► Gateway JWT │ -│ registerDepositIntent(txHash) ──► confirmDeposit(intentId) │ -│ │ -│ [provider] ──auth──► registerService(mockUrl) ──► serviceId │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ Tests │ -│ │ -│ 1. Authentication (JWT + cache) │ -│ 2. Balance (≥ DEPOSIT_USDC after confirm) │ -│ 3. issueCredential → agentToken │ -│ 4. client.discover() → list non-empty; includes test service │ -│ 5. client.invoke(discoveredServiceId, { costUsdc }) → OK │ -│ 6. client.invoke(serviceId, { costUsdc }) direct path → OK │ -│ 7. client.getInvocation(id) → receipt by ID │ -│ 8. balance after < balance before invoke │ -└─────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 5. 验收标准 - -- 所有 consumer E2E Jest test case 通过(`PASS`) -- `chargedUsdc > 0`(实际结算发生) -- `consumerAvailableBalance` 全程正确递减 -- discovery 选中的 `serviceId` 与已注册服务一致 -- 已知 `serviceId` 直调路径同样可用 -- 整体运行时间 < 120 秒 - ---- - -## 6. 测试文件位置 - -| 文件 | 说明 | -|------|------| -| `sdk/typescript/tests/e2e/new-consumer.test.ts` | 主测试文件 | -| `docs/test/sdk/consumer-e2e-plan.md` | 本文档 | -| `docs/bugfix/sdk/bugs.md` | Bug 记录 | - ---- - -## 7. 运行命令 +## Run ```bash -cd sdk/typescript -npm run test:new-consumer -# 等价于: npx jest tests/e2e/new-consumer.test.ts --verbose +cd /Users/cliff/workspace/agent/Synapse-Network-Sdk/typescript +npm run test:e2e ``` ---- - -## 8. 环境变量(可选覆盖) - -| 变量 | 默认值 | 说明 | -|------|--------|------| -| `SYNAPSE_GATEWAY` | `http://127.0.0.1:8000` | Gateway URL | -| `RPC_URL` | `http://127.0.0.1:8545` | Hardhat JSON-RPC | -| `DEPOSIT_USDC` | `10` | 充值金额(USDC) | +The test suite is skipped unless `RUN_STAGING_E2E=1` is set. PR CI keeps staging E2E out of the default gate. diff --git a/docs/test/python-consumer-cold-start-e2e-plan.md b/docs/test/python-consumer-cold-start-e2e-plan.md index 8d6b42c..14eddd5 100644 --- a/docs/test/python-consumer-cold-start-e2e-plan.md +++ b/docs/test/python-consumer-cold-start-e2e-plan.md @@ -1,85 +1,32 @@ -# Python SDK — Consumer Cold-Start E2E 自动化测试方案 +# Python SDK — Staging Consumer E2E Plan -> 目标:验证 Python SDK 能从 **新建钱包** 开始,完整走通 -> `钱包创建 -> 链上充值 -> 网关登录 -> 确认入账 -> 创建 agent -> 服务发现 -> 服务调用 -> 余额减少` +## Goal ---- +Validate that an existing staging Agent Key can invoke staging services through the Python SDK. This replaces the old cold-start chain flow; funding, wallet setup, and credential issuance are handled before the test. -## 1. 测试范围 +## Required Environment -| # | 阶段 | 验证点 | -|---|---|---| -| 1 | 创建钱包 | `Account.create()` 生成全新 EOA | -| 2 | ETH 注入 | Deployer 给新钱包转 0.5 ETH 作为 gas | -| 3 | USDC Mint | Deployer 调 `MockUSDC.mint()` 给新钱包注入 10 USDC | -| 4 | 链上充值 | 新钱包 `approve + deposit` 到 `SynapseCore` | -| 5 | 登录 | `SynapseAuth.from_private_key()` 完成 challenge/sign/verify | -| 6 | JWT 缓存 | 连续 `get_token()` 返回同一 token | -| 7 | 充值登记 | `register_deposit_intent(txHash, amount)` | -| 8 | 充值确认 | `confirm_deposit(intentId, eventKey)` | -| 9 | 余额生效 | `consumerAvailableBalance >= 9.9` | -| 10 | Provider 注册服务 | 通过 provider wallet 注册 mock 服务 | -| 11 | 创建 agent | `issue_credential()` 成功,返回 agent token | -| 12 | 列举 credential | `list_credentials()` 能找到刚创建的 agent | -| 13 | 服务发现 | `client.discover()` 返回服务列表,包含刚注册服务 | -| 14 | 调用 | `client.invoke(serviceId, payload, cost_usdc=...)` 返回 `SUCCEEDED/SETTLED` | -| 15 | Receipt | `client.get_invocation()` 能按 invocation id 取回状态 | -| 16 | 余额扣减 | 调用后余额小于调用前余额 | +| Variable | Purpose | +|---|---| +| `RUN_STAGING_E2E=1` | Opt in to live staging tests | +| `SYNAPSE_AGENT_KEY=agt_xxx` | Agent runtime credential | +| `SYNAPSE_STAGING_SERVICE_ID` | Fixed-price staging service ID | +| `SYNAPSE_STAGING_SERVICE_PRICE_USDC` | Latest discovery price for that service | +| `SYNAPSE_STAGING_LLM_SERVICE_ID` | Optional token-metered LLM service ID | +| `SYNAPSE_STAGING_LLM_MAX_COST_USDC` | Optional LLM spend cap | ---- +## Flow -## 2. 依赖环境 +1. Create `SynapseClient(api_key=..., environment="staging")`. +2. Call fixed-price `invoke(service_id, payload, cost_usdc=...)`. +3. Read the receipt with `get_invocation_receipt(invocation_id)`. +4. If LLM env is present, call `invoke_llm(service_id, payload, max_cost_usdc=...)`. -| 组件 | 地址 | 说明 | -|---|---|---| -| Hardhat node | `http://127.0.0.1:8545` | 本地链 | -| Gateway | `http://127.0.0.1:8000` | Synapse gateway | -| Mock Provider | `http://127.0.0.1:9399` | 测试内启动的 HTTP server | - -合约配置来自: - -1. `/home/alex/Documents/cliff/Synapse-Network/services/user-front/src/contract-config.json` -2. `/home/alex/Documents/cliff/Synapse-Network/services/user-front/src/MockUSDCABI.json` -3. `/home/alex/Documents/cliff/Synapse-Network/services/user-front/src/SynapseCoreABI.json` - ---- - -## 3. 关键角色 - -| 角色 | 来源 | 作用 | -|---|---|---| -| Deployer | Hardhat #0 | 转 ETH、mint USDC | -| Provider | Hardhat #2 | 注册测试服务 | -| Fresh Consumer | `eth_account.Account.create()` | 本次测试主角 | - ---- - -## 4. 测试文件 - -1. `python/synapse_client/test/test_auth_unit.py` -2. `python/synapse_client/test/test_client_unit.py` -3. `python/synapse_client/test/test_consumer_e2e.py` - ---- - -## 5. 运行命令 +## Run ```bash -cd /home/alex/Documents/cliff/Synapse-Network-Sdk/python -python3.11 -m venv .venv -source .venv/bin/activate -python -m pip install -e ".[dev]" - -PYTHONPATH="$PWD" .venv/bin/python -m pytest synapse_client/test/test_auth_unit.py synapse_client/test/test_client_unit.py -q +cd /Users/cliff/workspace/agent/Synapse-Network-Sdk/python PYTHONPATH="$PWD" .venv/bin/python -m pytest synapse_client/test/test_consumer_e2e.py -q -s ``` ---- - -## 6. 验收标准 - -1. 全部 unit test 通过 -2. Python 冷启动 e2e 通过 -3. `charged_usdc > 0` -4. `consumer_available_balance` 在调用后下降 -5. 测试总时长稳定在本地 30 秒内 +The test suite is skipped unless `RUN_STAGING_E2E=1` is set. PR CI keeps staging E2E out of the default gate. diff --git a/docs/test/python-provider-onboarding-e2e-plan.md b/docs/test/python-provider-onboarding-e2e-plan.md index cf644ae..1456632 100644 --- a/docs/test/python-provider-onboarding-e2e-plan.md +++ b/docs/test/python-provider-onboarding-e2e-plan.md @@ -1,71 +1,29 @@ -# Python SDK — Provider Onboarding E2E 自动化测试方案 +# Python SDK — Staging Provider E2E Plan -> 目标:验证 Python SDK 能从 **新钱包 Provider 冷启动** 开始,完整走通 -> `创建钱包 -> 登录 -> 创建 provider credentials -> 注册服务 -> 读取注册状态` +## Goal ---- +Validate provider onboarding against staging with a real provider wallet and a public HTTPS provider endpoint. -## 1. 测试范围 +## Required Environment -| # | 阶段 | 验证点 | -|---|---|---| -| 1 | 创建钱包 | `Account.create()` 生成全新 Provider EOA | -| 2 | 登录 | `SynapseAuth.from_private_key()` 完成 challenge/sign/verify | -| 3 | JWT 缓存 | `get_token()` 返回稳定 token | -| 4 | 创建 provider secret | `issue_provider_secret()` 成功返回 `secret.id` 与 `secretKey` | -| 5 | 查询 provider secret | `list_provider_secrets()` 能找到刚签发的 secret | -| 6 | 注册服务 | `register_provider_service()` 成功返回 `serviceId` | -| 7 | 查询 owner service list | `list_provider_services()` 包含刚注册服务 | -| 8 | 查询服务状态 | `get_provider_service_status()` 返回 lifecycle + runtime 状态 | +| Variable | Purpose | +|---|---| +| `RUN_STAGING_PROVIDER_E2E=1` | Opt in to live staging provider tests | +| `SYNAPSE_PROVIDER_PRIVATE_KEY` | Provider owner private key for staging | +| `SYNAPSE_PROVIDER_ENDPOINT_URL` | Public HTTPS endpoint reachable by staging | ---- +## Flow -## 2. 依赖环境 +1. Authenticate with `SynapseAuth.from_private_key(..., environment="staging")`. +2. Issue a provider secret. +3. Register a provider service with the public HTTPS endpoint. +4. List services and read service status. -| 组件 | 地址 | 说明 | -|---|---|---| -| Gateway | `http://127.0.0.1:8000` | Synapse gateway | -| Mock Provider | `http://127.0.0.1:9499` | 测试内启动的 HTTP server | - -这个 Provider E2E 不依赖链上充值,因此比 consumer 链更轻、更快。 - ---- - -## 3. 关键角色 - -| 角色 | 来源 | 作用 | -|---|---|---| -| Fresh Provider | `eth_account.Account.create()` | 本次测试主角 | -| Mock Provider Server | test fixture | 对 `/health` 和 invoke 返回 200 | - ---- - -## 4. 测试文件 - -1. `python/synapse_client/test/test_auth_unit.py` -2. `python/synapse_client/test/test_provider_e2e.py` - ---- - -## 5. 运行命令 +## Run ```bash -cd /home/alex/Documents/cliff/Synapse-Network-Sdk/python -python3.11 -m venv .venv -source .venv/bin/activate -python -m pip install -e ".[dev]" - -PYTHONPATH="$PWD" .venv/bin/python -m pytest synapse_client/test/test_auth_unit.py -q +cd /Users/cliff/workspace/agent/Synapse-Network-Sdk/python PYTHONPATH="$PWD" .venv/bin/python -m pytest synapse_client/test/test_provider_e2e.py -q -s ``` ---- - -## 6. 验收标准 - -1. unit test 通过 -2. provider onboarding e2e 通过 -3. fresh wallet 能拿到 JWT -4. provider secret 创建成功 -5. service register 成功返回 `serviceId` -6. `get_provider_service_status()` 能查到刚注册服务的状态 +The test suite is skipped unless `RUN_STAGING_PROVIDER_E2E=1` is set. diff --git a/docs/test/typescript-provider-onboarding-e2e-plan.md b/docs/test/typescript-provider-onboarding-e2e-plan.md index 6193e2d..3d4cc52 100644 --- a/docs/test/typescript-provider-onboarding-e2e-plan.md +++ b/docs/test/typescript-provider-onboarding-e2e-plan.md @@ -1,67 +1,29 @@ -# TypeScript SDK — Provider Onboarding E2E 自动化测试方案 +# TypeScript SDK — Staging Provider E2E Plan -> 目标:验证 TypeScript SDK 能从 **新钱包 Provider 冷启动** 开始,完整走通 -> `创建钱包 -> 登录 -> 创建 provider credentials -> 注册服务 -> 读取注册状态` +## Goal ---- +Validate provider onboarding against staging with a real provider wallet and a public HTTPS provider endpoint. -## 1. 测试范围 +## Required Environment -| # | 阶段 | 验证点 | -|---|---|---| -| 1 | 创建钱包 | `Wallet.createRandom()` 生成全新 Provider EOA | -| 2 | 登录 | `SynapseAuth.fromWallet()` 完成 challenge/sign/verify | -| 3 | JWT 缓存 | `getToken()` 返回稳定 token | -| 4 | 创建 provider secret | `issueProviderSecret()` 成功返回 `secret.id` 与 `secretKey` | -| 5 | 查询 provider secret | `listProviderSecrets()` 能找到刚签发的 secret | -| 6 | 注册服务 | `registerProviderService()` 成功返回 `serviceId` | -| 7 | 查询 owner service list | `listProviderServices()` 包含刚注册服务 | -| 8 | 查询服务状态 | `getProviderServiceStatus()` 返回 lifecycle + runtime 状态 | +| Variable | Purpose | +|---|---| +| `RUN_STAGING_PROVIDER_E2E=1` | Opt in to live staging provider tests | +| `SYNAPSE_PROVIDER_PRIVATE_KEY` | Provider owner private key for staging | +| `SYNAPSE_PROVIDER_ENDPOINT_URL` | Public HTTPS endpoint reachable by staging | ---- +## Flow -## 2. 依赖环境 +1. Authenticate with `SynapseAuth.fromWallet(wallet, { environment: "staging" })`. +2. Issue a provider secret. +3. Register a provider service with the public HTTPS endpoint. +4. List services and read service status. -| 组件 | 地址 | 说明 | -|---|---|---| -| Gateway | `http://127.0.0.1:8000` | Synapse gateway | -| Mock Provider | `http://127.0.0.1:9498` | 测试内启动的 HTTP server | - -这个 Provider E2E 不依赖链上充值,因此执行速度比 consumer 链更快。 - ---- - -## 3. 关键角色 - -| 角色 | 来源 | 作用 | -|---|---|---| -| Fresh Provider | `ethers.Wallet.createRandom()` | 本次测试主角 | -| Mock Provider Server | test fixture | 对 `/health` 与 invoke 返回 200 | - ---- - -## 4. 测试文件 - -1. `typescript/tests/e2e/provider.test.ts` - ---- - -## 5. 运行命令 +## Run ```bash -cd /home/alex/Documents/cliff/Synapse-Network-Sdk/typescript -npm install -npm run lint +cd /Users/cliff/workspace/agent/Synapse-Network-Sdk/typescript npm run test:provider ``` ---- - -## 6. 验收标准 - -1. TypeScript 编译通过 -2. provider onboarding e2e 通过 -3. fresh wallet 能拿到 JWT -4. provider secret 创建成功 -5. service register 成功返回 `serviceId` -6. `getProviderServiceStatus()` 能查到刚注册服务的状态 +The test suite is skipped unless `RUN_STAGING_PROVIDER_E2E=1` is set. diff --git a/llm-instructions.md b/llm-instructions.md index edf2dbc..701e334 100644 --- a/llm-instructions.md +++ b/llm-instructions.md @@ -10,12 +10,13 @@ CRITICAL RULES FOR AI AGENTS: 2. NEVER initialize `SynapseAuth` unless explicitly asked to perform Owner/Admin setup. 3. NEVER ask the user for their wallet private keys. 4. You MUST use an Agent Key (format: `agt_xxx`). -5. ALWAYS call `.search()` to discover the service before invoking. -6. ALWAYS extract `price_usdc` from the search result and pass it to the invoke method. -7. ALWAYS provide an `idempotency_key`. -8. ALWAYS fetch and read the receipt after invocation to verify settlement. -9. IF the user asks to publish/register/manage a provider API, initialize `SynapseAuth` and use `auth.provider()`. -10. Provider setup is owner-scoped. Do not treat Provider as a separate root account. +5. ALWAYS read it from `SYNAPSE_AGENT_KEY` in examples. +6. For fixed-price API services, call `.search()` first, extract `price_usdc`, and pass it exactly to `invoke()`. +7. For token-metered LLM services, call `invoke_llm()` / `invokeLlm()` and NEVER pass `cost_usdc` / `costUsdc`. +8. ALWAYS provide an `idempotency_key`. +9. ALWAYS fetch and read the receipt after invocation to verify settlement. +10. IF the user asks to publish/register/manage a provider API, initialize `SynapseAuth` and use `auth.provider()`. +11. Provider setup is owner-scoped. Do not treat Provider as a separate root account. @@ -24,12 +25,13 @@ AI Agent 中文规则: 2. 除非用户明确要求 Owner/Admin 设置,否则不要初始化 `SynapseAuth`。 3. 不要向用户索要钱包私钥。 4. 必须使用 Agent Key,格式为 `agt_xxx`。 -5. 调用前必须先 `.search()` 发现服务。 -6. 必须从搜索结果读取 `price_usdc`,并把价格传给 invoke 方法。 -7. 必须提供 `idempotency_key`。 -8. 调用后必须读取 receipt,确认结算状态。 -9. 如果用户要求发布、注册或管理 provider API,才初始化 `SynapseAuth` 并使用 `auth.provider()`。 -10. Provider 是 owner scope 下的角色,不是第二套根账户体系。 +5. 示例中必须从 `SYNAPSE_AGENT_KEY` 读取 Agent Key。 +6. 普通 fixed-price API 调用前必须先 `.search()`,读取 `price_usdc`,并原样传给 `invoke()`。 +7. 按 token 计费的 LLM 服务必须使用 `invoke_llm()` / `invokeLlm()`,不要传 `cost_usdc` / `costUsdc`。 +8. 必须提供 `idempotency_key`。 +9. 调用后必须读取 receipt,确认结算状态。 +10. 如果用户要求发布、注册或管理 provider API,才初始化 `SynapseAuth` 并使用 `auth.provider()`。 +11. Provider 是 owner scope 下的角色,不是第二套根账户体系。 Additional runtime preferences: @@ -47,15 +49,17 @@ Python pattern: Python 模式: ```python +import os + from synapse_client import SynapseClient -client = SynapseClient(api_key="agt_xxx", environment="staging") +client = SynapseClient(api_key=os.environ["SYNAPSE_AGENT_KEY"], environment="staging") services = client.search("free", limit=10) service = services[0] result = client.invoke( service.service_id, {"prompt": "hello"}, - cost_usdc=float(service.price_usdc), + cost_usdc=str(service.price_usdc), idempotency_key="agent-job-001", ) receipt = client.get_invocation(result.invocation_id) @@ -68,20 +72,42 @@ TypeScript 模式: ```ts import { SynapseClient } from "@synapse-network/sdk"; -const client = new SynapseClient({ credential: "agt_xxx", environment: "staging" }); +const agentKey = process.env.SYNAPSE_AGENT_KEY; +if (!agentKey) throw new Error("SYNAPSE_AGENT_KEY is required"); + +const client = new SynapseClient({ credential: agentKey, environment: "staging" }); const services = await client.search("free", { limit: 10 }); const service = services[0]; const result = await client.invoke( service.serviceId ?? service.id!, { prompt: "hello" }, { - costUsdc: Number(service.pricing?.amount ?? 0), + costUsdc: String(service.pricing?.amount ?? "0"), idempotencyKey: "agent-job-001", } ); const receipt = await client.getInvocation(result.invocationId); ``` +Token-metered LLM pattern: + +```python +result = client.invoke_llm( + "svc_deepseek_chat", + {"messages": [{"role": "user", "content": "hello"}]}, + max_cost_usdc="0.010000", + idempotency_key="llm-job-001", +) +``` + +```ts +const result = await client.invokeLlm( + "svc_deepseek_chat", + { messages: [{ role: "user", content: "hello" }] }, + { maxCostUsdc: "0.010000", idempotencyKey: "llm-job-001" } +); +``` + Provider publishing pattern: Provider 发布模式: diff --git a/llms.txt b/llms.txt index c1b8cc5..cac7a04 100644 --- a/llms.txt +++ b/llms.txt @@ -1,104 +1,146 @@ # SynapseNetwork SDK -This is the SDK and developer onboarding repository for SynapseNetwork. - -这是 SynapseNetwork 的 SDK 与开发者接入仓库。 - -README language layout: - -- English default: `README.md` -- Simplified Chinese: `README.zh-CN.md` -- SDK docs English hub: `docs/sdk/README.md` -- SDK docs Simplified Chinese hub: `docs/sdk/README.zh-CN.md` -- Do not interleave English and Chinese in the same README body. - -README 语言结构: - -- 英文默认页:`README.md` -- 简体中文页:`README.zh-CN.md` -- SDK docs 英文页:`docs/sdk/README.md` -- SDK docs 简体中文页:`docs/sdk/README.zh-CN.md` -- 不要在同一个 README 正文中交替混排中英文。 - -## Start Here - -优先阅读。 - -- AGENTS.md - repository-local agent instructions and validation expectations -- docs/agent-map/README.md - task-to-file map for AI agents -- docs/agent-map/index.json - machine-readable routing map -- llm-instructions.md - minimal prompt rules for AI agents integrating the SDK -- README.md - English public preview README and shortest integration examples -- README.zh-CN.md - Simplified Chinese public preview README and shortest integration examples -- SECURITY.md - vulnerability reporting, credential handling, and staging/prod safety -- docs/sdk/README.md - English SDK documentation hub -- docs/sdk/README.zh-CN.md - Simplified Chinese SDK documentation hub -- docs/sdk/capability_inventory.md - current Python and TypeScript SDK capability truth -- docs/quality-gates.md - executable PR quality gate thresholds and refactor rules -- https://staging.synapse-network.ai/docs/sdk/python - public preview Python SDK runbook -- https://staging.synapse-network.ai/docs/sdk/typescript - public preview TypeScript SDK runbook -- scripts/ci/pr_checks.sh - local PR quality gate used by GitHub Actions - -中文入口说明: - -- AGENTS.md - 仓库级 Agent 指令和验证要求 -- docs/agent-map/README.md - 面向 AI Agent 的任务到文件映射 -- docs/agent-map/index.json - 机器可读路由映射 -- llm-instructions.md - Agent 集成 SDK 的最小提示规则 -- README.md - 英文 public preview README 和最短接入示例 -- README.zh-CN.md - 简体中文 public preview README 和最短接入示例 -- SECURITY.md - 漏洞报告、凭据处理、staging/prod 安全边界 -- docs/sdk/README.md - 英文 SDK 文档 hub -- docs/sdk/README.zh-CN.md - 简体中文 SDK 文档 hub -- docs/sdk/capability_inventory.md - Python 和 TypeScript SDK 当前能力真相 -- docs/quality-gates.md - 可执行 PR 质量门禁阈值和重构规则 -- scripts/ci/pr_checks.sh - GitHub Actions 复用的本地 PR 质量门禁 - -## Main Areas - -主要区域。 - -- Python SDK: `python/synapse_client` -- TypeScript SDK: `typescript/src` -- Python tests: `python/synapse_client/test` -- TypeScript tests: `typescript/tests` -- SDK docs: `docs/sdk` -- CI scripts: `scripts/ci` +SynapseNetwork is the settlement layer for AI agents. It allows AI agents to discover APIs, invoke them, and pay with zero-gas crypto micropayments (USDC) without human-in-the-loop. + +This repository contains the official SynapseNetwork SDKs for TypeScript and Python. + +## Core Concepts + +- **Agent Key (`agt_xxx`)**: Runtime credential used by consumer agents to invoke services and pay. +- **Service ID**: Unique identifier for a provider API. +- **Staging URL**: `https://api-staging.synapse-network.ai`; staging is the default development environment. +- **Money Rule**: Never recompute prices with floating-point math. Pass discovered prices and spend limits through exactly; use string amounts, for example `"0.05"`. + +## Easiest Way: MCP Server + +If you are integrating SynapseNetwork into an agent framework such as Cursor, Claude Desktop, or LangChain, prefer the official MCP server instead of writing direct SDK code. + +- Registry: `@synapse-network/mcp-server` +- Environment variable: `SYNAPSE_AGENT_KEY=agt_xxx` + +## SDK Quickstart: Consumer Agent + +Use the SDK directly when application code needs to discover services, invoke them, and read receipts. The runtime workflow is always: + +1. Discover or choose a service ID. +2. Invoke and pay with an agent key. +3. Read the invocation result and receipt. + +Use exactly one invocation mode: + +- Fixed-price API invoke: normal API services use `invoke()` / `invoke` with latest discovery price as `cost_usdc` / `costUsdc`. +- Token-metered LLM invoke: LLM services use `invoke_llm()` / `invokeLlm()` with optional `max_cost_usdc` / `maxCostUsdc`; do not pass `cost_usdc` / `costUsdc`. + +### TypeScript + +```typescript +import { SynapseClient } from "@synapse-network/sdk"; + +const agentKey = process.env.SYNAPSE_AGENT_KEY; +if (!agentKey) { + throw new Error("SYNAPSE_AGENT_KEY is required"); +} + +const client = new SynapseClient({ + credential: agentKey, + environment: "staging", +}); + +// Fixed-price API invoke. costUsdc must match the discovered price. +const invokeRes = await client.invoke( + "web3-sentiment-index", + { target: "Ethereum" }, + { + costUsdc: "0.05", + idempotencyKey: "task_123", + } +); + +console.log(invokeRes.result, invokeRes.chargedUsdc); + +// Token-metered LLM invoke. Do not pass costUsdc for LLM services. +const llmRes = await client.invokeLlm( + "svc_provider_deepseek_chat", + { messages: [{ role: "user", content: "Hello" }] }, + { + maxCostUsdc: "0.10", + idempotencyKey: "task_124", + } +); + +console.log(llmRes.usage, llmRes.synapse); +``` + +### Python + +```python +import os + +from synapse_client import SynapseClient + +agent_key = os.environ["SYNAPSE_AGENT_KEY"] + +client = SynapseClient( + api_key=agent_key, + environment="staging", +) + +# Fixed-price API invoke. cost_usdc must match the discovered price. +response = client.invoke( + service_id="web3-sentiment-index", + payload={"target": "Ethereum"}, + cost_usdc="0.05", + idempotency_key="task_123", +) + +print(response.result, response.charged_usdc) + +# Token-metered LLM invoke. Do not pass cost_usdc for LLM services. +llm_response = client.invoke_llm( + service_id="svc_provider_deepseek_chat", + payload={"messages": [{"role": "user", "content": "Hello"}]}, + max_cost_usdc="0.10", + idempotency_key="task_124", +) + +print(llm_response.usage, llm_response.synapse) +``` + +## Owner and Provider Workflows + +- Use `SynapseAuth` only for owner/backend tasks such as wallet auth, issuing agent credentials, balance/deposit helpers, and provider registration. +- Agent runtime code should use `SynapseClient` with an existing `agt_xxx` key. +- Provider publishing code should use `auth.provider()` / `SynapseProvider`. +- Public `SynapseAuth` and `SynapseProvider` methods return named SDK objects/interfaces, not raw `dict` / `Record`. + +## Repository Map for Contributors + +- Python SDK: `python/synapse_client/` +- TypeScript SDK: `typescript/src/` +- Python tests: `python/synapse_client/test/` +- TypeScript tests: `typescript/tests/` +- SDK docs: `docs/sdk/` +- Pull request checks: `scripts/ci/` ## Validation -验证命令。 +Run before committing: + +```bash +bash scripts/ci/pr_checks.sh +``` -- Full PR gate: `bash scripts/ci/pr_checks.sh` -- Python checks: `bash scripts/ci/python_checks.sh` -- TypeScript checks: `bash scripts/ci/typescript_checks.sh` -- Repo hygiene: `bash scripts/ci/repo_hygiene_checks.sh` -- Security checks: `bash scripts/ci/security_checks.sh` +Focused checks: -## Boundaries +```bash +bash scripts/ci/python_checks.sh +bash scripts/ci/typescript_checks.sh +bash scripts/ci/repo_hygiene_checks.sh +bash scripts/ci/security_checks.sh +``` -边界。 +## Safety Rules -- Product name is SynapseNetwork. Do not use old agent-payment brand labels as the product name. -- SynapseNetwork is a platform where agents call APIs and make small USDC payments through blockchain-backed settlement. -- Default public preview environment is staging: `https://api-staging.synapse-network.ai`. -- Do not reintroduce the retired gateway domain from the old `.network` namespace. - Do not commit real credentials, provider secrets, private keys, wallet seed phrases, or production tokens. -- SDK runtime flow is discovery/search plus price-asserted invoke plus receipt. -- Agent runtime code should use `SynapseClient` with an `agt_xxx` key. Do not initialize `SynapseAuth` unless the task explicitly asks for owner credential issuance. -- Provider publishing code should use `auth.provider()` / `SynapseProvider`; Provider is an owner-scoped supply-side role, not a separate root account. -- Public `SynapseAuth` and `SynapseProvider` methods must return named SDK objects/interfaces instead of raw `dict` / `Record`. -- Old Python quote-first helpers are deprecated compatibility shims and must not call removed gateway endpoints. - -中文边界: - -- 产品名是 SynapseNetwork,不要把旧的 agent-payment 命名当作产品名。 -- SynapseNetwork 是 Agent 调用 API 并通过区块链支持的 USDC 结算完成小额付款的平台。 -- 默认 public preview 环境是 staging: `https://api-staging.synapse-network.ai`。 -- 不要恢复旧 `.network` namespace 下的退役 gateway 域名。 -- 不要提交真实凭据、provider secret、私钥、钱包助记词或生产 token。 -- SDK runtime 主链是 discovery/search + price-asserted invoke + receipt。 -- Agent runtime 代码应使用 `SynapseClient` 和 `agt_xxx` key。除非任务明确要求 owner credential issuance,否则不要初始化 `SynapseAuth`。 -- Provider 发布代码应使用 `auth.provider()` / `SynapseProvider`;Provider 是 owner scope 下的供给侧角色,不是第二套根账户体系。 -- 公开 `SynapseAuth` / `SynapseProvider` 方法必须返回命名 SDK 对象/interface,不能返回 raw `dict` / `Record`。 -- 旧 Python quote-first helper 是废弃兼容层,不能调用已移除的 gateway endpoint。 +- Do not reintroduce retired `.network` gateway domains. +- Product name is `SynapseNetwork`. diff --git a/python/synapse_client/client.py b/python/synapse_client/client.py index f8690ee..52c62a3 100644 --- a/python/synapse_client/client.py +++ b/python/synapse_client/client.py @@ -32,6 +32,16 @@ ) +def _resolve_agent_key(api_key: Optional[str]) -> str: + return str(api_key or os.getenv("SYNAPSE_AGENT_KEY", "") or os.getenv("SYNAPSE_API_KEY", "") or "").strip() + + +def _cost_usdc_payload_value(cost_usdc: Union[float, str]) -> Union[float, str]: + if isinstance(cost_usdc, str): + return cost_usdc + return round(float(cost_usdc), 6) + + class SynapseClient: """Official Python client for Synapse agent discovery, invoke, and receipt APIs.""" @@ -42,13 +52,13 @@ def __init__( environment: Optional[str] = None, timeout_sec: int = 30, ): - # Resolve api_key from arguments or environment variable - self.api_key = str(api_key or os.getenv("SYNAPSE_API_KEY", "") or "").strip() + # Resolve api_key from arguments or environment variables. + self.api_key = _resolve_agent_key(api_key) self.gateway_url = resolve_gateway_url(environment=environment, gateway_url=gateway_url) self.timeout_sec = timeout_sec if not self.api_key: - raise ValueError("api_key is required. Pass it via init or set SYNAPSE_API_KEY env var.") + raise ValueError("api_key is required. Pass it via init or set SYNAPSE_AGENT_KEY env var.") def _headers(self, request_id: Optional[str] = None) -> Dict[str, str]: headers = { @@ -354,14 +364,14 @@ def invoke( invocation_key = (idempotency_key or f"invoke-{uuid4().hex}").strip() runtime_payload = RuntimePayload(body=payload or {}) - body = { + body: Dict[str, Any] = { "serviceId": service_id.strip(), "idempotencyKey": invocation_key, "payload": runtime_payload.model_dump(by_alias=True), "responseMode": response_mode, } if cost_usdc is not None: - body["costUsdc"] = round(float(cost_usdc), 6) + body["costUsdc"] = _cost_usdc_payload_value(cost_usdc) if max_cost_usdc is not None: body["maxCostUsdc"] = str(max_cost_usdc) response = requests.post( diff --git a/python/synapse_client/config.py b/python/synapse_client/config.py index 4a3dd2d..2e3fa90 100644 --- a/python/synapse_client/config.py +++ b/python/synapse_client/config.py @@ -3,10 +3,9 @@ import os from typing import Literal, Optional -SynapseEnvironment = Literal["local", "staging", "prod"] +SynapseEnvironment = Literal["staging", "prod"] GATEWAY_URLS: dict[SynapseEnvironment, str] = { - "local": "http://127.0.0.1:8000", "staging": "https://api-staging.synapse-network.ai", "prod": "https://api.synapse-network.ai", } diff --git a/python/synapse_client/test/test_auth_unit.py b/python/synapse_client/test/test_auth_unit.py index 890a7cf..657bbb0 100644 --- a/python/synapse_client/test/test_auth_unit.py +++ b/python/synapse_client/test/test_auth_unit.py @@ -76,7 +76,7 @@ def fake_request(method, url, headers, json, timeout): auth = SynapseAuth( wallet_address="0xAbC", signer=lambda message: signed_messages.append(message) or "0xsigned", - gateway_url="http://127.0.0.1:8000", + gateway_url="https://gateway.example", timeout_sec=9, ) @@ -89,14 +89,14 @@ def fake_request(method, url, headers, json, timeout): assert calls == [ { "method": "GET", - "url": "http://127.0.0.1:8000/api/v1/auth/challenge?address=0xabc", + "url": "https://gateway.example/api/v1/auth/challenge?address=0xabc", "headers": {"Content-Type": "application/json"}, "json": None, "timeout": 9, }, { "method": "POST", - "url": "http://127.0.0.1:8000/api/v1/auth/verify", + "url": "https://gateway.example/api/v1/auth/verify", "headers": {"Content-Type": "application/json"}, "json": { "wallet_address": "0xabc", @@ -178,7 +178,7 @@ def fake_request(method, url, headers, json, timeout): auth = SynapseAuth( wallet_address="0xabc", signer=lambda _: "0xsigned", - gateway_url="http://127.0.0.1:8000", + gateway_url="https://gateway.example", timeout_sec=12, ) @@ -488,7 +488,7 @@ def fake_request(method, url, headers, json, timeout): monkeypatch.setattr("synapse_client.auth.requests.request", fake_request) - auth = SynapseAuth(wallet_address="0xabc", signer=lambda _: "0xsigned", gateway_url="http://127.0.0.1:8000") + auth = SynapseAuth(wallet_address="0xabc", signer=lambda _: "0xsigned", gateway_url="https://gateway.example") revoked = auth.revoke_credential("cred_1") rotated = auth.rotate_credential("cred_1") quota = auth.update_credential_quota("cred_1", credit_limit=5, rpm=60) @@ -576,7 +576,7 @@ def fake_request(method, url, headers, json, timeout): monkeypatch.setattr("synapse_client.auth.requests.request", fake_request) - auth = SynapseAuth(wallet_address="0xabc", signer=lambda _: "0xsigned", gateway_url="http://127.0.0.1:8000") + auth = SynapseAuth(wallet_address="0xabc", signer=lambda _: "0xsigned", gateway_url="https://gateway.example") _assert_provider_control_result_types(_call_provider_lifecycle_helpers(auth)) urls = [call["url"] for call in calls[2:]] diff --git a/python/synapse_client/test/test_client_unit.py b/python/synapse_client/test/test_client_unit.py index 38c298f..00e45cf 100644 --- a/python/synapse_client/test/test_client_unit.py +++ b/python/synapse_client/test/test_client_unit.py @@ -27,12 +27,49 @@ def json(self): def test_client_requires_api_key(monkeypatch): + monkeypatch.delenv("SYNAPSE_AGENT_KEY", raising=False) monkeypatch.delenv("SYNAPSE_API_KEY", raising=False) with pytest.raises(ValueError, match="api_key is required"): SynapseClient(api_key="") +def test_client_reads_synapse_agent_key(monkeypatch): + monkeypatch.setenv("SYNAPSE_AGENT_KEY", "agt_agent") + monkeypatch.delenv("SYNAPSE_API_KEY", raising=False) + + client = SynapseClient() + + assert client.api_key == "agt_agent" + + +def test_client_prefers_synapse_agent_key_over_legacy_api_key(monkeypatch): + monkeypatch.setenv("SYNAPSE_AGENT_KEY", "agt_agent") + monkeypatch.setenv("SYNAPSE_API_KEY", "agt_legacy") + + client = SynapseClient() + + assert client.api_key == "agt_agent" + + +def test_client_keeps_legacy_synapse_api_key_fallback(monkeypatch): + monkeypatch.delenv("SYNAPSE_AGENT_KEY", raising=False) + monkeypatch.setenv("SYNAPSE_API_KEY", "agt_legacy") + + client = SynapseClient() + + assert client.api_key == "agt_legacy" + + +def test_client_explicit_api_key_overrides_env(monkeypatch): + monkeypatch.setenv("SYNAPSE_AGENT_KEY", "agt_agent") + monkeypatch.setenv("SYNAPSE_API_KEY", "agt_legacy") + + client = SynapseClient(api_key="agt_explicit") + + assert client.api_key == "agt_explicit" + + def test_client_uses_synapse_gateway_env(monkeypatch): monkeypatch.setenv("SYNAPSE_GATEWAY", "https://gateway.example") client = SynapseClient(api_key="agt_test") @@ -51,7 +88,6 @@ def test_resolve_gateway_url_supports_presets_and_explicit_override(monkeypatch) monkeypatch.delenv("SYNAPSE_GATEWAY", raising=False) monkeypatch.delenv("SYNAPSE_ENV", raising=False) - assert resolve_gateway_url(environment="local") == "http://127.0.0.1:8000" assert resolve_gateway_url(environment="staging") == "https://api-staging.synapse-network.ai" assert resolve_gateway_url(environment="prod") == "https://api.synapse-network.ai" assert resolve_gateway_url(environment="prod", gateway_url="https://gateway.example/") == "https://gateway.example" @@ -59,9 +95,9 @@ def test_resolve_gateway_url_supports_presets_and_explicit_override(monkeypatch) def test_resolve_gateway_url_uses_synapse_env(monkeypatch): monkeypatch.delenv("SYNAPSE_GATEWAY", raising=False) - monkeypatch.setenv("SYNAPSE_ENV", "local") + monkeypatch.setenv("SYNAPSE_ENV", "staging") - assert resolve_gateway_url() == "http://127.0.0.1:8000" + assert resolve_gateway_url() == "https://api-staging.synapse-network.ai" def test_resolve_gateway_url_prefers_explicit_environment_over_synapse_gateway(monkeypatch): @@ -79,13 +115,31 @@ def test_resolve_gateway_url_rejects_invalid_environment(monkeypatch): resolve_gateway_url(environment="preview") +def test_resolve_gateway_url_rejects_local_environment(monkeypatch): + monkeypatch.delenv("SYNAPSE_GATEWAY", raising=False) + monkeypatch.delenv("SYNAPSE_ENV", raising=False) + + with pytest.raises(ValueError, match="unsupported Synapse environment"): + resolve_gateway_url(environment="local") + + def test_agent_wallet_connect_requires_real_credential(monkeypatch): + monkeypatch.delenv("SYNAPSE_AGENT_KEY", raising=False) monkeypatch.delenv("SYNAPSE_API_KEY", raising=False) with pytest.raises(ValueError, match="api_key is required"): AgentWallet.connect() +def test_agent_wallet_connect_reads_synapse_agent_key(monkeypatch): + monkeypatch.setenv("SYNAPSE_AGENT_KEY", "agt_agent") + monkeypatch.setenv("SYNAPSE_API_KEY", "agt_legacy") + + wallet = AgentWallet.connect() + + assert wallet.api_key == "agt_agent" + + def test_discover_services_passes_intent_and_parses_response(monkeypatch): calls = [] @@ -129,7 +183,7 @@ def fake_post(url, headers, json, timeout): monkeypatch.setattr("synapse_client.client.requests.post", fake_post) - client = SynapseClient(api_key="agt_test", gateway_url="http://127.0.0.1:8000", timeout_sec=12) + client = SynapseClient(api_key="agt_test", gateway_url="https://gateway.example", timeout_sec=12) result = client.discover_services(intent="quotes", tags=["quotes"]) assert result.count == 1 @@ -142,7 +196,7 @@ def fake_post(url, headers, json, timeout): assert str(result.services[0].price_usdc) == "0.05" assert calls == [ { - "url": "http://127.0.0.1:8000/api/v1/agent/discovery/search", + "url": "https://gateway.example/api/v1/agent/discovery/search", "headers": { "Content-Type": "application/json", "X-Credential": "agt_test", @@ -310,6 +364,20 @@ def fake_post(url, headers, json, timeout): assert captured[0]["costUsdc"] == pytest.approx(0.10) +def test_invoke_with_string_cost_usdc_preserves_exact_amount(monkeypatch): + captured = [] + + def fake_post(url, headers, json, timeout): + captured.append(json) + return DummyResponse(json_data={"invocationId": "inv_s", "status": "SUCCEEDED", "chargedUsdc": "0.050000"}) + + monkeypatch.setattr("synapse_client.client.requests.post", fake_post) + client = SynapseClient(api_key="agt_test") + client.invoke("svc_2", {"prompt": "test"}, cost_usdc="0.050000", idempotency_key="ik-string") + + assert captured[0]["costUsdc"] == "0.050000" + + def test_invoke_llm_sends_max_cost_without_cost_usdc(monkeypatch): captured = [] @@ -455,7 +523,7 @@ def test_gateway_health_and_empty_discovery_diagnostics(monkeypatch): lambda url, timeout: DummyResponse(json_data={"status": "ok", "version": "2.0.0"}), ) - client = SynapseClient(api_key="agt_test", gateway_url="http://127.0.0.1:8000") + client = SynapseClient(api_key="agt_test", gateway_url="https://gateway.example") assert client.check_gateway_health()["status"] == "ok" diagnostics = client.explain_discovery_empty_result(query="quotes", tags=["text"]) diff --git a/python/synapse_client/test/test_consumer_e2e.py b/python/synapse_client/test/test_consumer_e2e.py index f24da74..df195ab 100644 --- a/python/synapse_client/test/test_consumer_e2e.py +++ b/python/synapse_client/test/test_consumer_e2e.py @@ -1,411 +1,73 @@ from __future__ import annotations -import json -import threading -import time -from http.server import BaseHTTPRequestHandler, HTTPServer -from pathlib import Path +import os from uuid import uuid4 import pytest -import requests -from synapse_client import SynapseAuth, SynapseClient +from synapse_client import SynapseClient -pytest.importorskip("eth_account") -pytest.importorskip("web3") +pytestmark = pytest.mark.e2e -from eth_account import Account -from web3 import Web3 -GATEWAY_URL = "http://127.0.0.1:8000" -RPC_URL = "http://127.0.0.1:8545" -DEPOSIT_USDC = 10 -MOCK_PROVIDER_PORT = 9399 +def _staging_consumer_env() -> dict[str, str]: + if os.getenv("RUN_STAGING_E2E") != "1": + pytest.skip("set RUN_STAGING_E2E=1 to run staging consumer e2e tests") -DEPLOYER_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" -PROVIDER_KEY = "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ea870594801966b8ea0ec4f" + required = { + "SYNAPSE_AGENT_KEY": os.getenv("SYNAPSE_AGENT_KEY", "").strip(), + "SYNAPSE_STAGING_SERVICE_ID": os.getenv("SYNAPSE_STAGING_SERVICE_ID", "").strip(), + "SYNAPSE_STAGING_SERVICE_PRICE_USDC": os.getenv("SYNAPSE_STAGING_SERVICE_PRICE_USDC", "").strip(), + } + missing = [name for name, value in required.items() if not value] + if missing: + pytest.skip(f"missing staging consumer e2e env: {', '.join(missing)}") + return required -REPO_ROOT = Path(__file__).resolve().parents[4] / "Synapse-Network" -CONTRACT_CONFIG_PATH = REPO_ROOT / "services/user-front/src/contract-config.json" -MOCK_USDC_ABI_PATH = REPO_ROOT / "services/user-front/src/MockUSDCABI.json" -SYNAPSE_CORE_ABI_PATH = REPO_ROOT / "services/user-front/src/SynapseCoreABI.json" -SESSION_ID = uuid4().hex[:8] -SERVICE_NAME = f"py_sdk_e2e_{SESSION_ID}" -CRED_NAME = f"py-sdk-cred-{SESSION_ID}" - - -def _load_json(path: Path): - return json.loads(path.read_text()) - - -def _wait_for(predicate, timeout_sec: float = 30.0, interval_sec: float = 1.0, message: str = "condition not met"): - deadline = time.time() + timeout_sec - while time.time() < deadline: - value = predicate() - if value: - return value - time.sleep(interval_sec) - raise AssertionError(message) - - -def _raw_signed(signed_tx): - raw = getattr(signed_tx, "raw_transaction", None) - if raw is None: - raw = getattr(signed_tx, "rawTransaction", None) - if raw is None: - raise AttributeError("Signed transaction does not expose raw_transaction/rawTransaction") - return raw - - -def _send_transaction(w3: Web3, account, tx: dict): - signed = account.sign_transaction(tx) - tx_hash = w3.eth.send_raw_transaction(_raw_signed(signed)) - return w3.eth.wait_for_transaction_receipt(tx_hash, timeout=120) - - -def _normalized_tx_hash(value) -> str: - if isinstance(value, bytes): - hex_value = value.hex() - else: - hex_value = str(value) - return hex_value if hex_value.startswith("0x") else f"0x{hex_value}" - - -def _fund_and_deposit(w3: Web3, fresh_account, deployer_account, amount_usdc: int) -> str: - config = _load_json(CONTRACT_CONFIG_PATH) - mock_usdc_abi = _load_json(MOCK_USDC_ABI_PATH) - synapse_core_abi = _load_json(SYNAPSE_CORE_ABI_PATH) - chain_id = int(w3.eth.chain_id) - gas_price = int(w3.eth.gas_price) - - usdc = w3.eth.contract(address=Web3.to_checksum_address(config["MockUSDC"]), abi=mock_usdc_abi) - core = w3.eth.contract(address=Web3.to_checksum_address(config["SynapseCore"]), abi=synapse_core_abi) - - decimals = int(usdc.functions.decimals().call()) - amount_wei = int(amount_usdc * (10**decimals)) - - deployer_nonce = w3.eth.get_transaction_count(deployer_account.address, "pending") - _send_transaction( - w3, - deployer_account, - { - "to": fresh_account.address, - "value": w3.to_wei(0.5, "ether"), - "nonce": deployer_nonce, - "gas": 21_000, - "gasPrice": gas_price, - "chainId": chain_id, - }, - ) - deployer_nonce += 1 - - mint_tx = usdc.functions.mint(fresh_account.address, amount_wei).build_transaction( - { - "from": deployer_account.address, - "nonce": deployer_nonce, - "gasPrice": gas_price, - "chainId": chain_id, - "gas": 300_000, - } - ) - _send_transaction(w3, deployer_account, mint_tx) - - fresh_nonce = w3.eth.get_transaction_count(fresh_account.address, "pending") - approve_tx = usdc.functions.approve(core.address, amount_wei).build_transaction( - { - "from": fresh_account.address, - "nonce": fresh_nonce, - "gasPrice": gas_price, - "chainId": chain_id, - "gas": 300_000, - } - ) - _send_transaction(w3, fresh_account, approve_tx) - fresh_nonce += 1 - - deposit_tx = core.functions.deposit(amount_wei).build_transaction( - { - "from": fresh_account.address, - "nonce": fresh_nonce, - "gasPrice": gas_price, - "chainId": chain_id, - "gas": 500_000, - } - ) - receipt = _send_transaction(w3, fresh_account, deposit_tx) - return _normalized_tx_hash(receipt["transactionHash"]) - - -class _MockProviderHandler(BaseHTTPRequestHandler): - def log_message(self, format, *args): # noqa: A003 - return - - def _write_json(self, status: int, payload: dict): - body = json.dumps(payload).encode("utf-8") - self.send_response(status) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - def do_GET(self): # noqa: N802 - self._write_json(200, {"status": "healthy"}) - - def do_POST(self): # noqa: N802 - self._write_json(200, {"result": "python-sdk e2e mock response"}) - - -@pytest.fixture(scope="module") -def mock_provider_server(): - server = HTTPServer(("127.0.0.1", MOCK_PROVIDER_PORT), _MockProviderHandler) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - try: - yield f"http://127.0.0.1:{MOCK_PROVIDER_PORT}" - finally: - server.shutdown() - thread.join(timeout=5) - server.server_close() - - -@pytest.mark.e2e -def test_python_sdk_consumer_cold_start_e2e(mock_provider_server): - assert CONTRACT_CONFIG_PATH.exists(), f"missing contract config: {CONTRACT_CONFIG_PATH}" - - w3 = Web3(Web3.HTTPProvider(RPC_URL)) - assert w3.is_connected(), "Hardhat RPC is not reachable" - - deployer_account = Account.from_key(DEPLOYER_KEY) - provider_account = Account.from_key(PROVIDER_KEY) - fresh_account = Account.create() - - fresh_auth = SynapseAuth.from_private_key( - fresh_account.key.hex(), - gateway_url=GATEWAY_URL, +def test_python_sdk_staging_fixed_price_invoke_e2e(): + env = _staging_consumer_env() + client = SynapseClient( + api_key=env["SYNAPSE_AGENT_KEY"], + environment="staging", timeout_sec=30, ) - provider_auth = SynapseAuth.from_private_key( - PROVIDER_KEY, - gateway_url=GATEWAY_URL, - timeout_sec=30, - ) - - tx_hash = _fund_and_deposit(w3, fresh_account, deployer_account, DEPOSIT_USDC) - token = fresh_auth.get_token() - assert isinstance(token, str) and len(token) > 20 - assert fresh_auth.get_token() == token - - intent_resp = fresh_auth.register_deposit_intent(tx_hash, DEPOSIT_USDC) - assert intent_resp.status == "success" - intent_id = intent_resp.intent.resolved_id - event_key = intent_resp.intent.resolved_event_key or tx_hash - assert intent_id, f"missing deposit intent id: {intent_resp.model_dump(by_alias=True)}" - - confirm_resp = fresh_auth.confirm_deposit(intent_id, event_key) - assert confirm_resp.status == "success" - - balance_after_deposit = _wait_for( - lambda: ( - fresh_auth.get_balance() - if float(fresh_auth.get_balance().consumer_available_balance or 0) >= DEPOSIT_USDC * 0.99 - else None - ), - timeout_sec=20, - interval_sec=1.5, - message="deposit never became spendable in gateway balance", - ) - available_after_deposit = float(balance_after_deposit.consumer_available_balance or 0) - assert available_after_deposit >= DEPOSIT_USDC * 0.99 - - provider_token = provider_auth.get_token() - create_service_resp = requests.post( # type: ignore[name-defined] - f"{GATEWAY_URL}/api/v1/services", - headers={ - "Content-Type": "application/json", - "Authorization": f"Bearer {provider_token}", - }, - json={ - "agentToolName": SERVICE_NAME, - "serviceName": f"Python SDK E2E Service {SESSION_ID}", - "role": "Provider", - "status": "active", - "isActive": True, - "pricing": {"amount": "0.001", "currency": "USDC"}, - "summary": "Python SDK automated e2e integration test service", - "tags": ["py-sdk", "e2e", "test"], - "auth": {"type": "gateway_signed"}, - "invoke": { - "method": "POST", - "targets": [{"url": mock_provider_server}], - "request": {"body": {"type": "object", "properties": {"prompt": {"type": "string"}}}}, - "response": {"body": {"type": "object", "properties": {"result": {"type": "string"}}}}, - }, - "healthCheck": { - "path": "/health", - "method": "GET", - "timeoutMs": 3000, - "successCodes": [200], - "healthyThreshold": 1, - "unhealthyThreshold": 3, - }, - "payoutAccount": { - "payoutAddress": provider_account.address.lower(), - "chainId": int(w3.eth.chain_id), - "settlementCurrency": "USDC", - }, - "providerProfile": {"displayName": f"Python SDK E2E Provider {SESSION_ID}"}, - "governance": {"termsAccepted": True, "riskAcknowledged": True}, - }, - timeout=30, - ) - assert create_service_resp.ok, create_service_resp.text - service_payload = create_service_resp.json() - service_id = ( - service_payload.get("serviceId") - or service_payload.get("id") - or service_payload.get("service_id") - or (service_payload.get("service") or {}).get("serviceId") - or (service_payload.get("service") or {}).get("id") - or SERVICE_NAME - ) - assert service_id - time.sleep(2) - - issue_result = fresh_auth.issue_credential( - name=CRED_NAME, - maxCalls=100, - creditLimit=5.0, - rpm=60, - ) - assert issue_result.token - listed_credentials = fresh_auth.list_credentials() - assert CRED_NAME in [credential.name for credential in listed_credentials] - - client = SynapseClient(api_key=issue_result.token, gateway_url=GATEWAY_URL, timeout_sec=30) - services = client.discover_services(page_size=50) - ids = [service.service_id for service in services.services] - assert ids - assert service_id in ids - discovered_service = next(service for service in services.services if service.service_id == service_id) - assert discovered_service.price_usdc is not None - - balance_before_invoke = float(fresh_auth.get_balance().consumer_available_balance or 0) invocation = client.invoke( - service_id, - payload={"prompt": "python-sdk e2e automated test"}, - cost_usdc=float(discovered_service.price_usdc), - idempotency_key=f"py-sdk-e2e-{SESSION_ID}", + env["SYNAPSE_STAGING_SERVICE_ID"], + payload={"prompt": "python sdk staging e2e"}, + cost_usdc=env["SYNAPSE_STAGING_SERVICE_PRICE_USDC"], + idempotency_key=f"py-staging-e2e-{uuid4().hex[:12]}", poll_timeout_sec=60, ) + assert invocation.invocation_id assert invocation.status in {"SUCCEEDED", "SETTLED"} - assert invocation.charged_usdc > 0 - assert invocation.result receipt = client.get_invocation_receipt(invocation.invocation_id) assert receipt.invocation_id == invocation.invocation_id assert receipt.status in {"SUCCEEDED", "SETTLED"} - balance_after_invoke = _wait_for( - lambda: ( - fresh_auth.get_balance() - if float(fresh_auth.get_balance().consumer_available_balance or 0) < balance_before_invoke - else None - ), - timeout_sec=20, - interval_sec=1.5, - message="post-invocation balance never decreased", - ) - assert float(balance_after_invoke.consumer_available_balance or 0) < balance_before_invoke +def test_python_sdk_staging_llm_token_metered_e2e(): + if os.getenv("RUN_STAGING_E2E") != "1": + pytest.skip("set RUN_STAGING_E2E=1 to run staging consumer e2e tests") -@pytest.mark.e2e -def test_python_sdk_credential_management_e2e(): - """Tests for new credential management APIs: - - list_active_credentials (active_only=true) - - get_credential_status - - update_credential (name + quota PATCH) - - ensure_credential (idempotent init) - """ - w3 = Web3(Web3.HTTPProvider(RPC_URL)) - assert w3.is_connected(), "Hardhat RPC is not reachable" + agent_key = os.getenv("SYNAPSE_AGENT_KEY", "").strip() + service_id = os.getenv("SYNAPSE_STAGING_LLM_SERVICE_ID", "").strip() + max_cost_usdc = os.getenv("SYNAPSE_STAGING_LLM_MAX_COST_USDC", "0.010000").strip() + if not agent_key or not service_id: + pytest.skip("missing SYNAPSE_AGENT_KEY or SYNAPSE_STAGING_LLM_SERVICE_ID") - deployer_account = Account.from_key(DEPLOYER_KEY) - fresh_account = Account.create() - mgmt_cred_name = f"sdk-mgmt-{SESSION_ID}" - - fresh_auth = SynapseAuth.from_private_key( - fresh_account.key.hex(), - gateway_url=GATEWAY_URL, - timeout_sec=30, - ) - - # Fund + deposit (tiny amount, just enough to have an owner account) - tx_hash = _fund_and_deposit(w3, fresh_account, deployer_account, DEPOSIT_USDC) - token = fresh_auth.get_token() - assert isinstance(token, str) and len(token) > 20 - - intent_resp = fresh_auth.register_deposit_intent(tx_hash, DEPOSIT_USDC) - assert intent_resp.status == "success" - confirm_resp = fresh_auth.confirm_deposit( - intent_resp.intent.resolved_id, - intent_resp.intent.resolved_event_key or tx_hash, - ) - assert confirm_resp.status == "success" - - # ── 1. Issue a credential ────────────────────────────────────────────────── - issue_result = fresh_auth.issue_credential( - name=mgmt_cred_name, - maxCalls=300, - creditLimit=3.0, - rpm=30, + client = SynapseClient(api_key=agent_key, environment="staging", timeout_sec=30) + invocation = client.invoke_llm( + service_id, + payload={"messages": [{"role": "user", "content": "hello from python staging e2e"}]}, + max_cost_usdc=max_cost_usdc, + idempotency_key=f"py-staging-llm-e2e-{uuid4().hex[:12]}", + poll_timeout_sec=60, ) - assert issue_result.token, "credential token missing" - cred_id = issue_result.credential.credential_id or issue_result.credential.id - assert cred_id, "credential_id missing" - - # ── 2. list_active_credentials returns the new credential ───────────────── - active_creds = fresh_auth.list_active_credentials() - active_names = [c.name for c in active_creds] - assert mgmt_cred_name in active_names, f"'{mgmt_cred_name}' not in active_only list: {active_names}" - for c in active_creds: - assert c.status == "active", f"non-active in active_only result: {c}" - - # ── 3. get_credential_status returns valid=True ──────────────────────────── - status_result = fresh_auth.get_credential_status(cred_id) - assert status_result.valid is True, f"expected valid=True: {status_result.model_dump()}" - assert status_result.credential_status == "active" - assert status_result.is_expired is False - assert status_result.calls_exhausted is False - assert status_result.credential_id == cred_id - - # ── 4. update_credential renames + changes maxCalls ─────────────────────── - new_name = f"{mgmt_cred_name}-updated" - update_result = fresh_auth.update_credential(cred_id, name=new_name, maxCalls=600) - assert update_result.status == "success" - assert update_result.credential.name == new_name, f"name not updated: {update_result.credential.name}" - assert update_result.credential.max_calls == 600, f"maxCalls not updated: {update_result.credential.max_calls}" - # ── 5. active_only list reflects the rename ──────────────────────────────── - active_after_update = fresh_auth.list_active_credentials() - names_after = [c.name for c in active_after_update] - assert new_name in names_after, f"renamed credential not in active_only list: {names_after}" - - # ── 6. ensure_credential is idempotent — same name = rotate for token ───── - token_a = fresh_auth.ensure_credential(new_name, maxCalls=600, creditLimit=3.0) - assert token_a, "ensure_credential should return a token" - # Second call should also return a token (rotate path) - token_b = fresh_auth.ensure_credential(new_name, maxCalls=600, creditLimit=3.0) - assert token_b, "ensure_credential second call should return a token" - - # ── 7. ensure_credential creates new credential when name doesn't exist ─── - brand_new_name = f"sdk-ensure-new-{SESSION_ID}" - token_new = fresh_auth.ensure_credential(brand_new_name, maxCalls=50, creditLimit=1.0) - assert token_new, f"ensure_credential should issue a new credential for '{brand_new_name}'" - # Verify it's now in active list - active_final = fresh_auth.list_active_credentials() - final_names = [c.name for c in active_final] - assert brand_new_name in final_names, f"ensure_credential-created credential not found: {final_names}" + assert invocation.invocation_id + assert invocation.status in {"SUCCEEDED", "SETTLED"} + assert invocation.synapse is not None diff --git a/python/synapse_client/test/test_provider_e2e.py b/python/synapse_client/test/test_provider_e2e.py index 6c1e724..fffcf29 100644 --- a/python/synapse_client/test/test_provider_e2e.py +++ b/python/synapse_client/test/test_provider_e2e.py @@ -1,62 +1,37 @@ from __future__ import annotations -import json -import threading -from http.server import BaseHTTPRequestHandler, HTTPServer +import os from uuid import uuid4 import pytest from synapse_client import SynapseAuth -pytest.importorskip("eth_account") +pytestmark = pytest.mark.e2e -from eth_account import Account -GATEWAY_URL = "http://127.0.0.1:8000" -MOCK_PROVIDER_PORT = 9499 -SESSION_ID = uuid4().hex[:8] -SERVICE_NAME = f"Python Provider OCR {SESSION_ID}" +def _staging_provider_env() -> dict[str, str]: + if os.getenv("RUN_STAGING_PROVIDER_E2E") != "1": + pytest.skip("set RUN_STAGING_PROVIDER_E2E=1 to run staging provider e2e tests") + required = { + "SYNAPSE_PROVIDER_PRIVATE_KEY": os.getenv("SYNAPSE_PROVIDER_PRIVATE_KEY", "").strip(), + "SYNAPSE_PROVIDER_ENDPOINT_URL": os.getenv("SYNAPSE_PROVIDER_ENDPOINT_URL", "").strip(), + } + missing = [name for name, value in required.items() if not value] + if missing: + pytest.skip(f"missing staging provider e2e env: {', '.join(missing)}") + if not required["SYNAPSE_PROVIDER_ENDPOINT_URL"].startswith("https://"): + pytest.skip("SYNAPSE_PROVIDER_ENDPOINT_URL must be a public HTTPS endpoint") + return required -class _MockProviderHandler(BaseHTTPRequestHandler): - def log_message(self, format, *args): # noqa: A003 - return - def _write_json(self, status: int, payload: dict): - body = json.dumps(payload).encode("utf-8") - self.send_response(status) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - def do_GET(self): # noqa: N802 - self._write_json(200, {"status": "healthy"}) - - def do_POST(self): # noqa: N802 - self._write_json(200, {"result": "provider-sdk e2e mock response"}) - - -@pytest.fixture(scope="module") -def mock_provider_server(): - server = HTTPServer(("127.0.0.1", MOCK_PROVIDER_PORT), _MockProviderHandler) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - try: - yield f"http://127.0.0.1:{MOCK_PROVIDER_PORT}" - finally: - server.shutdown() - thread.join(timeout=5) - server.server_close() - - -@pytest.mark.e2e -def test_python_sdk_provider_onboarding_e2e(mock_provider_server): - fresh_provider = Account.create(f"provider-e2e-{SESSION_ID}") +def test_python_sdk_staging_provider_onboarding_e2e(): + env = _staging_provider_env() + session_id = uuid4().hex[:8] provider_auth = SynapseAuth.from_private_key( - fresh_provider.key.hex(), - gateway_url=GATEWAY_URL, + env["SYNAPSE_PROVIDER_PRIVATE_KEY"], + environment="staging", timeout_sec=30, ) @@ -64,30 +39,25 @@ def test_python_sdk_provider_onboarding_e2e(mock_provider_server): assert isinstance(token, str) and len(token) > 20 issued = provider_auth.issue_provider_secret( - name=f"python-provider-secret-{SESSION_ID}", + name=f"python-provider-secret-{session_id}", rpm=180, creditLimit=25.0, ) assert issued.secret.id assert issued.secret.secret_key.startswith("agt_") - assert issued.secret.owner_address == fresh_provider.address.lower() registered = provider_auth.register_provider_service( - service_name=SERVICE_NAME, - endpoint_url=mock_provider_server, + service_name=f"Python Provider Staging {session_id}", + endpoint_url=env["SYNAPSE_PROVIDER_ENDPOINT_URL"], base_price_usdc="0.002", - description_for_model="Extract structured invoice fields for provider onboarding e2e.", - provider_display_name=f"Python Provider {SESSION_ID}", - governance_note="python provider sdk e2e", + description_for_model="Staging provider onboarding e2e service.", + provider_display_name=f"Python Provider {session_id}", + governance_note="python provider sdk staging e2e", ) - assert registered.status == "success" assert registered.service_id services = provider_auth.list_provider_services() - service_ids = [service.service_id for service in services] - assert registered.service_id in service_ids + assert registered.service_id in [service.service_id for service in services] status = provider_auth.get_provider_service_status(registered.service_id) assert status.service_id == registered.service_id - assert status.lifecycle_status in {"active", "draft", "paused"} - assert status.health.overall_status in {"healthy", "unknown", "degraded"} diff --git a/python/synapse_client/wallet.py b/python/synapse_client/wallet.py index 236f6ae..ad6e729 100644 --- a/python/synapse_client/wallet.py +++ b/python/synapse_client/wallet.py @@ -1,9 +1,8 @@ from __future__ import annotations -import os from typing import Any, Dict, Optional -from .client import SynapseClient +from .client import SynapseClient, _resolve_agent_key from .exceptions import InsufficientFundsError from .models import InvocationResponse @@ -24,7 +23,7 @@ def connect( gateway_url: Optional[str] = None, environment: Optional[str] = None, ) -> "AgentWallet": - api_key = api_key or os.getenv("SYNAPSE_API_KEY", "") + api_key = _resolve_agent_key(api_key) return cls(budget=budget, api_key=api_key, gateway_url=gateway_url, environment=environment) @property diff --git a/scripts/ci/repo_hygiene_checks.sh b/scripts/ci/repo_hygiene_checks.sh index a2ddf3d..10903b2 100755 --- a/scripts/ci/repo_hygiene_checks.sh +++ b/scripts/ci/repo_hygiene_checks.sh @@ -16,6 +16,18 @@ echo "[ci:hygiene] checking public preview staging defaults" grep -RIn --exclude-dir=.git --exclude-dir=node_modules --exclude-dir=.venv \ "https://api-staging.synapse-network.ai" README.md docs python/synapse_client typescript/src typescript/tests >/dev/null +echo "[ci:hygiene] checking removed local gateway guidance" +if grep -RInE --exclude-dir=.git --exclude-dir=node_modules --exclude-dir=.venv --exclude-dir=dist --exclude-dir=build --exclude-dir=coverage --exclude-dir=.pytest_cache \ + '(127\.0\.0\.1:8000|localhost:8000|scripts/local|Hardhat)' README.md README.zh-CN.md docs python/synapse_client/test typescript/tests; then + echo "[ci:hygiene] local gateway guidance detected; public tests and docs must point to staging" >&2 + exit 1 +fi +if grep -RInE --exclude-dir=.git --exclude-dir=node_modules --exclude-dir=.venv --exclude-dir=dist --exclude-dir=build --exclude-dir=coverage --exclude-dir=.pytest_cache \ + 'environment[=:] ?"local"|environment[=:] ?'\''local'\''' README.md README.zh-CN.md docs llms.txt llm-instructions.md; then + echo "[ci:hygiene] removed local environment preset detected in public docs" >&2 + exit 1 +fi + echo "[ci:hygiene] checking deprecated product brand wording" OLD_AGENT_PAY="Agent""Pay" OLD_SYNAPSE_AGENT_PAY="Synapse Agent""Pay" diff --git a/typescript/src/config.ts b/typescript/src/config.ts index fd07843..1323e53 100644 --- a/typescript/src/config.ts +++ b/typescript/src/config.ts @@ -3,7 +3,6 @@ import type { SynapseEnvironment } from "./types"; export const DEFAULT_ENVIRONMENT: SynapseEnvironment = "staging"; export const GATEWAY_URLS: Record = { - local: "http://127.0.0.1:8000", staging: "https://api-staging.synapse-network.ai", prod: "https://api.synapse-network.ai", }; diff --git a/typescript/src/types.ts b/typescript/src/types.ts index 4a5f6ad..ad68f36 100644 --- a/typescript/src/types.ts +++ b/typescript/src/types.ts @@ -1,6 +1,6 @@ // ── Auth ──────────────────────────────────────────────────────────────────── -export type SynapseEnvironment = "local" | "staging" | "prod"; +export type SynapseEnvironment = "staging" | "prod"; export interface SynapseAuthOptions { /** Gateway environment preset. Default: staging public preview. */ @@ -421,7 +421,7 @@ export interface InvokeOptions { * Pass this value from discover() results to enable price-assertion invoke. * Gateway returns 422 PRICE_MISMATCH if the live price has changed. */ - costUsdc: number; + costUsdc: number | string; } export interface LlmInvokeOptions { diff --git a/typescript/tests/e2e/consumer.test.ts b/typescript/tests/e2e/consumer.test.ts index 1651463..ba4d56c 100644 --- a/typescript/tests/e2e/consumer.test.ts +++ b/typescript/tests/e2e/consumer.test.ts @@ -1,403 +1,71 @@ /** - * Synapse TypeScript SDK — Consumer E2E Test + * Synapse TypeScript SDK — Staging Consumer E2E * - * Tests the full consumer pipeline end-to-end against a live local gateway: - * Auth → Credential → Discover → Invoke → Receipt - * - * Prerequisites: - * 1. Hardhat node running: npx hardhat node (port 8545) - * 2. Contracts deployed: sh scripts/local/setup_local_env.sh - * 3. Gateway running: sh scripts/local/restart_gateway.sh (port 8000) - * 4. A registered service available (provider pre-registered or via setup below) - * - * Run: cd sdk/typescript && npm test + * These tests are skipped by default. Set RUN_STAGING_E2E=1 plus the required + * staging service environment variables to run live gateway smoke tests. */ -import { Wallet, JsonRpcProvider, Contract, parseUnits } from "ethers"; import { v4 as uuidv4 } from "uuid"; -import * as fs from "fs"; -import * as path from "path"; -import * as http from "http"; -import { SynapseAuth } from "../../src/auth"; import { SynapseClient } from "../../src/client"; -import { InvocationResult } from "../../src/types"; - -// ── Config ────────────────────────────────────────────────────────────────── - -const GATEWAY_URL = process.env.SYNAPSE_GATEWAY ?? "http://127.0.0.1:8000"; -const RPC_URL = process.env.RPC_URL ?? "http://127.0.0.1:8545"; -const MOCK_PROVIDER_PORT = 9199; - -// Hardhat default keys (localhost only) -const DEPLOYER_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; -const OWNER_KEY = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"; -const PROVIDER_KEY = "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ea870594801966b8ea0ec4f"; -const REPO_ROOT = path.resolve(__dirname, "../../../../"); -const CONTRACT_CONFIG_PATH = path.join(REPO_ROOT, "apps/frontend/src/contract-config.json"); -const MOCK_USDC_ABI_PATH = path.join(REPO_ROOT, "apps/frontend/src/MockUSDCABI.json"); -const SYNAPSE_CORE_ABI_PATH = path.join(REPO_ROOT, "apps/frontend/src/SynapseCoreABI.json"); +const runStagingE2E = process.env.RUN_STAGING_E2E === "1"; +const describeStaging = runStagingE2E ? describe : describe.skip; -const DEPOSIT_USDC = 10; -const SERVICE_PRICE_USDC = 0.001; -const SESSION_ID = uuidv4().replace(/-/g, "").slice(0, 8); -const SERVICE_TOOL_NAME = `ts_sdk_e2e_${SESSION_ID}`; -const CRED_NAME = `ts-sdk-cred-${SESSION_ID}`; - -// ── Shared state ────────────────────────────────────────────────────────────── - -let ownerAuth: SynapseAuth; -let providerAuth: SynapseAuth; -let client: SynapseClient; -let agentToken: string; -let serviceId: string; -let discoveredServiceId: string; -let mockServer: http.Server; -let balanceBeforeInvocations: number; - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -async function startMockProvider(): Promise { - return new Promise((resolve) => { - mockServer = http.createServer((req, res) => { - if (req.method === "GET") { - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ status: "healthy" })); - } else if (req.method === "POST") { - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ result: "ts-sdk e2e mock response" })); - } else { - res.writeHead(405); - res.end(); - } - }); - mockServer.listen(MOCK_PROVIDER_PORT, "127.0.0.1", () => { - resolve(`http://127.0.0.1:${MOCK_PROVIDER_PORT}`); - }); - }); +function requiredEnv(name: string): string { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`${name} is required for staging e2e`); + return value; } -async function doFetch(url: string, init: RequestInit = {}): Promise> { - const resp = await fetch(url, { ...init, headers: { "Content-Type": "application/json", ...(init.headers ?? {}) } }); - const text = await resp.text(); - let data: unknown; - try { - data = JSON.parse(text); - } catch { - data = { raw: text }; - } - if (!resp.ok) throw new Error(`HTTP ${resp.status} ${url}: ${text.slice(0, 200)}`); - return data as Record; -} - -function loadContractConfig(): { MockUSDC: string; SynapseCore: string } { - const raw = fs.readFileSync(CONTRACT_CONFIG_PATH, "utf8"); - return JSON.parse(raw); -} - -async function depositBalance( - provider: JsonRpcProvider, - ownerWallet: Wallet, - deployerWallet: Wallet, - amountUsdc: number -): Promise { - const config = loadContractConfig(); - const mockUsdcAbi = JSON.parse(fs.readFileSync(MOCK_USDC_ABI_PATH, "utf8")); - const synapseCoreAbi = JSON.parse(fs.readFileSync(SYNAPSE_CORE_ABI_PATH, "utf8")); - - const usdc = new Contract(config.MockUSDC, mockUsdcAbi, deployerWallet); - const core = new Contract(config.SynapseCore, synapseCoreAbi, ownerWallet); - - // MockUSDC uses 18 decimals - const decimals = Number(await usdc.decimals()); - const amountWei = parseUnits(String(amountUsdc), decimals); - - // Mint USDC to owner - const mintTx = await (usdc.connect(deployerWallet) as Contract).mint(ownerWallet.address, amountWei); - await mintTx.wait(); - - // Approve SynapseCore to spend - const approveTx = await (usdc.connect(ownerWallet) as Contract).approve(config.SynapseCore, amountWei); - await approveTx.wait(); - - // Deposit on-chain - const depositTx = await core.deposit(amountWei); - const receipt = await depositTx.wait(); - const txHash: string = receipt.hash; - return txHash.startsWith("0x") ? txHash : `0x${txHash}`; -} - -async function registerTestService( - providerAddress: string, - mockServiceUrl: string, - providerToken: string -): Promise { - const body = { - agentToolName: SERVICE_TOOL_NAME, - serviceName: `TS SDK E2E Service ${SESSION_ID}`, - role: "Provider", - status: "active", - isActive: true, - pricing: { amount: String(SERVICE_PRICE_USDC), currency: "USDC" }, - summary: "TypeScript SDK automated e2e integration test service", - tags: ["ts-sdk", "e2e", "test"], - auth: { type: "gateway_signed" }, - invoke: { - method: "POST", - targets: [{ url: mockServiceUrl }], - request: { body: { type: "object", properties: { prompt: { type: "string" } } } }, - response: { body: { type: "object", properties: { result: { type: "string" } } } }, - }, - healthCheck: { - path: "/health", - method: "GET", - timeoutMs: 3000, - successCodes: [200], - healthyThreshold: 1, - unhealthyThreshold: 3, - }, - payoutAccount: { - payoutAddress: providerAddress.toLowerCase(), - chainId: 31337, - settlementCurrency: "USDC", - }, - providerProfile: { displayName: `TS SDK E2E Provider ${SESSION_ID}` }, - governance: { termsAccepted: true, riskAcknowledged: true }, - }; - - const resp = await doFetch(`${GATEWAY_URL}/api/v1/services`, { - method: "POST", - headers: { Authorization: `Bearer ${providerToken}` }, - body: JSON.stringify(body), - }); - - const svcId = - (resp["serviceId"] as string) || - (resp["id"] as string) || - (resp["service_id"] as string) || - ((resp["service"] as Record)?.["serviceId"] as string) || - ((resp["service"] as Record)?.["id"] as string) || - SERVICE_TOOL_NAME; - - return svcId; -} - -// ── Test Suite ──────────────────────────────────────────────────────────────── - -describe("Synapse TypeScript SDK — Consumer E2E Pipeline", () => { - // ── Suite Setup ──────────────────────────────────────────────────────────── - - beforeAll(async () => { - // 1. Start mock provider HTTP server - const mockServiceUrl = await startMockProvider(); - - // 2. Setup ethers wallets - const rpcProvider = new JsonRpcProvider(RPC_URL); - const ownerWallet = new Wallet(OWNER_KEY, rpcProvider); - const providerWallet = new Wallet(PROVIDER_KEY, rpcProvider); - const deployerWallet = new Wallet(DEPLOYER_KEY, rpcProvider); - - // 3. Create SynapseAuth instances - ownerAuth = SynapseAuth.fromWallet(ownerWallet, { gatewayUrl: GATEWAY_URL }); - providerAuth = SynapseAuth.fromWallet(providerWallet, { gatewayUrl: GATEWAY_URL }); - - // 4. Deposit USDC balance on-chain + notify gateway (skip if already funded) - const existingBalance = await ownerAuth.getBalance(); - const existingAvailable = Number(existingBalance.consumerAvailableBalance ?? existingBalance.ownerBalance ?? 0); - - if (existingAvailable < DEPOSIT_USDC / 2) { - const txHash = await depositBalance(rpcProvider, ownerWallet, deployerWallet, DEPOSIT_USDC); - - const intentResp = await ownerAuth.registerDepositIntent(txHash, DEPOSIT_USDC); - expect(intentResp.status).toBe("success"); - - const intentObj = intentResp.intent; - const intentId = (intentObj["id"] || intentObj["intentId"] || intentObj["depositIntentId"] || "") as string; - const eventKey = (intentObj["eventKey"] || intentObj["event_key"] || txHash) as string; - expect(intentId).toBeTruthy(); - - await ownerAuth.confirmDeposit(intentId, eventKey); - // Allow indexer to credit - await new Promise((r) => setTimeout(r, 1500)); - } else { - console.log(`[setup] Skipping deposit — existing balance: ${existingAvailable} USDC`); - } - - // 5. Authenticate provider + register test service - const providerToken = await providerAuth.getToken(); - serviceId = await registerTestService(providerWallet.address, mockServiceUrl, providerToken); - expect(serviceId).toBeTruthy(); - - // Allow health check pass - await new Promise((r) => setTimeout(r, 2000)); - }, 120_000); - - afterAll(() => { - if (mockServer) mockServer.close(); - }); - - // ── Test 1: Auth ─────────────────────────────────────────────────────────── - - describe("1. Authentication", () => { - it("should authenticate owner wallet and return a JWT", async () => { - const token = await ownerAuth.getToken(); - expect(typeof token).toBe("string"); - expect(token.length).toBeGreaterThan(20); - }); - - it("should cache the token on repeated calls", async () => { - const t1 = await ownerAuth.getToken(); - const t2 = await ownerAuth.getToken(); - expect(t1).toBe(t2); +describeStaging("Synapse TS SDK — Staging Consumer E2E", () => { + test("fixed-price invoke reaches a terminal status and receipt is readable", async () => { + const client = new SynapseClient({ + credential: requiredEnv("SYNAPSE_AGENT_KEY"), + environment: "staging", }); - }); - // ── Test 2: Balance ─────────────────────────────────────────────────────── - - describe("2. Balance", () => { - it("should return balance with consumerAvailableBalance > 0 after deposit", async () => { - const balance = await ownerAuth.getBalance(); - const available = Number(balance.consumerAvailableBalance ?? balance.ownerBalance ?? 0); - expect(available).toBeGreaterThan(0); - }); - }); - - // ── Test 3: Issue Credential ─────────────────────────────────────────────── - - describe("3. Agent Credential", () => { - it("should issue a credential and return a token", async () => { - const result = await ownerAuth.issueCredential({ - name: CRED_NAME, - maxCalls: 100, - creditLimit: 5.0, - rpm: 60, - }); - expect(result.token).toBeTruthy(); - expect(result.token.length).toBeGreaterThan(10); - expect(result.credential.id).toBeTruthy(); - - agentToken = result.token; - // Create client with the issued credential - client = new SynapseClient({ credential: agentToken, gatewayUrl: GATEWAY_URL }); - }); - - it("should list credentials and include the issued credential", async () => { - const creds = await ownerAuth.listCredentials(); - const names = creds.map((c) => c.name); - expect(names).toContain(CRED_NAME); - }); - }); - - // ── Test 4: Service Discovery ───────────────────────────────────────────── - - describe("4. Service Discovery", () => { - it("should discover services list (non-empty)", async () => { - const services = await client.discover({ limit: 20 }); - expect(Array.isArray(services)).toBe(true); - expect(services.length).toBeGreaterThan(0); - }); - - it("should find the registered test service", async () => { - const services = await client.discover({ limit: 50 }); - const ids = services.map((s) => s.serviceId ?? s.id ?? s.agentToolName ?? ""); - expect(ids).toContain(serviceId); - discoveredServiceId = - services.find((s) => [s.serviceId, s.id, s.agentToolName].includes(serviceId))?.serviceId ?? - services.find((s) => [s.serviceId, s.id, s.agentToolName].includes(serviceId))?.id ?? - serviceId; - expect(discoveredServiceId).toBe(serviceId); - }); - }); - - // ── Test 5: Invoke ──────────────────────────────────────────────────────── - - describe("5. Invocation (end-to-end settlement)", () => { - let invocationResult: InvocationResult; - - beforeAll(async () => { - // Snapshot balance before any invocations - const bal = await ownerAuth.getBalance(); - balanceBeforeInvocations = Number(bal.consumerAvailableBalance ?? bal.ownerBalance ?? 0); - }); - - it("should invoke service and return settled result", async () => { - invocationResult = await client.invoke( - discoveredServiceId, - { prompt: "ts-sdk e2e automated test" }, - { - costUsdc: SERVICE_PRICE_USDC, - idempotencyKey: `ts-sdk-e2e-${SESSION_ID}`, - pollTimeoutMs: 60_000, - pollIntervalMs: 1_000, - } - ); - - expect(invocationResult.invocationId).toBeTruthy(); - expect(["SUCCEEDED", "SETTLED"]).toContain(invocationResult.status); - }, 90_000); - - it("should have deducted USDC (chargedUsdc > 0)", () => { - expect(invocationResult.chargedUsdc).toBeGreaterThan(0); - }); - - it("should have a result payload from provider", () => { - // provider returns { result: "ts-sdk e2e mock response" } - expect(invocationResult.result).toBeTruthy(); - }); - }); + const result = await client.invoke( + requiredEnv("SYNAPSE_STAGING_SERVICE_ID"), + { prompt: "typescript sdk staging e2e" }, + { + costUsdc: requiredEnv("SYNAPSE_STAGING_SERVICE_PRICE_USDC"), + idempotencyKey: `ts-staging-e2e-${uuidv4()}`, + pollTimeoutMs: 60_000, + } + ); - // ── Test 6: Get Invocation Receipt ──────────────────────────────────────── + expect(result.invocationId).toBeTruthy(); + expect(["SUCCEEDED", "SETTLED"]).toContain(result.status); - describe("6. Invocation Receipt", () => { - let savedInvocationId: string; + const receipt = await client.getInvocation(result.invocationId); + expect(receipt.invocationId).toBe(result.invocationId); + expect(["SUCCEEDED", "SETTLED"]).toContain(receipt.status); + }, 90_000); - beforeAll(async () => { - // Run a second invocation to test receipt retrieval - const result = await client.invoke( - serviceId, - { prompt: "receipt check" }, - { - costUsdc: SERVICE_PRICE_USDC, - idempotencyKey: `ts-sdk-receipt-${SESSION_ID}`, - pollTimeoutMs: 60_000, - } - ); - savedInvocationId = result.invocationId; - }, 90_000); + test("token-metered LLM invoke omits costUsdc and returns billing metadata", async () => { + const serviceId = process.env.SYNAPSE_STAGING_LLM_SERVICE_ID?.trim(); + if (!serviceId) { + console.warn("Skipping optional LLM staging e2e: SYNAPSE_STAGING_LLM_SERVICE_ID is not set"); + return; + } - it("should retrieve the invocation receipt by ID", async () => { - const receipt = await client.getInvocation(savedInvocationId); - expect(receipt.invocationId).toBe(savedInvocationId); - expect(["SUCCEEDED", "SETTLED"]).toContain(receipt.status); + const client = new SynapseClient({ + credential: requiredEnv("SYNAPSE_AGENT_KEY"), + environment: "staging", }); - }); - describe("7. Direct known serviceId call", () => { - it("should invoke directly with a known serviceId and expected price", async () => { - const result = await client.invoke( - serviceId, - { prompt: "direct known service id path" }, - { - costUsdc: SERVICE_PRICE_USDC, - idempotencyKey: `ts-sdk-direct-${SESSION_ID}`, - pollTimeoutMs: 60_000, - } - ); - - expect(["SUCCEEDED", "SETTLED"]).toContain(result.status); - expect(result.chargedUsdc).toBeGreaterThan(0); - }, 90_000); - }); - - // ── Test 8: Balance after invoke ────────────────────────────────────────── + const result = await client.invokeLlm( + serviceId, + { messages: [{ role: "user", content: "hello from typescript staging e2e" }] }, + { + maxCostUsdc: process.env.SYNAPSE_STAGING_LLM_MAX_COST_USDC?.trim() ?? "0.010000", + idempotencyKey: `ts-staging-llm-e2e-${uuidv4()}`, + pollTimeoutMs: 60_000, + } + ); - describe("8. Balance After Settlement", () => { - it("should show reduced consumer balance after invocations", async () => { - const balance = await ownerAuth.getBalance(); - const available = Number(balance.consumerAvailableBalance ?? balance.ownerBalance ?? 0); - // Balance must have decreased from pre-invocation snapshot - expect(available).toBeLessThan(balanceBeforeInvocations); - expect(available).toBeGreaterThanOrEqual(0); - }); - }); + expect(result.invocationId).toBeTruthy(); + expect(["SUCCEEDED", "SETTLED"]).toContain(result.status); + expect(result.synapse).toBeTruthy(); + }, 90_000); }); diff --git a/typescript/tests/e2e/new-consumer.test.ts b/typescript/tests/e2e/new-consumer.test.ts index 913a6ec..f836dc9 100644 --- a/typescript/tests/e2e/new-consumer.test.ts +++ b/typescript/tests/e2e/new-consumer.test.ts @@ -1,464 +1,43 @@ /** - * Synapse TypeScript SDK — New Consumer E2E Test + * Synapse TypeScript SDK — Staging Agent Key E2E * - * 完整的"新用户冷启动"链路验证: - * 创建空钱包 → 链上注入 ETH + USDC → 链上充值 → - * 网关认证 → 充值确认 → Credential 颁发 → - * 服务发现 → 单步调用 → 收据 → 余额验证 - * - * 与 consumer.test.ts 的区别: - * - 使用 Wallet.createRandom() 生成全新 EOA(不依赖 Hardhat 预充值账户) - * - Deployer 转 ETH 供 gas,再 mint USDC 到新钱包 - * - 无跳过充值逻辑 — 每次都走完整链上充值流程 - * - * Prerequisites: - * 1. npx hardhat node (port 8545) - * 2. sh scripts/local/setup_local_env.sh - * 3. sh scripts/local/restart_gateway.sh (port 8000) - * - * Run: cd sdk/typescript && npm run test:new-consumer + * This replaces the retired wallet/chain flow. It validates that an existing + * staging Agent Key can discover, invoke, and read receipts against staging. */ -import { Wallet, JsonRpcProvider, Contract, parseUnits, parseEther } from "ethers"; import { v4 as uuidv4 } from "uuid"; -import * as fs from "fs"; -import * as path from "path"; -import * as http from "http"; -import { SynapseAuth } from "../../src/auth"; import { SynapseClient } from "../../src/client"; -import { InvocationResult } from "../../src/types"; - -// ── Config ──────────────────────────────────────────────────────────────────── - -const GATEWAY_URL = process.env.SYNAPSE_GATEWAY ?? "http://127.0.0.1:8000"; -const RPC_URL = process.env.RPC_URL ?? "http://127.0.0.1:8545"; -const DEPOSIT_USDC = Number(process.env.DEPOSIT_USDC ?? "10"); -const MOCK_PROVIDER_PORT = 9299; - -// Hardhat default keys -const DEPLOYER_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; -const PROVIDER_KEY = "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ea870594801966b8ea0ec4f"; - -const REPO_ROOT = path.resolve(__dirname, "../../../../"); -const CONTRACT_CONFIG_PATH = path.join(REPO_ROOT, "apps/frontend/src/contract-config.json"); -const MOCK_USDC_ABI_PATH = path.join(REPO_ROOT, "apps/frontend/src/MockUSDCABI.json"); -const SYNAPSE_CORE_ABI_PATH = path.join(REPO_ROOT, "apps/frontend/src/SynapseCoreABI.json"); - -const SESSION_ID = uuidv4().replace(/-/g, "").slice(0, 8); -const SERVICE_PRICE_USDC = 0.001; -const SERVICE_NAME = `nc_e2e_svc_${SESSION_ID}`; -const CRED_NAME = `nc-cred-${SESSION_ID}`; - -// ── Shared state ────────────────────────────────────────────────────────────── - -let freshAuth: SynapseAuth; -let providerAuth: SynapseAuth; -let client: SynapseClient; -let agentToken: string; -let serviceId: string; -let discoveredServiceId: string; -let mockServer: http.Server; -let balanceBeforeInvocations: number; - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -async function startMockProvider(): Promise { - return new Promise((resolve) => { - mockServer = http.createServer((req, res) => { - if (req.method === "GET") { - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ status: "healthy" })); - } else if (req.method === "POST") { - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ result: "new-consumer e2e mock response" })); - } else { - res.writeHead(405); - res.end(); - } - }); - mockServer.listen(MOCK_PROVIDER_PORT, "127.0.0.1", () => { - resolve(`http://127.0.0.1:${MOCK_PROVIDER_PORT}`); - }); - }); -} - -function loadContracts(): { MockUSDC: string; SynapseCore: string } { - const raw = fs.readFileSync(CONTRACT_CONFIG_PATH, "utf8"); - return JSON.parse(raw); -} - -async function doFetch( - url: string, - init: { method?: string; headers?: Record; body?: string } = {} -): Promise> { - const resp = await fetch(url, { - ...init, - headers: { "Content-Type": "application/json", ...(init.headers ?? {}) }, - }); - const text = await resp.text(); - let data: unknown; - try { - data = JSON.parse(text); - } catch { - data = { raw: text }; - } - if (!resp.ok) throw new Error(`HTTP ${resp.status} ${url}: ${text.slice(0, 300)}`); - return data as Record; -} - -/** - * Funds the fresh wallet on-chain and submits the deposit to SynapseCore. - * Returns the on-chain tx hash. - */ -async function fundAndDeposit( - rpcProvider: JsonRpcProvider, - freshWallet: Wallet, - deployerWallet: Wallet, - amountUsdc: number -): Promise { - const config = loadContracts(); - const usdcAbi = JSON.parse(fs.readFileSync(MOCK_USDC_ABI_PATH, "utf8")); - const coreAbi = JSON.parse(fs.readFileSync(SYNAPSE_CORE_ABI_PATH, "utf8")); - - const usdc = new Contract(config.MockUSDC, usdcAbi, deployerWallet); - const core = new Contract(config.SynapseCore, coreAbi, freshWallet); - - const decimals = Number(await usdc.decimals()); - const amountWei = parseUnits(String(amountUsdc), decimals); - - // Fetch deployer's current pending nonce ONCE and manage manually to - // avoid Hardhat's stale-nonce race condition between rapid sequential txs. - let deployerNonce = await rpcProvider.getTransactionCount(deployerWallet.address, "pending"); - - // (a) Send ETH so fresh wallet can pay gas (0.5 ETH = plenty) - console.log(` → Sending ETH to fresh wallet ${freshWallet.address}`); - const ethTx = await deployerWallet.sendTransaction({ - to: freshWallet.address, - value: parseEther("0.5"), - nonce: deployerNonce++, - }); - await ethTx.wait(); - - // (b) Mint USDC to fresh wallet (explicit nonce to avoid stale-nonce race) - console.log(` → Minting ${amountUsdc} USDC to fresh wallet`); - const mintTx = await (usdc as Contract).mint(freshWallet.address, amountWei, { nonce: deployerNonce++ }); - await mintTx.wait(); - - // (c) Verify USDC balance - const usdcBal = (await (usdc as Contract).balanceOf(freshWallet.address)) as bigint; - console.log(` → USDC balance: ${usdcBal.toString()} (raw)`); - if (usdcBal === 0n) throw new Error("Mint failed — USDC balance is still 0"); - - // Fetch fresh wallet's pending nonce once and manage manually (same stale-nonce - // race condition as deployer: Hardhat auto-mine doesn't always propagate nonce - // synchronously between consecutive contract calls through different signer refs) - let freshNonce = await rpcProvider.getTransactionCount(freshWallet.address, "pending"); - - // (d) Approve SynapseCore - console.log(` → Approving SynapseCore for ${amountUsdc} USDC`); - const approveTx = await (usdc.connect(freshWallet) as Contract).approve(config.SynapseCore, amountWei, { - nonce: freshNonce++, - }); - await approveTx.wait(); - - // (e) On-chain deposit - console.log(` → Depositing ${amountUsdc} USDC to SynapseCore`); - const depositTx = await core.deposit(amountWei, { nonce: freshNonce++ }); - const receipt = await depositTx.wait(); - const txHash: string = receipt.hash; - console.log(` → Deposit tx: ${txHash}`); - return txHash.startsWith("0x") ? txHash : `0x${txHash}`; -} -async function registerTestService( - providerAddress: string, - mockServiceUrl: string, - providerToken: string -): Promise { - const body = { - agentToolName: SERVICE_NAME, - serviceName: `NC E2E Service ${SESSION_ID}`, - role: "Provider", - status: "active", - isActive: true, - pricing: { amount: String(SERVICE_PRICE_USDC), currency: "USDC" }, - summary: "New-Consumer SDK automated e2e integration test service", - tags: ["nc-sdk", "e2e", "test"], - auth: { type: "gateway_signed" }, - invoke: { - method: "POST", - targets: [{ url: mockServiceUrl }], - request: { body: { type: "object", properties: { prompt: { type: "string" } } } }, - response: { body: { type: "object", properties: { result: { type: "string" } } } }, - }, - healthCheck: { - path: "/health", - method: "GET", - timeoutMs: 3000, - successCodes: [200], - healthyThreshold: 1, - unhealthyThreshold: 3, - }, - payoutAccount: { - payoutAddress: providerAddress.toLowerCase(), - chainId: 31337, - settlementCurrency: "USDC", - }, - providerProfile: { displayName: `NC E2E Provider ${SESSION_ID}` }, - governance: { termsAccepted: true, riskAcknowledged: true }, - }; +const runStagingE2E = process.env.RUN_STAGING_E2E === "1"; +const describeStaging = runStagingE2E ? describe : describe.skip; - const resp = await doFetch(`${GATEWAY_URL}/api/v1/services`, { - method: "POST", - headers: { Authorization: `Bearer ${providerToken}` }, - body: JSON.stringify(body), - }); - - const svcId = - (resp["serviceId"] as string) || - (resp["id"] as string) || - (resp["service_id"] as string) || - ((resp["service"] as Record)?.["serviceId"] as string) || - ((resp["service"] as Record)?.["id"] as string) || - SERVICE_NAME; - - return svcId; +function requiredEnv(name: string): string { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`${name} is required for staging e2e`); + return value; } -// ── Test Suite ──────────────────────────────────────────────────────────────── - -describe("Synapse TS SDK — New Consumer Cold-Start E2E", () => { - // ── beforeAll: chain setup + service registration ────────────────────────── - - beforeAll(async () => { - console.log(`\n[setup] Session: ${SESSION_ID}`); - - // 1. Start mock HTTP server for provider - const mockServiceUrl = await startMockProvider(); - console.log(`[setup] Mock provider: ${mockServiceUrl}`); - - // 2. Ethers setup - const rpcProvider = new JsonRpcProvider(RPC_URL); - const deployerWallet = new Wallet(DEPLOYER_KEY, rpcProvider); - const providerWallet = new Wallet(PROVIDER_KEY, rpcProvider); - // createRandom() returns HDNodeWallet in ethers v6; extract privateKey → Wallet - const freshPrivKey = Wallet.createRandom().privateKey; - const freshWallet = new Wallet(freshPrivKey, rpcProvider); - - console.log(`[setup] Fresh wallet: ${freshWallet.address}`); - console.log(`[setup] Provider: ${providerWallet.address}`); - - // 3. Create SynapseAuth for fresh wallet - freshAuth = SynapseAuth.fromWallet(freshWallet, { gatewayUrl: GATEWAY_URL }); - providerAuth = SynapseAuth.fromWallet(providerWallet, { gatewayUrl: GATEWAY_URL }); - - // 4. Fund fresh wallet + on-chain deposit - const txHash = await fundAndDeposit(rpcProvider, freshWallet, deployerWallet, DEPOSIT_USDC); - - // 5. Authenticate fresh wallet with gateway (JWT) - const token = await freshAuth.getToken(); - expect(token).toBeTruthy(); - - // 6. Register deposit intent - const intentResp = await freshAuth.registerDepositIntent(txHash, DEPOSIT_USDC); - console.log(`[setup] Intent response:`, JSON.stringify(intentResp)); - expect(intentResp.status).toBe("success"); - - const intentObj = intentResp.intent as Record; - const intentId = String(intentObj["id"] || intentObj["intentId"] || intentObj["depositIntentId"] || "").trim(); - const eventKey = String(intentObj["eventKey"] || intentObj["event_key"] || txHash).trim(); - - expect(intentId).toBeTruthy(); - console.log(`[setup] Intent ID: ${intentId}, eventKey: ${eventKey}`); - - // 7. Confirm deposit - await freshAuth.confirmDeposit(intentId, eventKey); - console.log(`[setup] Deposit confirmed`); - - // Allow indexer to credit - await new Promise((r) => setTimeout(r, 2000)); - - // 8. Register test service via provider - const providerToken = await providerAuth.getToken(); - serviceId = await registerTestService(providerWallet.address, mockServiceUrl, providerToken); - console.log(`[setup] Service ID: ${serviceId}`); - expect(serviceId).toBeTruthy(); - - // Allow health-check to pass - await new Promise((r) => setTimeout(r, 2000)); - }, 180_000); - - afterAll(() => { - if (mockServer) mockServer.close(); - }); - - // ── Test 1: Authentication ──────────────────────────────────────────────── - - describe("1. Authentication", () => { - it("should authenticate fresh wallet and return JWT", async () => { - const token = await freshAuth.getToken(); - expect(typeof token).toBe("string"); - expect(token.length).toBeGreaterThan(20); - }); - - it("should cache token on repeated calls", async () => { - const t1 = await freshAuth.getToken(); - const t2 = await freshAuth.getToken(); - expect(t1).toBe(t2); - }); - }); - - // ── Test 2: Balance after deposit ───────────────────────────────────────── - - describe("2. Balance After Deposit", () => { - it("should show consumerAvailableBalance >= DEPOSIT_USDC after on-chain deposit", async () => { - const balance = await freshAuth.getBalance(); - const available = Number(balance.consumerAvailableBalance ?? balance.ownerBalance ?? 0); - console.log(` Balance: ${JSON.stringify(balance)}`); - expect(available).toBeGreaterThanOrEqual(DEPOSIT_USDC * 0.99); // allow minor rounding - }); - }); - - // ── Test 3: Issue Credential ────────────────────────────────────────────── - - describe("3. Issue Agent Credential", () => { - it("should issue credential and return agentToken", async () => { - const result = await freshAuth.issueCredential({ - name: CRED_NAME, - maxCalls: 100, - creditLimit: 5.0, - rpm: 60, - }); - expect(result.token).toBeTruthy(); - expect(result.token.length).toBeGreaterThan(10); - expect(result.credential.id).toBeTruthy(); - - agentToken = result.token; - client = new SynapseClient({ credential: agentToken, gatewayUrl: GATEWAY_URL }); - console.log(` Credential ID: ${result.credential.id}`); - }); - - it("should list credentials and include the new credential", async () => { - const creds = await freshAuth.listCredentials(); - const names = creds.map((c) => c.name); - expect(names).toContain(CRED_NAME); - }); - }); - - // ── Test 4: Service Discovery ───────────────────────────────────────────── - - describe("4. Service Discovery", () => { - it("should return non-empty service list", async () => { - const services = await client.discover({ limit: 20 }); - expect(Array.isArray(services)).toBe(true); - expect(services.length).toBeGreaterThan(0); - }); - - it("should find the registered test service", async () => { - const services = await client.discover({ limit: 50 }); - const ids = services.map((s) => s.serviceId ?? s.id ?? s.agentToolName ?? ""); - console.log(` Discovered IDs (first 5): ${ids.slice(0, 5).join(", ")}`); - expect(ids).toContain(serviceId); - discoveredServiceId = - services.find((s) => [s.serviceId, s.id, s.agentToolName].includes(serviceId))?.serviceId ?? - services.find((s) => [s.serviceId, s.id, s.agentToolName].includes(serviceId))?.id ?? - serviceId; - expect(discoveredServiceId).toBe(serviceId); - }); - }); - - // ── Test 5: Invoke ──────────────────────────────────────────────────────── - - describe("5. Invocation (end-to-end settlement)", () => { - let invResult: InvocationResult; - - beforeAll(async () => { - const bal = await freshAuth.getBalance(); - balanceBeforeInvocations = Number(bal.consumerAvailableBalance ?? bal.ownerBalance ?? 0); - console.log(` Balance before invocations: ${balanceBeforeInvocations}`); - }); - - it("should invoke service and reach SUCCEEDED/SETTLED status", async () => { - invResult = await client.invoke( - discoveredServiceId, - { prompt: "new-consumer e2e automated test" }, - { - costUsdc: SERVICE_PRICE_USDC, - idempotencyKey: `nc-e2e-${SESSION_ID}`, - pollTimeoutMs: 60_000, - pollIntervalMs: 1_000, - } - ); - - console.log( - ` Invocation: id=${invResult.invocationId} status=${invResult.status} charged=${invResult.chargedUsdc}` - ); - expect(invResult.invocationId).toBeTruthy(); - expect(["SUCCEEDED", "SETTLED"]).toContain(invResult.status); - }, 90_000); - - it("should deduct USDC (chargedUsdc > 0)", () => { - expect(invResult.chargedUsdc).toBeGreaterThan(0); +describeStaging("Synapse TS SDK — Staging Agent Key E2E", () => { + test("discovers staging services and invokes a known fixed-price service", async () => { + const client = new SynapseClient({ + credential: requiredEnv("SYNAPSE_AGENT_KEY"), + environment: "staging", }); - it("should return a result payload from provider", () => { - expect(invResult.result).toBeTruthy(); - }); - }); - - // ── Test 6: Invocation Receipt ──────────────────────────────────────────── - - describe("6. Invocation Receipt", () => { - let receiptInvocationId: string; - - beforeAll(async () => { - const r = await client.invoke( - serviceId, - { prompt: "receipt verification call" }, - { - costUsdc: SERVICE_PRICE_USDC, - idempotencyKey: `nc-receipt-${SESSION_ID}`, - pollTimeoutMs: 60_000, - } - ); - receiptInvocationId = r.invocationId; - }, 90_000); - - it("should retrieve receipt by invocation ID", async () => { - const receipt = await client.getInvocation(receiptInvocationId); - expect(receipt.invocationId).toBe(receiptInvocationId); - expect(["SUCCEEDED", "SETTLED"]).toContain(receipt.status); - }); - }); + const services = await client.discover({ limit: 20 }); + expect(Array.isArray(services)).toBe(true); - // ── Test 7: Direct known serviceId path ───────────────────────────────── - - describe("7. Direct known serviceId path", () => { - it("should invoke directly when serviceId and expected price are already known", async () => { - const result = await client.invoke( - serviceId, - { prompt: "direct path from known service id" }, - { - costUsdc: SERVICE_PRICE_USDC, - idempotencyKey: `nc-direct-${SESSION_ID}`, - pollTimeoutMs: 60_000, - } - ); - - expect(["SUCCEEDED", "SETTLED"]).toContain(result.status); - expect(result.chargedUsdc).toBeGreaterThan(0); - }, 90_000); - }); - - // ── Test 8: Post-settlement Balance ────────────────────────────────────── + const result = await client.invoke( + requiredEnv("SYNAPSE_STAGING_SERVICE_ID"), + { prompt: "typescript sdk staging agent key e2e" }, + { + costUsdc: requiredEnv("SYNAPSE_STAGING_SERVICE_PRICE_USDC"), + idempotencyKey: `ts-staging-agent-e2e-${uuidv4()}`, + pollTimeoutMs: 60_000, + } + ); - describe("8. Post-Settlement Balance", () => { - it("should show reduced consumer balance after invocations", async () => { - const balance = await freshAuth.getBalance(); - const available = Number(balance.consumerAvailableBalance ?? balance.ownerBalance ?? 0); - console.log(` Balance after invocations: ${available} (was ${balanceBeforeInvocations})`); - expect(available).toBeLessThan(balanceBeforeInvocations); - expect(available).toBeGreaterThanOrEqual(0); - }); - }); + expect(result.invocationId).toBeTruthy(); + expect(["SUCCEEDED", "SETTLED"]).toContain(result.status); + }, 90_000); }); diff --git a/typescript/tests/e2e/provider.test.ts b/typescript/tests/e2e/provider.test.ts index 408c1d8..ded30d8 100644 --- a/typescript/tests/e2e/provider.test.ts +++ b/typescript/tests/e2e/provider.test.ts @@ -1,59 +1,40 @@ /** - * Synapse TypeScript SDK — Provider Onboarding E2E Test + * Synapse TypeScript SDK — Staging Provider E2E * - * 冷启动 Provider 控制面链路: - * 创建钱包 → challenge/sign/verify → issue provider secret - * → register provider service → read provider service status + * Skipped by default. Set RUN_STAGING_PROVIDER_E2E=1 and provide a real + * staging provider private key plus a public HTTPS provider endpoint. */ import { Wallet } from "ethers"; -import * as http from "http"; import { v4 as uuidv4 } from "uuid"; import { SynapseAuth } from "../../src/auth"; -const GATEWAY_URL = process.env.SYNAPSE_GATEWAY ?? "http://127.0.0.1:8000"; -const MOCK_PROVIDER_PORT = 9498; -const SESSION_ID = uuidv4().replace(/-/g, "").slice(0, 8); +const runStagingProviderE2E = process.env.RUN_STAGING_PROVIDER_E2E === "1"; +const describeStagingProvider = runStagingProviderE2E ? describe : describe.skip; -let mockServer: http.Server; -let providerAuth: SynapseAuth; -let providerServiceId: string; - -async function startMockProvider(): Promise { - return new Promise((resolve) => { - mockServer = http.createServer((req, res) => { - if (req.method === "GET") { - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ status: "healthy" })); - } else if (req.method === "POST") { - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ result: "ts provider-sdk e2e mock response" })); - } else { - res.writeHead(405); - res.end(); - } - }); - mockServer.listen(MOCK_PROVIDER_PORT, "127.0.0.1", () => { - resolve(`http://127.0.0.1:${MOCK_PROVIDER_PORT}`); - }); - }); +function requiredEnv(name: string): string { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`${name} is required for staging provider e2e`); + return value; } -describe("Synapse TS SDK — Provider Onboarding E2E", () => { - beforeAll(async () => { - const mockServiceUrl = await startMockProvider(); - const freshProvider = Wallet.createRandom(); +describeStagingProvider("Synapse TS SDK — Staging Provider E2E", () => { + test("registers a provider service and reads lifecycle status", async () => { + const endpointUrl = requiredEnv("SYNAPSE_PROVIDER_ENDPOINT_URL"); + if (!endpointUrl.startsWith("https://")) { + throw new Error("SYNAPSE_PROVIDER_ENDPOINT_URL must be a public HTTPS endpoint"); + } - providerAuth = SynapseAuth.fromWallet(freshProvider, { - gatewayUrl: GATEWAY_URL, + const sessionId = uuidv4().replace(/-/g, "").slice(0, 8); + const providerAuth = SynapseAuth.fromWallet(new Wallet(requiredEnv("SYNAPSE_PROVIDER_PRIVATE_KEY")), { + environment: "staging", }); const token = await providerAuth.getToken(); - expect(typeof token).toBe("string"); expect(token.length).toBeGreaterThan(20); const issued = await providerAuth.issueProviderSecret({ - name: `ts-provider-secret-${SESSION_ID}`, + name: `ts-provider-secret-${sessionId}`, rpm: 180, creditLimit: 25, resetInterval: "monthly", @@ -62,38 +43,19 @@ describe("Synapse TS SDK — Provider Onboarding E2E", () => { expect(String(issued.secret.secretKey ?? "")).toMatch(/^agt_/); const registered = await providerAuth.registerProviderService({ - serviceName: `TS Provider OCR ${SESSION_ID}`, - endpointUrl: mockServiceUrl, + serviceName: `TS Provider Staging ${sessionId}`, + endpointUrl, basePriceUsdc: "0.002", - descriptionForModel: "Extract structured invoice fields for TypeScript provider onboarding e2e.", - providerDisplayName: `TS Provider ${SESSION_ID}`, - governanceNote: "typescript provider sdk e2e", + descriptionForModel: "Staging provider onboarding e2e service.", + providerDisplayName: `TS Provider ${sessionId}`, + governanceNote: "typescript provider sdk staging e2e", }); - expect(registered.status).toBe("success"); expect(registered.serviceId).toBeTruthy(); - providerServiceId = registered.serviceId; - }, 120_000); - afterAll(() => { - if (mockServer) mockServer.close(); - }); - - it("lists the newly issued provider secret", async () => { - const secrets = await providerAuth.listProviderSecrets(); - const names = secrets.map((secret) => secret.name); - expect(names).toContain(`ts-provider-secret-${SESSION_ID}`); - }); - - it("lists the newly registered provider service", async () => { const services = await providerAuth.listProviderServices(); - const serviceIds = services.map((service) => service.serviceId); - expect(serviceIds).toContain(providerServiceId); - }); + expect(services.map((service) => service.serviceId)).toContain(registered.serviceId); - it("returns lifecycle and runtime status for the provider service", async () => { - const status = await providerAuth.getProviderServiceStatus(providerServiceId); - expect(status.serviceId).toBe(providerServiceId); - expect(["active", "draft", "paused"]).toContain(status.lifecycleStatus); - expect(["healthy", "unknown", "degraded"]).toContain(String(status.health.overallStatus ?? "unknown")); - }); + const status = await providerAuth.getProviderServiceStatus(registered.serviceId); + expect(status.serviceId).toBe(registered.serviceId); + }, 120_000); }); diff --git a/typescript/tests/unit/auth.test.ts b/typescript/tests/unit/auth.test.ts index 95537e0..414dfe1 100644 --- a/typescript/tests/unit/auth.test.ts +++ b/typescript/tests/unit/auth.test.ts @@ -51,7 +51,7 @@ function authForTests(): SynapseAuth { address: "0xABCDEF", signMessage: async (message: string) => `signed:${message}`, }, - { environment: "local" } + { environment: "staging" } ); } @@ -74,14 +74,15 @@ test("public barrel exports SDK entrypoints", () => { expect(SynapseAuth).toBeDefined(); expect(SynapseClient).toBeDefined(); expect(SynapseProvider).toBeDefined(); - expect(resolveGatewayUrl({ environment: "local" })).toBe("http://127.0.0.1:8000"); + expect(resolveGatewayUrl({ environment: "staging" })).toBe("https://api-staging.synapse-network.ai"); + expect(() => resolveGatewayUrl({ environment: "local" as never })).toThrow("unsupported Synapse environment"); }); test("authenticate signs challenge, verifies wallet, and caches JWT", async () => { const signer = jest.fn(async (message: string) => `signature:${message}`); const calls = mockFetch(authHandshakeResponses("cached-jwt")); const auth = new SynapseAuth({ - environment: "local", + environment: "staging", signer, walletAddress: "0xABCDEF", }); @@ -213,7 +214,7 @@ test("registerProviderService validates input and builds provider service contra await expect( authForTests().registerProviderService({ serviceName: "", - endpointUrl: "http://provider.local/invoke", + endpointUrl: "https://provider.example.com/invoke", descriptionForModel: "Summarize text", basePriceUsdc: 0.01, }) @@ -229,7 +230,7 @@ test("registerProviderService validates input and builds provider service contra await expect( authForTests().registerProviderService({ serviceName: "Summarizer", - endpointUrl: "http://provider.local/invoke", + endpointUrl: "https://provider.example.com/invoke", descriptionForModel: "", basePriceUsdc: 0.01, }) @@ -243,7 +244,7 @@ test("registerProviderService validates input and builds provider service contra await expect( authForTests().registerProviderService({ serviceName: "Summarizer Pro", - endpointUrl: "http://provider.local/invoke", + endpointUrl: "https://provider.example.com/invoke", descriptionForModel: "Summarize text", basePriceUsdc: 0.05, tags: ["text"], diff --git a/typescript/tests/unit/client.test.ts b/typescript/tests/unit/client.test.ts index 9600feb..b394f78 100644 --- a/typescript/tests/unit/client.test.ts +++ b/typescript/tests/unit/client.test.ts @@ -53,7 +53,6 @@ test("resolveGatewayUrl defaults to staging public preview", () => { }); test("resolveGatewayUrl supports presets and explicit override", () => { - expect(resolveGatewayUrl({ environment: "local" })).toBe("http://127.0.0.1:8000"); expect(resolveGatewayUrl({ environment: "staging" })).toBe("https://api-staging.synapse-network.ai"); expect(resolveGatewayUrl({ environment: "prod" })).toBe("https://api.synapse-network.ai"); expect(resolveGatewayUrl({ environment: "prod", gatewayUrl: "https://gateway.example/" })).toBe( @@ -65,6 +64,10 @@ test("resolveGatewayUrl rejects invalid environment", () => { expect(() => resolveGatewayUrl({ environment: "preview" as never })).toThrow("unsupported Synapse environment"); }); +test("resolveGatewayUrl rejects removed local environment preset", () => { + expect(() => resolveGatewayUrl({ environment: "local" as never })).toThrow("unsupported Synapse environment"); +}); + test("SynapseAuth defaults to staging gateway", async () => { const urls: string[] = []; (globalThis as unknown as Record).fetch = jest.fn(async (url: string) => { @@ -112,7 +115,7 @@ test("invoke() calls /agent/invoke and returns InvocationResult", async () => { const client = new SynapseClient({ credential: "agt_test", - environment: "local", + environment: "staging", }); const result = await client.invoke("svc_test", { prompt: "hi" }, { costUsdc: 0.05, idempotencyKey: "key-1" }); @@ -163,6 +166,24 @@ test("invoke() sends correct body to /agent/invoke", async () => { expect((body["payload"] as Record)["body"]).toEqual({ text: "test" }); }); +test("invoke() accepts string costUsdc and preserves exact amount", async () => { + let capturedBody: unknown; + (globalThis as unknown as Record).fetch = jest.fn(async (_url: string, init?: RequestInit) => { + capturedBody = JSON.parse((init?.body as string) ?? "{}"); + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ invocationId: "inv_s", status: "SUCCEEDED", chargedUsdc: "0.050000" }), + } as Response; + }); + + const client = new SynapseClient({ credential: "agt_test" }); + await client.invoke("svc_2", { text: "test" }, { costUsdc: "0.050000", idempotencyKey: "ik-string" }); + + const body = capturedBody as Record; + expect(body["costUsdc"]).toBe("0.050000"); +}); + test("invokeLlm() sends maxCostUsdc without costUsdc and returns usage metadata", async () => { let capturedBody: Record = {}; (globalThis as unknown as Record).fetch = jest.fn(async (_url: string, init?: RequestInit) => { @@ -507,7 +528,7 @@ test("gateway health, invocation receipt alias, and empty discovery diagnostics }) as Response ); - const client = new SynapseClient({ credential: "agt_test", environment: "local" }); + const client = new SynapseClient({ credential: "agt_test", environment: "staging" }); await expect(client.checkGatewayHealth()).resolves.toEqual({ status: "ok" }); await expect(client.getInvocationReceipt("inv_1")).resolves.toMatchObject({ invocationId: "inv_1" }); expect(client.explainDiscoveryEmptyResult({ query: "quotes" })).toMatchObject({ query: "quotes" });