From 64e454dedbc6fd280e13129f8d61d9a379b9f759 Mon Sep 17 00:00:00 2001 From: cc-mac-mini Date: Tue, 28 Apr 2026 12:25:42 +0800 Subject: [PATCH 1/3] Add enforced PR quality gates --- docs/agent-map/index.json | 7 +- docs/plans/bootstrap-checklist.md | 37 +- docs/plans/refactor-watch.md | 32 +- docs/quality-gates.md | 40 + llms.txt | 3 + python/pyproject.toml | 24 +- python/synapse_client/__init__.py | 4 +- python/synapse_client/_auth_credentials.py | 241 ++ python/synapse_client/_auth_finance.py | 102 + .../synapse_client/_auth_provider_control.py | 368 +++ python/synapse_client/auth.py | 663 +---- python/synapse_client/client.py | 72 +- python/synapse_client/exceptions.py | 1 - python/synapse_client/models.py | 4 +- python/synapse_client/test/test_auth_unit.py | 356 +-- .../synapse_client/test/test_client_unit.py | 30 +- .../synapse_client/test/test_consumer_e2e.py | 23 +- .../synapse_client/test/test_provider_e2e.py | 1 - scripts/ci/pr_checks.sh | 1 + scripts/ci/python_checks.sh | 24 +- scripts/ci/security_checks.sh | 28 + scripts/ci/source_quality_checks.py | 86 + scripts/ci/typescript_checks.sh | 7 +- typescript/.jscpd.json | 17 + typescript/.prettierignore | 3 + typescript/.prettierrc.json | 4 + typescript/eslint.config.mjs | 28 + typescript/jest.config.js | 9 +- typescript/package-lock.json | 2266 ++++++++++++++++- typescript/package.json | 11 +- typescript/src/auth.ts | 396 +-- typescript/src/auth_credentials.ts | 64 + typescript/src/auth_provider_control.ts | 344 +++ typescript/src/client.ts | 272 +- typescript/src/config.ts | 10 +- typescript/src/http.ts | 60 + typescript/src/types.ts | 8 +- typescript/tests/e2e/consumer.test.ts | 36 +- typescript/tests/e2e/new-consumer.test.ts | 105 +- typescript/tests/e2e/provider.test.ts | 4 +- typescript/tests/unit/auth.test.ts | 153 +- typescript/tests/unit/client.test.ts | 298 ++- 42 files changed, 4606 insertions(+), 1636 deletions(-) create mode 100644 docs/quality-gates.md create mode 100644 python/synapse_client/_auth_credentials.py create mode 100644 python/synapse_client/_auth_finance.py create mode 100644 python/synapse_client/_auth_provider_control.py create mode 100644 scripts/ci/security_checks.sh create mode 100644 scripts/ci/source_quality_checks.py create mode 100644 typescript/.jscpd.json create mode 100644 typescript/.prettierignore create mode 100644 typescript/.prettierrc.json create mode 100644 typescript/eslint.config.mjs create mode 100644 typescript/src/auth_credentials.ts create mode 100644 typescript/src/auth_provider_control.ts create mode 100644 typescript/src/http.ts diff --git a/docs/agent-map/index.json b/docs/agent-map/index.json index e1c4593..26dbbf3 100644 --- a/docs/agent-map/index.json +++ b/docs/agent-map/index.json @@ -11,6 +11,7 @@ "SECURITY.md", "docs/sdk/README.md", "docs/sdk/capability_inventory.md", + "docs/quality-gates.md", "scripts/ci/pr_checks.sh" ], "domains": [ @@ -179,6 +180,9 @@ "scripts/ci/python_checks.sh", "scripts/ci/typescript_checks.sh", "scripts/ci/repo_hygiene_checks.sh", + "scripts/ci/security_checks.sh", + "scripts/ci/source_quality_checks.py", + "docs/quality-gates.md", "typescript/package.json" ], "supporting_files": [ @@ -189,7 +193,8 @@ "bash scripts/ci/pr_checks.sh" ], "notes": [ - "Local scripts and GitHub Actions must share the same entrypoint." + "Local scripts and GitHub Actions must share the same entrypoint.", + "Source files over 500 lines, high complexity, and duplicate code above threshold must fail PR CI." ] }, { diff --git a/docs/plans/bootstrap-checklist.md b/docs/plans/bootstrap-checklist.md index ca8df16..9f31336 100644 --- a/docs/plans/bootstrap-checklist.md +++ b/docs/plans/bootstrap-checklist.md @@ -2,27 +2,42 @@ - Project: `synapse-network-sdk` - Root: `/Users/cliff/workspace/agent/Synapse-Network-Sdk` -- Overall: `READY` +- Overall: `PARTIAL` ## Checklist -- [x] Core - Core status=HEALTHY (ok=7, warn=0, fail=0, info=0) +- [ ] Core - Core status=ATTENTION (ok=6, warn=0, fail=1, info=0) - [x] Planning - Planning status=HEALTHY (ok=2, warn=0, fail=0, info=0) - [x] Integration - Integration status=HEALTHY (ok=2, warn=0, fail=0, info=0) - [ ] Optional - Optional status=WATCH (ok=2, warn=5, fail=0, info=0) -- [x] Final verification - latest `amem doctor .` already reflects the current healthy state +- [ ] Final verification - re-run `amem doctor .` and confirm no remaining WARN / FAIL steps ## Action Sequence -1. Optional (recommended): Refactor flagged functions before adding more behavior, and add a short guiding comment when complex logic must remain in place. +1. Core (required): Re-run `amem profile-check .` and repair the missing profile-managed files. +2. Optional (recommended): Refactor flagged functions before adding more behavior, and add a short guiding comment when complex logic must remain in place. + +## Onboarding Runbook +### Step 1: Core / profile_consistency +- Priority: `required` +- Trigger: missing required file: tests +- Action: Repair missing or drifted profile-managed files before continuing onboarding. +- Command: `amem profile-check .` +- Verify with: `amem profile-check .` +- Next command: `amem doctor .` +- Safe To Auto Execute: `False` +- Approval Required: `True` +- Approval Reason: this step diagnoses drift but manual repair choices still require a human decision +- Done when: `amem doctor .` shows `[OK] profile_consistency`. + ## Group Health ### Core -- Summary: Core status=HEALTHY (ok=7, warn=0, fail=0, info=0) +- Summary: Core status=ATTENTION (ok=6, warn=0, fail=1, info=0) - [OK] `registry` registered as 'synapse-network-sdk' - [OK] `active` active=true - [OK] `root` /Users/cliff/workspace/agent/Synapse-Network-Sdk - [OK] `python3.12` /opt/homebrew/bin/python3.12 - [OK] `mcp_package` mcp import OK - [OK] `profile_manifest` applied profile 'python-service' -- [OK] `profile_consistency` profile 'python-service' consistency OK +- [FAIL] `profile_consistency` missing required file: tests ### Planning - Summary: Planning status=HEALTHY (ok=2, warn=0, fail=0, info=0) @@ -38,8 +53,8 @@ - Summary: Optional status=WATCH (ok=2, warn=5, fail=0, info=0) - [OK] `copilot_activation` Agents-Memory activation block present -> /Users/cliff/workspace/agent/Synapse-Network-Sdk/.github/copilot-instructions.md - [OK] `agents_read_order` AGENTS.md references current bridge and 8 managed standard(s) -- [WARN] `refactor_watch` python/examples/smoke_test.py::main high complexity (lines=101>40, locals=12>8, branches=5, missing_guiding_comment) -- [WARN] `refactor_watch` python/synapse_client/test/test_consumer_e2e.py::test_python_sdk_consumer_cold_start_e2e high complexity (lines=134>40, locals=30>8, missing_guiding_comment) -- [WARN] `refactor_watch` python/synapse_client/test/test_consumer_e2e.py::_fund_and_deposit high complexity (lines=57>40, locals=15>8, missing_guiding_comment) -- [WARN] `refactor_watch` python/synapse_client/test/test_consumer_e2e.py::test_python_sdk_credential_management_e2e high complexity (lines=75>40, locals=25>8) -- [WARN] `refactor_watch` python/synapse_client/auth.py::SynapseAuth.issue_credential high complexity (branches=7>5, lines=38, locals=8, missing_guiding_comment) +- [WARN] `refactor_watch` python/examples/consumer_wallet_to_invoke.py::main high complexity (lines=78>40, branches=7>5, locals=18>8, nesting=3, missing_guiding_comment) +- [WARN] `refactor_watch` python/examples/smoke_test.py::main high complexity (lines=104>40, branches=6>5, locals=13>8, missing_guiding_comment) +- [WARN] `refactor_watch` python/examples/consumer_call_provider.py::main high complexity (lines=42>40, locals=10>8, branches=4, missing_guiding_comment) +- [WARN] `refactor_watch` python/synapse_client/test/test_consumer_e2e.py::test_python_sdk_consumer_cold_start_e2e high complexity (lines=135>40, locals=30>8, missing_guiding_comment) +- [WARN] `refactor_watch` python/examples/provider_staging_onboarding.py::main high complexity (lines=46>40, locals=9>8, missing_guiding_comment) diff --git a/docs/plans/refactor-watch.md b/docs/plans/refactor-watch.md index 19a6fb2..06aacbe 100644 --- a/docs/plans/refactor-watch.md +++ b/docs/plans/refactor-watch.md @@ -22,26 +22,26 @@ Track Python functions that are already high-complexity or are approaching the c ## Hotspots -1. [WARN] `python/examples/smoke_test.py::main` line=376 metrics=(lines=101, branches=5, nesting=2, locals=12) +1. [WARN] `python/examples/consumer_wallet_to_invoke.py::main` line=74 metrics=(lines=78, branches=7, nesting=3, locals=18) + - token: `hotspot-664c48761084` + - issues: `lines=78>40, branches=7>5, locals=18>8, nesting=3, missing_guiding_comment` + - bundle command: `amem refactor-bundle . --token hotspot-664c48761084` +2. [WARN] `python/examples/smoke_test.py::main` line=345 metrics=(lines=104, branches=6, nesting=2, locals=13) - token: `hotspot-8ec9b16a0f08` - - issues: `lines=101>40, locals=12>8, branches=5, missing_guiding_comment` + - issues: `lines=104>40, branches=6>5, locals=13>8, missing_guiding_comment` - bundle command: `amem refactor-bundle . --token hotspot-8ec9b16a0f08` -2. [WARN] `python/synapse_client/test/test_consumer_e2e.py::test_python_sdk_consumer_cold_start_e2e` line=175 metrics=(lines=134, branches=0, nesting=0, locals=30) +3. [WARN] `python/examples/consumer_call_provider.py::main` line=116 metrics=(lines=42, branches=4, nesting=2, locals=10) + - token: `hotspot-c6270384bbbf` + - issues: `lines=42>40, locals=10>8, branches=4, missing_guiding_comment` + - bundle command: `amem refactor-bundle . --token hotspot-c6270384bbbf` +4. [WARN] `python/synapse_client/test/test_consumer_e2e.py::test_python_sdk_consumer_cold_start_e2e` line=174 metrics=(lines=135, branches=0, nesting=0, locals=30) - token: `hotspot-402833792f1b` - - issues: `lines=134>40, locals=30>8, missing_guiding_comment` + - issues: `lines=135>40, locals=30>8, missing_guiding_comment` - bundle command: `amem refactor-bundle . --token hotspot-402833792f1b` -3. [WARN] `python/synapse_client/test/test_consumer_e2e.py::_fund_and_deposit` line=77 metrics=(lines=57, branches=0, nesting=0, locals=15) - - token: `hotspot-7f5cb8d7d075` - - issues: `lines=57>40, locals=15>8, missing_guiding_comment` - - bundle command: `amem refactor-bundle . --token hotspot-7f5cb8d7d075` -4. [WARN] `python/synapse_client/test/test_consumer_e2e.py::test_python_sdk_credential_management_e2e` line=327 metrics=(lines=75, branches=1, nesting=1, locals=25) - - token: `hotspot-26c3a2e1a320` - - issues: `lines=75>40, locals=25>8` - - bundle command: `amem refactor-bundle . --token hotspot-26c3a2e1a320` -5. [WARN] `python/synapse_client/auth.py::SynapseAuth.issue_credential` line=198 metrics=(lines=38, branches=7, nesting=2, locals=8) - - token: `hotspot-4ede4034d9f9` - - issues: `branches=7>5, lines=38, locals=8, missing_guiding_comment` - - bundle command: `amem refactor-bundle . --token hotspot-4ede4034d9f9` +5. [WARN] `python/examples/provider_staging_onboarding.py::main` line=91 metrics=(lines=46, branches=2, nesting=1, locals=9) + - token: `hotspot-41fb390a7399` + - issues: `lines=46>40, locals=9>8, missing_guiding_comment` + - bundle command: `amem refactor-bundle . --token hotspot-41fb390a7399` ## Suggested Action diff --git a/docs/quality-gates.md b/docs/quality-gates.md new file mode 100644 index 0000000..39cebc8 --- /dev/null +++ b/docs/quality-gates.md @@ -0,0 +1,40 @@ +--- +created_at: 2026-04-28 +updated_at: 2026-04-28 +doc_status: active +--- + +# SDK Quality Gates + +This repository uses `bash scripts/ci/pr_checks.sh` as the single PR quality gate entrypoint. GitHub Actions and local validation must keep using that same script so PR behavior matches developer machines. + +## Required Checks + +- Repo hygiene: retired gateway domains, deprecated brand wording, README language split, staging defaults, and sensitive tracked filenames. +- Python: Ruff format, Ruff lint, Mypy, unit tests with coverage, source size checks, Radon cyclomatic complexity, and package build. +- TypeScript: Prettier, ESLint, `tsc --noEmit`, package build, unit tests, coverage, and duplicate-code scanning. +- Security: Bandit for Python source and `npm audit --omit=dev --audit-level=high` for production npm dependencies. + +## Thresholds + +- Source files must be `500` lines or fewer. +- Test files may be up to `700` lines while the suite is being decomposed. +- Python functions may have at most `40` effective logic lines. +- Python Radon complexity must stay at grade `A` or `B`; grade `C` or worse fails CI. +- TypeScript ESLint complexity must be `8` or lower. +- TypeScript functions may have at most `60` effective lines. +- Python coverage must be at least `80%`. +- TypeScript global lines, branches, functions, and statements coverage must each be at least `80%`. +- Duplicate code across `python/synapse_client` and `typescript/src` must stay at or below `3%` with a `50` token minimum clone size. + +## Refactor Rules + +- If a source file exceeds `500` lines, split it before adding more behavior. +- If logic appears in three places, extract a shared helper or module. +- If a function exceeds `40` Python effective lines, `60` TypeScript lines, or the complexity threshold, split pure decisions from I/O and orchestration. +- Bug fixes need regression tests. New observable behavior needs unit tests. +- Public SDK API changes must update docs and `docs/sdk/capability_inventory.md` when the implementation state changes. + +## GitHub Enforcement + +The `main` branch must require the PR CI status check named `SDK PR quality gates`, require the branch to be up to date, block direct pushes, and require at least one approving review before merge. diff --git a/llms.txt b/llms.txt index 696f8a3..71788a9 100644 --- a/llms.txt +++ b/llms.txt @@ -34,6 +34,7 @@ README 语言结构: - 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 @@ -50,6 +51,7 @@ README 语言结构: - 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 @@ -71,6 +73,7 @@ README 语言结构: - 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` ## Boundaries diff --git a/python/pyproject.toml b/python/pyproject.toml index 40f4a0a..74883ff 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -22,7 +22,11 @@ dev = [ "pytest>=9.0.0", "build>=1.4.0", "eth-account>=0.13.0", - "web3>=7.0.0" + "web3>=7.0.0", + "pytest-cov>=7.0.0", + "ruff>=0.15.0", + "mypy>=1.20.0", + "radon>=6.0.0" ] [tool.setuptools] @@ -36,3 +40,21 @@ exclude = ["provider_api*", "examples*", "venv*", "__pycache__*", "synapse_clien markers = [ "e2e: marks live end-to-end gateway + chain integration tests", ] + +[tool.ruff] +line-length = 120 +target-version = "py39" +extend-exclude = ["python/.venv", "python/build", "python/dist"] + +[tool.ruff.lint] +select = ["E", "F", "I"] + +[tool.mypy] +python_version = "3.9" +ignore_missing_imports = true +allow_untyped_defs = true +no_implicit_optional = false +warn_return_any = false +warn_unused_ignores = true +exclude = ["python/synapse_client/test/"] +disable_error_code = ["attr-defined", "override", "arg-type"] diff --git a/python/synapse_client/__init__.py b/python/synapse_client/__init__.py index 1f75d01..b61048f 100644 --- a/python/synapse_client/__init__.py +++ b/python/synapse_client/__init__.py @@ -1,7 +1,6 @@ from .auth import SynapseAuth from .client import AgentWallet, SynapseClient from .config import DEFAULT_ENVIRONMENT, GATEWAY_URLS, resolve_gateway_url -from .provider import SynapseProvider from .exceptions import ( AuthenticationError, BudgetExceededError, @@ -33,6 +32,7 @@ TokenResponse, UpdateCredentialResult, ) +from .provider import SynapseProvider __all__ = [ "AgentWallet", @@ -56,6 +56,7 @@ "DiscoveryResponse", "InvocationResponse", "ChallengeResponse", + "CredentialStatusResult", "TokenResponse", "AgentCredential", "IssueCredentialResult", @@ -67,4 +68,5 @@ "BalanceSummary", "DepositIntentResult", "DepositConfirmResult", + "UpdateCredentialResult", ] diff --git a/python/synapse_client/_auth_credentials.py b/python/synapse_client/_auth_credentials.py new file mode 100644 index 0000000..fea81f2 --- /dev/null +++ b/python/synapse_client/_auth_credentials.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +from typing import Any, Dict + +from .exceptions import AuthenticationError +from .models import AgentCredential, CredentialStatusResult, IssueCredentialResult, UpdateCredentialResult + + +class CredentialManagementMixin: + def issue_credential(self, **options: Any) -> IssueCredentialResult: + body = self._credential_options_body(options) + payload = self._request( + "POST", + "/api/v1/credentials/agent/issue", + headers=self._authorized_headers(), + json_body=body, + ) + credential_payload, credential_token = self._issued_credential_payload(payload) + credential = AgentCredential.model_validate(credential_payload) + return IssueCredentialResult(credential=credential, token=credential.token or credential_token) + + @staticmethod + def _credential_options_body(options: Dict[str, Any]) -> Dict[str, Any]: + aliases = { + "max_calls": "maxCalls", + "credit_limit": "creditLimit", + "reset_interval": "resetInterval", + "expires_in_sec": "expiresInSec", + } + for source, target in aliases.items(): + if source in options and target not in options: + options[target] = options[source] + body: Dict[str, Any] = {} + for key in ("name", "maxCalls", "creditLimit", "resetInterval", "rpm", "expiresInSec", "expiration"): + value = options.get(key) + if value is not None: + body[key] = value + return body + + @staticmethod + def _issued_credential_payload(payload: Dict[str, Any]) -> tuple[Dict[str, Any], str]: + credential_payload = payload.get("credential") + if not isinstance(credential_payload, dict): + credential_payload = {} + + credential_token = _first_text( + payload.get("token"), payload.get("credential_token"), credential_payload.get("token") + ) + credential_id = _first_text( + payload.get("credential_id"), + payload.get("id"), + credential_payload.get("credential_id"), + credential_payload.get("id"), + ) + _apply_credential_defaults(credential_payload, credential_id, credential_token) + + if not credential_payload: + raise AuthenticationError(f"Credential payload missing: {payload}") + return credential_payload, credential_token + + def list_credentials(self) -> list[AgentCredential]: + payload = self._request( + "GET", + "/api/v1/credentials/agent/list", + headers=self._authorized_headers(), + ) + credentials = payload.get("credentials") + if not isinstance(credentials, list): + return [] + return [AgentCredential.model_validate(item) for item in credentials if isinstance(item, dict)] + + def list_active_credentials(self) -> list[AgentCredential]: + """Return only active, non-expired credentials (active_only=true filter).""" + payload = self._request( + "GET", + self._query_path("/api/v1/credentials/agent/list", {"active_only": "true"}), + headers=self._authorized_headers(), + ) + credentials = payload.get("credentials") + if not isinstance(credentials, list): + return [] + return [AgentCredential.model_validate(item) for item in credentials if isinstance(item, dict)] + + def get_credential_status(self, credential_id: str) -> CredentialStatusResult: + """Check whether a credential is valid and usable.""" + credential_id = self._require_value(credential_id, "credential_id") + payload = self._request( + "GET", + f"/api/v1/credentials/agent/{credential_id}/status", + headers=self._authorized_headers(), + ) + return CredentialStatusResult.model_validate(payload) + + def revoke_credential(self, credential_id: str) -> Dict[str, Any]: + """Revoke an agent credential without deleting its audit trail.""" + credential_id = self._require_value(credential_id, "credential_id") + return self._request( + "POST", + f"/api/v1/credentials/agent/{credential_id}/revoke", + headers=self._authorized_headers(), + ) + + def rotate_credential(self, credential_id: str) -> Dict[str, Any]: + """Rotate an agent credential and return the gateway response containing the new token.""" + credential_id = self._require_value(credential_id, "credential_id") + return self._request( + "POST", + f"/api/v1/credentials/agent/{credential_id}/rotate", + headers=self._authorized_headers(), + ) + + def delete_credential(self, credential_id: str) -> Dict[str, Any]: + """Delete an agent credential. Use revoke_credential for emergency shutoff.""" + credential_id = self._require_value(credential_id, "credential_id") + return self._request( + "DELETE", + f"/api/v1/credentials/agent/{credential_id}", + headers=self._authorized_headers(), + ) + + def update_credential_quota(self, credential_id: str, **options: Any) -> Dict[str, Any]: + """Update spend/call/rate quota fields for an agent credential.""" + credential_id = self._require_value(credential_id, "credential_id") + aliases = { + "max_calls": "maxCalls", + "credit_limit": "creditLimit", + "reset_interval": "resetInterval", + "expires_at": "expiresAt", + } + for source, target in aliases.items(): + if source in options and target not in options: + options[target] = options[source] + body: Dict[str, Any] = {} + for key in ("maxCalls", "rpm", "creditLimit", "resetInterval", "expiresAt", "expiration"): + value = options.get(key) + if value is not None: + body[key] = value + return self._request( + "PATCH", + f"/api/v1/credentials/agent/{credential_id}/quota", + headers=self._authorized_headers(), + json_body=body, + ) + + def get_credential_audit_logs(self, *, limit: int = 100) -> Dict[str, Any]: + """Fetch credential lifecycle audit logs for the authenticated owner.""" + return self._request( + "GET", + self._query_path("/api/v1/credentials/agent/audit-logs", {"limit": limit}), + headers=self._authorized_headers(), + ) + + def check_credential_status(self, credential_id: str) -> CredentialStatusResult: + """Alias for get_credential_status().""" + return self.get_credential_status(credential_id) + + def update_credential(self, credential_id: str, **options: Any) -> UpdateCredentialResult: + """Update name and/or quota fields of a credential (PATCH).""" + credential_id = self._require_value(credential_id, "credential_id") + body: Dict[str, Any] = {} + for key in ("name", "maxCalls", "rpm", "expiresAt", "creditLimit", "resetInterval", "expiration"): + value = options.get(key) + if value is not None: + body[key] = value + payload = self._request( + "PATCH", + f"/api/v1/credentials/agent/{credential_id}", + headers=self._authorized_headers(), + json_body=body, + ) + credential_payload = payload.get("credential") + if not isinstance(credential_payload, dict): + credential_payload = {} + return UpdateCredentialResult( + status=str(payload.get("status", "success")), + credential=AgentCredential.model_validate(credential_payload) if credential_payload else AgentCredential(), + ) + + def ensure_credential( + self, + name: str, + **options: Any, + ) -> str: + """Idempotent init: return token of an existing active credential by name, or create one. + + Usage:: + + token = auth.ensure_credential("my-agent", creditLimit=10.0, maxCalls=1000) + + The method: + 1. Calls ``list_active_credentials()``. + 2. Returns the token of the first credential matching *name* (if any). + 3. Otherwise issues a new credential and returns its token. + + Note: The token is only returned once at issue time. For existing + credentials the token is NOT re-returned by the list API (it is hashed). + A credential name match is used as a readiness signal — if you need the + raw token again you must persist it externally on first creation. + """ + credential = self._matching_active_credential(name) + if credential: + return self._usable_token_for_credential(credential) + result = self.issue_credential(name=name, **options) + return result.token or str(result.credential.token or "") + + def _matching_active_credential(self, name: str) -> AgentCredential | None: + target = str(name or "").strip() + for cred in self.list_active_credentials(): + if str(cred.name or "").strip() == target: + return cred + return None + + def _usable_token_for_credential(self, credential: AgentCredential) -> str: + token = str(credential.token or "").strip() + if token: + return token + rotated = self._request( + "POST", + f"/api/v1/credentials/agent/{credential.credential_id}/rotate", + headers=self._authorized_headers(), + ) + credential_payload = rotated.get("credential") + nested_token = credential_payload.get("token") if isinstance(credential_payload, dict) else None + return _first_text(rotated.get("token"), nested_token) + + +def _first_text(*values: Any) -> str: + for value in values: + text = str(value or "").strip() + if text: + return text + return "" + + +def _apply_credential_defaults(credential_payload: Dict[str, Any], credential_id: str, credential_token: str) -> None: + if credential_id and "id" not in credential_payload: + credential_payload["id"] = credential_id + if credential_id and "credential_id" not in credential_payload: + credential_payload["credential_id"] = credential_id + if credential_token and "token" not in credential_payload: + credential_payload["token"] = credential_token diff --git a/python/synapse_client/_auth_finance.py b/python/synapse_client/_auth_finance.py new file mode 100644 index 0000000..1311b75 --- /dev/null +++ b/python/synapse_client/_auth_finance.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from typing import Any, Dict, Optional +from uuid import uuid4 + +from .models import BalanceSummary, DepositConfirmResult, DepositIntentResult + + +class FinanceManagementMixin: + def get_balance(self) -> BalanceSummary: + payload = self._request( + "GET", + "/api/v1/balance", + headers=self._authorized_headers(), + ) + balance_payload = payload.get("balance") + if not isinstance(balance_payload, dict): + balance_payload = payload + return BalanceSummary.model_validate(balance_payload) + + def register_deposit_intent( + self, + tx_hash: str, + amount_usdc: float, + *, + idempotency_key: Optional[str] = None, + ) -> DepositIntentResult: + payload = self._request( + "POST", + "/api/v1/balance/deposit/intent", + headers={ + **self._authorized_headers(), + "X-Idempotency-Key": idempotency_key or f"deposit-{uuid4().hex}", + }, + json_body={ + "txHash": tx_hash, + "amountUsdc": amount_usdc, + }, + ) + return DepositIntentResult.model_validate(payload) + + def confirm_deposit(self, intent_id: str, event_key: str, confirmations: int = 1) -> DepositConfirmResult: + payload = self._request( + "POST", + f"/api/v1/balance/deposit/intents/{intent_id}/confirm", + headers=self._authorized_headers(), + json_body={ + "eventKey": event_key, + "confirmations": confirmations, + }, + ) + return DepositConfirmResult.model_validate(payload) + + def set_spending_limit(self, spending_limit_usdc: float | None) -> Dict[str, Any]: + body = ( + {"allowUnlimited": True} + if spending_limit_usdc is None + else {"spendingLimitUsdc": spending_limit_usdc, "allowUnlimited": False} + ) + return self._request( + "PUT", + "/api/v1/balance/spending-limit", + headers=self._authorized_headers(), + json_body=body, + ) + + def redeem_voucher(self, voucher_code: str, *, idempotency_key: Optional[str] = None) -> Dict[str, Any]: + """Redeem a voucher into the authenticated owner balance.""" + voucher_code = self._require_value(voucher_code, "voucher_code") + return self._request( + "POST", + "/api/v1/balance/vouchers/redeem", + headers={ + **self._authorized_headers(), + "X-Idempotency-Key": idempotency_key or f"voucher-{uuid4().hex}", + }, + json_body={"voucherCode": voucher_code}, + ) + + def get_usage_logs(self, *, limit: int = 100) -> Dict[str, Any]: + """Fetch owner usage logs for observability and billing review.""" + return self._request( + "GET", + self._query_path("/api/v1/usage/logs", {"limit": limit}), + headers=self._authorized_headers(), + ) + + def get_finance_audit_logs(self, *, limit: int = 100) -> Dict[str, Any]: + """Fetch finance audit logs. High-impact finance actions remain explicit.""" + return self._request( + "GET", + self._query_path("/api/v1/finance/audit-logs", {"limit": limit}), + headers=self._authorized_headers(), + ) + + def get_risk_overview(self) -> Dict[str, Any]: + """Return the owner finance risk overview.""" + return self._request( + "GET", + "/api/v1/finance/risk-overview", + headers=self._authorized_headers(), + ) diff --git a/python/synapse_client/_auth_provider_control.py b/python/synapse_client/_auth_provider_control.py new file mode 100644 index 0000000..ad2cdff --- /dev/null +++ b/python/synapse_client/_auth_provider_control.py @@ -0,0 +1,368 @@ +from __future__ import annotations + +from typing import Any, Dict, Optional +from uuid import uuid4 + +from .exceptions import AuthenticationError +from .models import ( + IssueProviderSecretResult, + ProviderSecret, + ProviderService, + ProviderServiceRegistrationResult, + ProviderServiceStatus, +) + + +class ProviderControlMixin: + def issue_provider_secret(self, **options: Any) -> IssueProviderSecretResult: + aliases = { + "max_calls": "maxCalls", + "credit_limit": "creditLimit", + "reset_interval": "resetInterval", + "expires_in_sec": "expiresInSec", + } + for source, target in aliases.items(): + if source in options and target not in options: + options[target] = options[source] + body: Dict[str, Any] = {} + for key in ("name", "maxCalls", "creditLimit", "resetInterval", "rpm", "expiresInSec", "expiration"): + value = options.get(key) + if value is not None: + body[key] = value + + payload = self._request( + "POST", + "/api/v1/secrets/provider/issue", + headers=self._authorized_headers(), + json_body=body, + ) + secret_payload = payload.get("secret") + if not isinstance(secret_payload, dict): + secret_payload = {} + if not secret_payload: + raise AuthenticationError(f"Provider secret payload missing: {payload}") + return IssueProviderSecretResult(secret=ProviderSecret.model_validate(secret_payload)) + + def list_provider_secrets(self) -> list[ProviderSecret]: + payload = self._request( + "GET", + "/api/v1/secrets/provider/list", + headers=self._authorized_headers(), + ) + secrets = payload.get("secrets") + if not isinstance(secrets, list): + return [] + return [ProviderSecret.model_validate(item) for item in secrets if isinstance(item, dict)] + + def delete_provider_secret(self, secret_id: str) -> Dict[str, Any]: + """Delete a provider control-plane secret.""" + secret_id = self._require_value(secret_id, "secret_id") + return self._request( + "DELETE", + f"/api/v1/secrets/provider/{secret_id}", + headers=self._authorized_headers(), + ) + + def register_provider_service( + self, + *, + service_name: str, + endpoint_url: str, + base_price_usdc: float | str, + description_for_model: str, + service_id: Optional[str] = None, + provider_display_name: Optional[str] = None, + payout_address: Optional[str] = None, + chain_id: int = 31337, + settlement_currency: str = "USDC", + tags: Optional[list[str]] = None, + status: str = "active", + is_active: bool = True, + input_schema: Optional[Dict[str, Any]] = None, + output_schema: Optional[Dict[str, Any]] = None, + endpoint_method: str = "POST", + health_path: str = "/health", + health_method: str = "GET", + health_timeout_ms: int = 3000, + request_timeout_ms: int = 15000, + governance_note: Optional[str] = None, + ) -> ProviderServiceRegistrationResult: + service_values = self._provider_service_values( + service_name=service_name, + endpoint_url=endpoint_url, + description_for_model=description_for_model, + service_id=service_id, + ) + body = self._provider_service_body( + service_values=service_values, + base_price_usdc=base_price_usdc, + provider_display_name=provider_display_name, + payout_address=payout_address, + chain_id=chain_id, + settlement_currency=settlement_currency, + tags=tags, + status=status, + is_active=is_active, + input_schema=input_schema, + output_schema=output_schema, + endpoint_method=endpoint_method, + health_path=health_path, + health_method=health_method, + health_timeout_ms=health_timeout_ms, + request_timeout_ms=request_timeout_ms, + governance_note=governance_note, + ) + payload = self._request( + "POST", + "/api/v1/services", + headers=self._authorized_headers(), + json_body=body, + ) + return ProviderServiceRegistrationResult.model_validate(payload) + + def _provider_service_values( + self, + *, + service_name: str, + endpoint_url: str, + description_for_model: str, + service_id: Optional[str], + ) -> Dict[str, str]: + name = str(service_name or "").strip() + endpoint = str(endpoint_url or "").strip() + summary = str(description_for_model or "").strip() + if not name: + raise ValueError("service_name is required") + if not endpoint: + raise ValueError("endpoint_url is required") + if not summary: + raise ValueError("description_for_model is required") + resolved_service_id = str(service_id or "").strip() or self._default_service_id(name) + return {"service_id": resolved_service_id, "name": name, "endpoint": endpoint, "summary": summary} + + def _provider_service_body( + self, + *, + service_values: Dict[str, str], + base_price_usdc: float | str, + provider_display_name: Optional[str], + payout_address: Optional[str], + chain_id: int, + settlement_currency: str, + tags: Optional[list[str]], + status: str, + is_active: bool, + input_schema: Optional[Dict[str, Any]], + output_schema: Optional[Dict[str, Any]], + endpoint_method: str, + health_path: str, + health_method: str, + health_timeout_ms: int, + request_timeout_ms: int, + governance_note: Optional[str], + ) -> Dict[str, Any]: + service_id = service_values["service_id"] + return { + "serviceId": service_id, + "agentToolName": service_id, + "serviceName": service_values["name"], + "role": "Provider", + "status": status, + "isActive": is_active, + "pricing": { + "amount": str(base_price_usdc), + "currency": "USDC", + }, + "summary": service_values["summary"], + "tags": tags or [], + "auth": {"type": "gateway_signed"}, + "invoke": self._provider_invoke_config( + endpoint_url=service_values["endpoint"], + endpoint_method=endpoint_method, + request_timeout_ms=request_timeout_ms, + input_schema=input_schema, + output_schema=output_schema, + ), + "healthCheck": self._provider_health_check(health_path, health_method, health_timeout_ms), + "providerProfile": self._provider_profile(provider_display_name, service_values["name"]), + "payoutAccount": self._provider_payout_account(payout_address, chain_id, settlement_currency), + "governance": { + "termsAccepted": True, + "riskAcknowledged": True, + "note": governance_note, + }, + } + + @staticmethod + def _provider_invoke_config( + *, + endpoint_url: str, + endpoint_method: str, + request_timeout_ms: int, + input_schema: Optional[Dict[str, Any]], + output_schema: Optional[Dict[str, Any]], + ) -> Dict[str, Any]: + return { + "method": endpoint_method, + "targets": [{"url": endpoint_url}], + "timeoutMs": request_timeout_ms, + "request": {"body": input_schema or {"type": "object", "properties": {}, "required": []}}, + "response": {"body": output_schema or {"type": "object", "properties": {}}}, + } + + @staticmethod + def _provider_health_check(health_path: str, health_method: str, health_timeout_ms: int) -> Dict[str, Any]: + return { + "path": health_path, + "method": health_method, + "timeoutMs": health_timeout_ms, + "successCodes": [200], + "healthyThreshold": 1, + "unhealthyThreshold": 3, + } + + @staticmethod + def _provider_profile(provider_display_name: Optional[str], service_name: str) -> Dict[str, str]: + return {"displayName": str(provider_display_name or service_name).strip() or service_name} + + def _provider_payout_account( + self, + payout_address: Optional[str], + chain_id: int, + settlement_currency: str, + ) -> Dict[str, Any]: + return { + "payoutAddress": str(payout_address or self.wallet_address).strip() or self.wallet_address, + "chainId": chain_id, + "settlementCurrency": settlement_currency, + } + + def list_provider_services(self) -> list[ProviderService]: + payload = self._request( + "GET", + "/api/v1/services", + headers=self._authorized_headers(), + ) + services = payload.get("services") + if not isinstance(services, list): + return [] + return [ProviderService.model_validate(item) for item in services if isinstance(item, dict)] + + def get_registration_guide(self) -> Dict[str, Any]: + """Fetch the provider registration guide from the gateway control plane.""" + return self._request( + "GET", + "/api/v1/services/registration-guide", + headers=self._authorized_headers(), + ) + + def parse_curl_to_service_manifest(self, curl_command: str) -> Dict[str, Any]: + """Convert a curl command into a provider service manifest draft.""" + curl_command = self._require_value(curl_command, "curl_command") + return self._request( + "POST", + "/api/v1/services/parse-curl", + headers=self._authorized_headers(), + json_body={"curlCommand": curl_command}, + ) + + def update_provider_service(self, service_record_id: str, patch: Dict[str, Any]) -> Dict[str, Any]: + """Patch a provider service registration by gateway record ID.""" + service_record_id = self._require_value(service_record_id, "service_record_id") + return self._request( + "PUT", + f"/api/v1/services/{service_record_id}", + headers=self._authorized_headers(), + json_body=patch or {}, + ) + + def delete_provider_service(self, service_record_id: str) -> Dict[str, Any]: + """Delete a provider service registration by gateway record ID.""" + service_record_id = self._require_value(service_record_id, "service_record_id") + return self._request( + "DELETE", + f"/api/v1/services/{service_record_id}", + headers=self._authorized_headers(), + ) + + def ping_provider_service(self, service_record_id: str) -> Dict[str, Any]: + """Force a provider service health ping.""" + service_record_id = self._require_value(service_record_id, "service_record_id") + return self._request( + "POST", + f"/api/v1/services/{service_record_id}/ping", + headers=self._authorized_headers(), + ) + + def get_provider_service_health_history(self, service_record_id: str, *, limit: int = 100) -> Dict[str, Any]: + """Fetch health history for a provider service.""" + service_record_id = self._require_value(service_record_id, "service_record_id") + return self._request( + "GET", + self._query_path(f"/api/v1/services/{service_record_id}/health/history", {"limitPerTarget": limit}), + headers=self._authorized_headers(), + ) + + def get_provider_earnings_summary(self) -> Dict[str, Any]: + """Return provider earnings summary for the authenticated owner.""" + return self._request( + "GET", + "/api/v1/providers/earnings/summary", + headers=self._authorized_headers(), + ) + + def get_provider_withdrawal_capability(self) -> Dict[str, Any]: + """Return whether provider withdrawals are currently available.""" + return self._request( + "GET", + "/api/v1/providers/withdrawals/capability", + headers=self._authorized_headers(), + ) + + def create_provider_withdrawal_intent( + self, + amount_usdc: float, + *, + idempotency_key: Optional[str] = None, + destination_address: Optional[str] = None, + ) -> Dict[str, Any]: + """Create a provider withdrawal intent. This does not auto-submit funds on-chain.""" + body: Dict[str, Any] = {"amountUsdc": amount_usdc} + if destination_address: + body["destinationAddress"] = destination_address + return self._request( + "POST", + "/api/v1/providers/withdrawals/intent", + headers={ + **self._authorized_headers(), + "X-Idempotency-Key": idempotency_key or f"provider-withdraw-{uuid4().hex}", + }, + json_body=body, + ) + + def list_provider_withdrawals(self, *, limit: int = 100) -> Dict[str, Any]: + """List provider withdrawal records.""" + return self._request( + "GET", + self._query_path("/api/v1/providers/withdrawals", {"limit": limit}), + headers=self._authorized_headers(), + ) + + def get_provider_service(self, service_id: str) -> ProviderService: + resolved_service_id = str(service_id or "").strip() + if not resolved_service_id: + raise ValueError("service_id is required") + services = self.list_provider_services() + for service in services: + if service.service_id == resolved_service_id: + return service + raise AuthenticationError(f"Provider service not found: {resolved_service_id}") + + def get_provider_service_status(self, service_id: str) -> ProviderServiceStatus: + service = self.get_provider_service(service_id) + return ProviderServiceStatus( + serviceId=service.service_id, + lifecycleStatus=service.status, + runtimeAvailable=service.runtime_available, + health=service.health.model_dump(by_alias=True), + ) diff --git a/python/synapse_client/auth.py b/python/synapse_client/auth.py index 52d252e..63f2b95 100644 --- a/python/synapse_client/auth.py +++ b/python/synapse_client/auth.py @@ -7,29 +7,17 @@ import requests +from ._auth_credentials import CredentialManagementMixin +from ._auth_finance import FinanceManagementMixin +from ._auth_provider_control import ProviderControlMixin from .config import resolve_gateway_url from .exceptions import AuthenticationError -from .models import ( - AgentCredential, - BalanceSummary, - ChallengeResponse, - DepositConfirmResult, - DepositIntentResult, - IssueCredentialResult, - IssueProviderSecretResult, - ProviderSecret, - ProviderService, - ProviderServiceRegistrationResult, - ProviderServiceStatus, - TokenResponse, - CredentialStatusResult, - UpdateCredentialResult, -) +from .models import ChallengeResponse, TokenResponse SignerFn = Callable[[str], str] -class SynapseAuth: +class SynapseAuth(CredentialManagementMixin, FinanceManagementMixin, ProviderControlMixin): """Wallet-based owner auth + credential + balance management for Synapse.""" def __init__( @@ -72,7 +60,7 @@ def from_private_key( from eth_account.messages import encode_defunct except ImportError as exc: raise ImportError( - "SynapseAuth.from_private_key requires eth-account. Install with `pip install -e \".[dev]\"`." + 'SynapseAuth.from_private_key requires eth-account. Install with `pip install -e ".[dev]"`.' ) from exc account = Account.from_key(private_key) @@ -101,12 +89,7 @@ def _authorized_headers(self) -> Dict[str, str]: @staticmethod def _default_service_id(service_name: str) -> str: - normalized = ( - str(service_name or "") - .strip() - .lower() - .replace(" ", "_") - ) + normalized = str(service_name or "").strip().lower().replace(" ", "_") chars = [] previous_is_sep = False for char in normalized: @@ -141,22 +124,28 @@ def _request( timeout=self.timeout_sec, ) + payload = self._json_payload(response) + + if not response.ok: + raise AuthenticationError(self._auth_error_message(response, payload)) + return payload + + @staticmethod + def _json_payload(response: requests.Response) -> Dict[str, Any]: try: data = response.json() - payload = data if isinstance(data, dict) else {} except ValueError: - payload = {} + return {} + return data if isinstance(data, dict) else {} - if not response.ok: - detail = payload.get("detail") - if isinstance(detail, dict): - message = str(detail.get("message") or detail.get("code") or response.text) - elif isinstance(detail, str) and detail.strip(): - message = detail.strip() - else: - message = response.text.strip() or f"HTTP {response.status_code}" - raise AuthenticationError(message) - return payload + @staticmethod + def _auth_error_message(response: requests.Response, payload: Dict[str, Any]) -> str: + detail = payload.get("detail") + if isinstance(detail, dict): + return str(detail.get("message") or detail.get("code") or response.text) + if isinstance(detail, str) and detail.strip(): + return detail.strip() + return response.text.strip() or f"HTTP {response.status_code}" @staticmethod def _require_value(value: str, name: str) -> str: @@ -174,11 +163,7 @@ def _query_path(path: str, params: Dict[str, Any]) -> str: def authenticate(self, force_refresh: bool = False) -> str: now = time.time() - if ( - not force_refresh - and self._token - and now < max(0, self._token_expires_at - 30) - ): + if not force_refresh and self._token and now < max(0, self._token_expires_at - 30): return self._token challenge_payload = self._request( @@ -227,599 +212,3 @@ def get_owner_profile(self) -> Dict[str, Any]: "/api/v1/auth/me", headers=self._authorized_headers(), ) - - def issue_credential(self, **options: Any) -> IssueCredentialResult: - aliases = { - "max_calls": "maxCalls", - "credit_limit": "creditLimit", - "reset_interval": "resetInterval", - "expires_in_sec": "expiresInSec", - } - for source, target in aliases.items(): - if source in options and target not in options: - options[target] = options[source] - body: Dict[str, Any] = {} - for key in ("name", "maxCalls", "creditLimit", "resetInterval", "rpm", "expiresInSec", "expiration"): - value = options.get(key) - if value is not None: - body[key] = value - - payload = self._request( - "POST", - "/api/v1/credentials/agent/issue", - headers=self._authorized_headers(), - json_body=body, - ) - - credential_payload = payload.get("credential") - if not isinstance(credential_payload, dict): - credential_payload = {} - - credential_token = str( - payload.get("token") - or payload.get("credential_token") - or credential_payload.get("token") - or "" - ) - credential_id = str( - payload.get("credential_id") - or payload.get("id") - or credential_payload.get("credential_id") - or credential_payload.get("id") - or "" - ) - if credential_id and "id" not in credential_payload: - credential_payload["id"] = credential_id - if credential_id and "credential_id" not in credential_payload: - credential_payload["credential_id"] = credential_id - if credential_token and "token" not in credential_payload: - credential_payload["token"] = credential_token - - if not credential_payload: - raise AuthenticationError(f"Credential payload missing: {payload}") - - credential = AgentCredential.model_validate(credential_payload) - return IssueCredentialResult(credential=credential, token=credential.token or credential_token) - - def issue_provider_secret(self, **options: Any) -> IssueProviderSecretResult: - aliases = { - "max_calls": "maxCalls", - "credit_limit": "creditLimit", - "reset_interval": "resetInterval", - "expires_in_sec": "expiresInSec", - } - for source, target in aliases.items(): - if source in options and target not in options: - options[target] = options[source] - body: Dict[str, Any] = {} - for key in ("name", "maxCalls", "creditLimit", "resetInterval", "rpm", "expiresInSec", "expiration"): - value = options.get(key) - if value is not None: - body[key] = value - - payload = self._request( - "POST", - "/api/v1/secrets/provider/issue", - headers=self._authorized_headers(), - json_body=body, - ) - secret_payload = payload.get("secret") - if not isinstance(secret_payload, dict): - secret_payload = {} - if not secret_payload: - raise AuthenticationError(f"Provider secret payload missing: {payload}") - return IssueProviderSecretResult(secret=ProviderSecret.model_validate(secret_payload)) - - def list_provider_secrets(self) -> list[ProviderSecret]: - payload = self._request( - "GET", - "/api/v1/secrets/provider/list", - headers=self._authorized_headers(), - ) - secrets = payload.get("secrets") - if not isinstance(secrets, list): - return [] - return [ProviderSecret.model_validate(item) for item in secrets if isinstance(item, dict)] - - def delete_provider_secret(self, secret_id: str) -> Dict[str, Any]: - """Delete a provider control-plane secret.""" - secret_id = self._require_value(secret_id, "secret_id") - return self._request( - "DELETE", - f"/api/v1/secrets/provider/{secret_id}", - headers=self._authorized_headers(), - ) - - def list_credentials(self) -> list[AgentCredential]: - payload = self._request( - "GET", - "/api/v1/credentials/agent/list", - headers=self._authorized_headers(), - ) - credentials = payload.get("credentials") - if not isinstance(credentials, list): - return [] - return [AgentCredential.model_validate(item) for item in credentials if isinstance(item, dict)] - - def list_active_credentials(self) -> list[AgentCredential]: - """Return only active, non-expired credentials (active_only=true filter).""" - # active_only is a query param — use requests directly since _request doesn't support params - response = requests.get( - f"{self.gateway_url}/api/v1/credentials/agent/list", - headers=self._authorized_headers(), - params={"active_only": "true"}, - timeout=self.timeout_sec, - ) - try: - payload = response.json() if isinstance(response.json(), dict) else {} - except ValueError: - payload = {} - if not response.ok: - detail = payload.get("detail") - message = ( - str(detail.get("message") or detail.get("code")) if isinstance(detail, dict) - else (str(detail).strip() if isinstance(detail, str) else response.text) - ) - raise AuthenticationError(message) - credentials = payload.get("credentials") - if not isinstance(credentials, list): - return [] - return [AgentCredential.model_validate(item) for item in credentials if isinstance(item, dict)] - - def get_credential_status(self, credential_id: str) -> CredentialStatusResult: - """Check whether a credential is valid and usable.""" - credential_id = self._require_value(credential_id, "credential_id") - payload = self._request( - "GET", - f"/api/v1/credentials/agent/{credential_id}/status", - headers=self._authorized_headers(), - ) - return CredentialStatusResult.model_validate(payload) - - def revoke_credential(self, credential_id: str) -> Dict[str, Any]: - """Revoke an agent credential without deleting its audit trail.""" - credential_id = self._require_value(credential_id, "credential_id") - return self._request( - "POST", - f"/api/v1/credentials/agent/{credential_id}/revoke", - headers=self._authorized_headers(), - ) - - def rotate_credential(self, credential_id: str) -> Dict[str, Any]: - """Rotate an agent credential and return the gateway response containing the new token.""" - credential_id = self._require_value(credential_id, "credential_id") - return self._request( - "POST", - f"/api/v1/credentials/agent/{credential_id}/rotate", - headers=self._authorized_headers(), - ) - - def delete_credential(self, credential_id: str) -> Dict[str, Any]: - """Delete an agent credential. Use revoke_credential for emergency shutoff.""" - credential_id = self._require_value(credential_id, "credential_id") - return self._request( - "DELETE", - f"/api/v1/credentials/agent/{credential_id}", - headers=self._authorized_headers(), - ) - - def update_credential_quota(self, credential_id: str, **options: Any) -> Dict[str, Any]: - """Update spend/call/rate quota fields for an agent credential.""" - credential_id = self._require_value(credential_id, "credential_id") - aliases = { - "max_calls": "maxCalls", - "credit_limit": "creditLimit", - "reset_interval": "resetInterval", - "expires_at": "expiresAt", - } - for source, target in aliases.items(): - if source in options and target not in options: - options[target] = options[source] - body: Dict[str, Any] = {} - for key in ("maxCalls", "rpm", "creditLimit", "resetInterval", "expiresAt", "expiration"): - value = options.get(key) - if value is not None: - body[key] = value - return self._request( - "PATCH", - f"/api/v1/credentials/agent/{credential_id}/quota", - headers=self._authorized_headers(), - json_body=body, - ) - - def get_credential_audit_logs(self, *, limit: int = 100) -> Dict[str, Any]: - """Fetch credential lifecycle audit logs for the authenticated owner.""" - return self._request( - "GET", - self._query_path("/api/v1/credentials/agent/audit-logs", {"limit": limit}), - headers=self._authorized_headers(), - ) - - def check_credential_status(self, credential_id: str) -> CredentialStatusResult: - """Alias for get_credential_status().""" - return self.get_credential_status(credential_id) - - def update_credential(self, credential_id: str, **options: Any) -> UpdateCredentialResult: - """Update name and/or quota fields of a credential (PATCH).""" - credential_id = self._require_value(credential_id, "credential_id") - body: Dict[str, Any] = {} - for key in ("name", "maxCalls", "rpm", "expiresAt", "creditLimit", "resetInterval", "expiration"): - value = options.get(key) - if value is not None: - body[key] = value - payload = self._request( - "PATCH", - f"/api/v1/credentials/agent/{credential_id}", - headers=self._authorized_headers(), - json_body=body, - ) - credential_payload = payload.get("credential") - if not isinstance(credential_payload, dict): - credential_payload = {} - return UpdateCredentialResult( - status=str(payload.get("status", "success")), - credential=AgentCredential.model_validate(credential_payload) if credential_payload else AgentCredential(), - ) - - def ensure_credential( - self, - name: str, - **options: Any, - ) -> str: - """Idempotent init: return token of an existing active credential by name, or create one. - - Usage:: - - token = auth.ensure_credential("my-agent", creditLimit=10.0, maxCalls=1000) - - The method: - 1. Calls ``list_active_credentials()``. - 2. Returns the token of the first credential matching *name* (if any). - 3. Otherwise issues a new credential and returns its token. - - Note: The token is only returned once at issue time. For existing - credentials the token is NOT re-returned by the list API (it is hashed). - A credential name match is used as a readiness signal — if you need the - raw token again you must persist it externally on first creation. - """ - active = self.list_active_credentials() - for cred in active: - if str(cred.name or "").strip() == str(name or "").strip(): - token = str(cred.token or "").strip() - if token: - return token - # Credential exists but token not in list response (expected) — - # rotate to get a fresh token. - rotated = self._request( - "POST", - f"/api/v1/credentials/agent/{cred.credential_id}/rotate", - headers=self._authorized_headers(), - ) - return str( - rotated.get("token") - or (rotated.get("credential") or {}).get("token") - or "" - ) - # No matching active credential — create one - result = self.issue_credential(name=name, **options) - return result.token or str(result.credential.token or "") - - def get_balance(self) -> BalanceSummary: - payload = self._request( - "GET", - "/api/v1/balance", - headers=self._authorized_headers(), - ) - balance_payload = payload.get("balance") - if not isinstance(balance_payload, dict): - balance_payload = payload - return BalanceSummary.model_validate(balance_payload) - - def register_deposit_intent( - self, - tx_hash: str, - amount_usdc: float, - *, - idempotency_key: Optional[str] = None, - ) -> DepositIntentResult: - payload = self._request( - "POST", - "/api/v1/balance/deposit/intent", - headers={ - **self._authorized_headers(), - "X-Idempotency-Key": idempotency_key or f"deposit-{uuid4().hex}", - }, - json_body={ - "txHash": tx_hash, - "amountUsdc": amount_usdc, - }, - ) - return DepositIntentResult.model_validate(payload) - - def confirm_deposit(self, intent_id: str, event_key: str, confirmations: int = 1) -> DepositConfirmResult: - payload = self._request( - "POST", - f"/api/v1/balance/deposit/intents/{intent_id}/confirm", - headers=self._authorized_headers(), - json_body={ - "eventKey": event_key, - "confirmations": confirmations, - }, - ) - return DepositConfirmResult.model_validate(payload) - - def set_spending_limit(self, spending_limit_usdc: float | None) -> Dict[str, Any]: - body = ( - {"allowUnlimited": True} - if spending_limit_usdc is None - else {"spendingLimitUsdc": spending_limit_usdc, "allowUnlimited": False} - ) - return self._request( - "PUT", - "/api/v1/balance/spending-limit", - headers=self._authorized_headers(), - json_body=body, - ) - - def redeem_voucher(self, voucher_code: str, *, idempotency_key: Optional[str] = None) -> Dict[str, Any]: - """Redeem a voucher into the authenticated owner balance.""" - voucher_code = self._require_value(voucher_code, "voucher_code") - return self._request( - "POST", - "/api/v1/balance/vouchers/redeem", - headers={ - **self._authorized_headers(), - "X-Idempotency-Key": idempotency_key or f"voucher-{uuid4().hex}", - }, - json_body={"voucherCode": voucher_code}, - ) - - def get_usage_logs(self, *, limit: int = 100) -> Dict[str, Any]: - """Fetch owner usage logs for observability and billing review.""" - return self._request( - "GET", - self._query_path("/api/v1/usage/logs", {"limit": limit}), - headers=self._authorized_headers(), - ) - - def get_finance_audit_logs(self, *, limit: int = 100) -> Dict[str, Any]: - """Fetch finance audit logs. High-impact finance actions remain explicit.""" - return self._request( - "GET", - self._query_path("/api/v1/finance/audit-logs", {"limit": limit}), - headers=self._authorized_headers(), - ) - - def get_risk_overview(self) -> Dict[str, Any]: - """Return the owner finance risk overview.""" - return self._request( - "GET", - "/api/v1/finance/risk-overview", - headers=self._authorized_headers(), - ) - - def register_provider_service( - self, - *, - service_name: str, - endpoint_url: str, - base_price_usdc: float | str, - description_for_model: str, - service_id: Optional[str] = None, - provider_display_name: Optional[str] = None, - payout_address: Optional[str] = None, - chain_id: int = 31337, - settlement_currency: str = "USDC", - tags: Optional[list[str]] = None, - status: str = "active", - is_active: bool = True, - input_schema: Optional[Dict[str, Any]] = None, - output_schema: Optional[Dict[str, Any]] = None, - endpoint_method: str = "POST", - health_path: str = "/health", - health_method: str = "GET", - health_timeout_ms: int = 3000, - request_timeout_ms: int = 15000, - governance_note: Optional[str] = None, - ) -> ProviderServiceRegistrationResult: - resolved_service_name = str(service_name or "").strip() - resolved_endpoint = str(endpoint_url or "").strip() - resolved_summary = str(description_for_model or "").strip() - if not resolved_service_name: - raise ValueError("service_name is required") - if not resolved_endpoint: - raise ValueError("endpoint_url is required") - if not resolved_summary: - raise ValueError("description_for_model is required") - - resolved_service_id = str(service_id or "").strip() or self._default_service_id( - resolved_service_name - ) - body = { - "serviceId": resolved_service_id, - "agentToolName": resolved_service_id, - "serviceName": resolved_service_name, - "role": "Provider", - "status": status, - "isActive": is_active, - "pricing": { - "amount": str(base_price_usdc), - "currency": "USDC", - }, - "summary": resolved_summary, - "tags": tags or [], - "auth": {"type": "gateway_signed"}, - "invoke": { - "method": endpoint_method, - "targets": [{"url": resolved_endpoint}], - "timeoutMs": request_timeout_ms, - "request": { - "body": input_schema - or {"type": "object", "properties": {}, "required": []} - }, - "response": { - "body": output_schema - or {"type": "object", "properties": {}} - }, - }, - "healthCheck": { - "path": health_path, - "method": health_method, - "timeoutMs": health_timeout_ms, - "successCodes": [200], - "healthyThreshold": 1, - "unhealthyThreshold": 3, - }, - "providerProfile": { - "displayName": str(provider_display_name or resolved_service_name).strip() - or resolved_service_name, - }, - "payoutAccount": { - "payoutAddress": str(payout_address or self.wallet_address).strip() - or self.wallet_address, - "chainId": chain_id, - "settlementCurrency": settlement_currency, - }, - "governance": { - "termsAccepted": True, - "riskAcknowledged": True, - "note": governance_note, - }, - } - payload = self._request( - "POST", - "/api/v1/services", - headers=self._authorized_headers(), - json_body=body, - ) - return ProviderServiceRegistrationResult.model_validate(payload) - - def list_provider_services(self) -> list[ProviderService]: - payload = self._request( - "GET", - "/api/v1/services", - headers=self._authorized_headers(), - ) - services = payload.get("services") - if not isinstance(services, list): - return [] - return [ProviderService.model_validate(item) for item in services if isinstance(item, dict)] - - def get_registration_guide(self) -> Dict[str, Any]: - """Fetch the provider registration guide from the gateway control plane.""" - return self._request( - "GET", - "/api/v1/services/registration-guide", - headers=self._authorized_headers(), - ) - - def parse_curl_to_service_manifest(self, curl_command: str) -> Dict[str, Any]: - """Convert a curl command into a provider service manifest draft.""" - curl_command = self._require_value(curl_command, "curl_command") - return self._request( - "POST", - "/api/v1/services/parse-curl", - headers=self._authorized_headers(), - json_body={"curlCommand": curl_command}, - ) - - def update_provider_service(self, service_record_id: str, patch: Dict[str, Any]) -> Dict[str, Any]: - """Patch a provider service registration by gateway record ID.""" - service_record_id = self._require_value(service_record_id, "service_record_id") - return self._request( - "PUT", - f"/api/v1/services/{service_record_id}", - headers=self._authorized_headers(), - json_body=patch or {}, - ) - - def delete_provider_service(self, service_record_id: str) -> Dict[str, Any]: - """Delete a provider service registration by gateway record ID.""" - service_record_id = self._require_value(service_record_id, "service_record_id") - return self._request( - "DELETE", - f"/api/v1/services/{service_record_id}", - headers=self._authorized_headers(), - ) - - def ping_provider_service(self, service_record_id: str) -> Dict[str, Any]: - """Force a provider service health ping.""" - service_record_id = self._require_value(service_record_id, "service_record_id") - return self._request( - "POST", - f"/api/v1/services/{service_record_id}/ping", - headers=self._authorized_headers(), - ) - - def get_provider_service_health_history(self, service_record_id: str, *, limit: int = 100) -> Dict[str, Any]: - """Fetch health history for a provider service.""" - service_record_id = self._require_value(service_record_id, "service_record_id") - return self._request( - "GET", - self._query_path(f"/api/v1/services/{service_record_id}/health/history", {"limitPerTarget": limit}), - headers=self._authorized_headers(), - ) - - def get_provider_earnings_summary(self) -> Dict[str, Any]: - """Return provider earnings summary for the authenticated owner.""" - return self._request( - "GET", - "/api/v1/providers/earnings/summary", - headers=self._authorized_headers(), - ) - - def get_provider_withdrawal_capability(self) -> Dict[str, Any]: - """Return whether provider withdrawals are currently available.""" - return self._request( - "GET", - "/api/v1/providers/withdrawals/capability", - headers=self._authorized_headers(), - ) - - def create_provider_withdrawal_intent( - self, - amount_usdc: float, - *, - idempotency_key: Optional[str] = None, - destination_address: Optional[str] = None, - ) -> Dict[str, Any]: - """Create a provider withdrawal intent. This does not auto-submit funds on-chain.""" - body: Dict[str, Any] = {"amountUsdc": amount_usdc} - if destination_address: - body["destinationAddress"] = destination_address - return self._request( - "POST", - "/api/v1/providers/withdrawals/intent", - headers={ - **self._authorized_headers(), - "X-Idempotency-Key": idempotency_key or f"provider-withdraw-{uuid4().hex}", - }, - json_body=body, - ) - - def list_provider_withdrawals(self, *, limit: int = 100) -> Dict[str, Any]: - """List provider withdrawal records.""" - return self._request( - "GET", - self._query_path("/api/v1/providers/withdrawals", {"limit": limit}), - headers=self._authorized_headers(), - ) - - def get_provider_service(self, service_id: str) -> ProviderService: - resolved_service_id = str(service_id or "").strip() - if not resolved_service_id: - raise ValueError("service_id is required") - services = self.list_provider_services() - for service in services: - if service.service_id == resolved_service_id: - return service - raise AuthenticationError(f"Provider service not found: {resolved_service_id}") - - def get_provider_service_status(self, service_id: str) -> ProviderServiceStatus: - service = self.get_provider_service(service_id) - return ProviderServiceStatus( - serviceId=service.service_id, - lifecycleStatus=service.status, - runtimeAvailable=service.runtime_available, - health=service.health.model_dump(by_alias=True), - ) diff --git a/python/synapse_client/client.py b/python/synapse_client/client.py index e8a863d..ba93dda 100644 --- a/python/synapse_client/client.py +++ b/python/synapse_client/client.py @@ -20,10 +20,10 @@ from .models import ( DiscoveryResponse, InvocationResponse, + QuoteResponse, RuntimePayload, ) - TERMINAL_STATUSES = {"SUCCEEDED", "FAILED_RETRYABLE", "FAILED_FINAL", "SETTLED"} DEPRECATED_QUOTE_FLOW_MESSAGE = ( "The current Synapse gateway uses price-asserted single-call invoke. " @@ -43,7 +43,7 @@ def __init__( timeout_sec: int = 30, ): # Resolve api_key from arguments or environment variable - self.api_key = (api_key or os.getenv("SYNAPSE_API_KEY", "")).strip() + self.api_key = str(api_key or os.getenv("SYNAPSE_API_KEY", "") or "").strip() self.gateway_url = resolve_gateway_url(environment=environment, gateway_url=gateway_url) self.timeout_sec = timeout_sec @@ -96,25 +96,32 @@ def _raise_for_error(self, response: requests.Response, default_error: Exception raise AuthenticationError(message) if response.status_code == 402: - if error_code in {"BUDGET_EXHAUSTED", "CREDENTIAL_CREDIT_LIMIT_EXCEEDED"}: - raise InsufficientFundsError(message) - raise BudgetExceededError(message) + raise self._payment_error(error_code, message) if response.status_code == 422 and error_code == "PRICE_MISMATCH": - payload = self._response_payload(response) - detail = payload.get("detail") or {} - if isinstance(detail, dict): - raise PriceMismatchError( - message, - expected_price_usdc=float(detail.get("expectedPriceUsdc") or 0), - current_price_usdc=float(detail.get("currentPriceUsdc") or 0), - ) - raise PriceMismatchError(message, expected_price_usdc=0, current_price_usdc=0) + raise self._price_mismatch_error(response, message) if isinstance(default_error, DiscoveryError): raise DiscoveryError(message) raise InvokeError(message) + @staticmethod + def _payment_error(error_code: str, message: str) -> Exception: + if error_code in {"BUDGET_EXHAUSTED", "CREDENTIAL_CREDIT_LIMIT_EXCEEDED"}: + return InsufficientFundsError(message) + return BudgetExceededError(message) + + @classmethod + def _price_mismatch_error(cls, response: requests.Response, message: str) -> PriceMismatchError: + detail = cls._response_payload(response).get("detail") or {} + if not isinstance(detail, dict): + return PriceMismatchError(message, expected_price_usdc=0, current_price_usdc=0) + return PriceMismatchError( + message, + expected_price_usdc=float(detail.get("expectedPriceUsdc") or 0), + current_price_usdc=float(detail.get("currentPriceUsdc") or 0), + ) + def search_services( self, *, @@ -392,18 +399,13 @@ def invoke_with_rediscovery( if max_rediscovery_retries <= 0: raise - live_price = float(exc.current_price_usdc or 0) - services = self.search( - query or service_id, - limit=10, + live_price = self._rediscovered_price( + service_id, + fallback_price=float(exc.current_price_usdc or 0), + query=query, tags=tags, request_id=request_id, ) - for service in services: - if getattr(service, "service_id", "") == service_id or getattr(service, "serviceId", "") == service_id: - if service.price_usdc is not None: - live_price = float(service.price_usdc) - break if live_price <= 0: raise @@ -417,6 +419,26 @@ def invoke_with_rediscovery( request_id=request_id, ) + def _rediscovered_price( + self, + service_id: str, + *, + fallback_price: float, + query: Optional[str], + tags: Optional[list[str]], + request_id: Optional[str], + ) -> float: + services = self.search( + query or service_id, + limit=10, + tags=tags, + request_id=request_id, + ) + for service in services: + if getattr(service, "service_id", "") == service_id or getattr(service, "serviceId", "") == service_id: + return float(service.price_usdc) if service.price_usdc is not None else fallback_price + return fallback_price + class AgentWallet(SynapseClient): """Convenience wrapper — the 3-line DX entry point for agent developers. @@ -463,7 +485,9 @@ def spent_usdc(self) -> float: def remaining_usdc(self) -> float: return round(self._budget_usdc - self._spent_usdc, 6) - def invoke(self, service_id: str, *, payload: Optional[Dict[str, Any]] = None, cost_usdc: float = 0.0, **kwargs) -> "InvocationResponse": # type: ignore[override] + def invoke( + self, service_id: str, *, payload: Optional[Dict[str, Any]] = None, cost_usdc: float = 0.0, **kwargs + ) -> "InvocationResponse": """Invoke a service and track spend against the budget ceiling.""" cost = float(cost_usdc) if self._spent_usdc + cost > self._budget_usdc: diff --git a/python/synapse_client/exceptions.py b/python/synapse_client/exceptions.py index bc88fef..965d231 100644 --- a/python/synapse_client/exceptions.py +++ b/python/synapse_client/exceptions.py @@ -42,4 +42,3 @@ def __init__(self, message: str, expected_price_usdc: float, current_price_usdc: super().__init__(message) self.expected_price_usdc = expected_price_usdc self.current_price_usdc = current_price_usdc - diff --git a/python/synapse_client/models.py b/python/synapse_client/models.py index 35ecc21..fc473a9 100644 --- a/python/synapse_client/models.py +++ b/python/synapse_client/models.py @@ -338,7 +338,9 @@ class SynapseResponse(SDKModel): quote_id: Optional[str] = Field(default=None, alias="quoteId") invocation_id: Optional[str] = Field(default=None, alias="invocationId") status: str = "SUCCEEDED" - tx_hash: Optional[str] = Field(default=None, description="Reserved for compatibility with older direct invoke flows.") + tx_hash: Optional[str] = Field( + default=None, description="Reserved for compatibility with older direct invoke flows." + ) fee_deducted: float = Field(default=0.0, alias="feeDeducted") receipt: Dict[str, Any] = Field(default_factory=dict) raw_response: Dict[str, Any] = Field(default_factory=dict, alias="rawResponse") diff --git a/python/synapse_client/test/test_auth_unit.py b/python/synapse_client/test/test_auth_unit.py index a2fb2da..2d2ed1f 100644 --- a/python/synapse_client/test/test_auth_unit.py +++ b/python/synapse_client/test/test_auth_unit.py @@ -21,25 +21,31 @@ def test_authenticate_runs_challenge_sign_verify_and_caches(monkeypatch): calls = [] def fake_request(method, url, headers, json, timeout): - calls.append({ - "method": method, - "url": url, - "headers": headers, - "json": json, - "timeout": timeout, - }) + calls.append( + { + "method": method, + "url": url, + "headers": headers, + "json": json, + "timeout": timeout, + } + ) if url.endswith("/api/v1/auth/challenge?address=0xabc"): - return DummyResponse(json_data={ + return DummyResponse( + json_data={ + "success": True, + "challenge": "sign-me", + "domain": "a2a-pay-network", + } + ) + return DummyResponse( + json_data={ "success": True, - "challenge": "sign-me", - "domain": "a2a-pay-network", - }) - return DummyResponse(json_data={ - "success": True, - "access_token": "jwt-token", - "token_type": "bearer", - "expires_in": 3600, - }) + "access_token": "jwt-token", + "token_type": "bearer", + "expires_in": 3600, + } + ) monkeypatch.setattr("synapse_client.auth.requests.request", fake_request) @@ -96,44 +102,54 @@ def test_issue_credential_and_balance_use_bearer_token(monkeypatch): calls = [] def fake_request(method, url, headers, json, timeout): - calls.append({ - "method": method, - "url": url, - "headers": headers, - "json": json, - "timeout": timeout, - }) + calls.append( + { + "method": method, + "url": url, + "headers": headers, + "json": json, + "timeout": timeout, + } + ) if url.endswith("/api/v1/auth/challenge?address=0xabc"): - return DummyResponse(json_data={ - "success": True, - "challenge": "sign-me", - "domain": "a2a-pay-network", - }) + return DummyResponse( + json_data={ + "success": True, + "challenge": "sign-me", + "domain": "a2a-pay-network", + } + ) if url.endswith("/api/v1/auth/verify"): - return DummyResponse(json_data={ - "success": True, - "access_token": "jwt-token", - "token_type": "bearer", - "expires_in": 3600, - }) + return DummyResponse( + json_data={ + "success": True, + "access_token": "jwt-token", + "token_type": "bearer", + "expires_in": 3600, + } + ) if url.endswith("/api/v1/credentials/agent/issue"): - return DummyResponse(json_data={ - "credential": { - "id": "cred-123", - "token": "agt-123", - "name": "bot-1", - "status": "active", + return DummyResponse( + json_data={ + "credential": { + "id": "cred-123", + "token": "agt-123", + "name": "bot-1", + "status": "active", + }, + } + ) + return DummyResponse( + json_data={ + "status": "success", + "balance": { + "ownerBalance": "9.99", + "consumerAvailableBalance": "9.98", + "providerReceivable": "0", + "platformFeeAccrued": "0.01", }, - }) - return DummyResponse(json_data={ - "status": "success", - "balance": { - "ownerBalance": "9.99", - "consumerAvailableBalance": "9.98", - "providerReceivable": "0", - "platformFeeAccrued": "0.01", - }, - }) + } + ) monkeypatch.setattr("synapse_client.auth.requests.request", fake_request) @@ -158,27 +174,35 @@ def test_register_and_confirm_deposit(monkeypatch): calls = [] def fake_request(method, url, headers, json, timeout): - calls.append({ - "method": method, - "url": url, - "headers": headers, - "json": json, - "timeout": timeout, - }) + calls.append( + { + "method": method, + "url": url, + "headers": headers, + "json": json, + "timeout": timeout, + } + ) if url.endswith("/api/v1/auth/challenge?address=0xabc"): return DummyResponse(json_data={"success": True, "challenge": "sign-me", "domain": "a2a-pay-network"}) if url.endswith("/api/v1/auth/verify"): - return DummyResponse(json_data={"success": True, "access_token": "jwt-token", "token_type": "bearer", "expires_in": 3600}) + return DummyResponse( + json_data={"success": True, "access_token": "jwt-token", "token_type": "bearer", "expires_in": 3600} + ) if url.endswith("/api/v1/balance/deposit/intent"): - return DummyResponse(json_data={ + return DummyResponse( + json_data={ + "status": "success", + "tx_hash": "0xabc", + "intent": {"id": "intent-1", "eventKey": "evt-1", "txHash": "0xabc"}, + } + ) + return DummyResponse( + json_data={ "status": "success", - "tx_hash": "0xabc", - "intent": {"id": "intent-1", "eventKey": "evt-1", "txHash": "0xabc"}, - }) - return DummyResponse(json_data={ - "status": "success", - "intent": {"id": "intent-1", "eventKey": "evt-1"}, - }) + "intent": {"id": "intent-1", "eventKey": "evt-1"}, + } + ) monkeypatch.setattr("synapse_client.auth.requests.request", fake_request) @@ -217,49 +241,57 @@ def test_issue_provider_secret_and_list_provider_secrets(monkeypatch): calls = [] def fake_request(method, url, headers, json, timeout): - calls.append({ - "method": method, - "url": url, - "headers": headers, - "json": json, - "timeout": timeout, - }) + calls.append( + { + "method": method, + "url": url, + "headers": headers, + "json": json, + "timeout": timeout, + } + ) if url.endswith("/api/v1/auth/challenge?address=0xabc"): return DummyResponse(json_data={"success": True, "challenge": "sign-me", "domain": "a2a-pay-network"}) if url.endswith("/api/v1/auth/verify"): - return DummyResponse(json_data={"success": True, "access_token": "jwt-token", "token_type": "bearer", "expires_in": 3600}) + return DummyResponse( + json_data={"success": True, "access_token": "jwt-token", "token_type": "bearer", "expires_in": 3600} + ) if url.endswith("/api/v1/secrets/provider/issue"): - return DummyResponse(json_data={ - "status": "success", - "secret": { - "id": "psk_123", - "name": "provider-key", - "ownerAddress": "0xabc", - "secretKey": "agt_provider_123", - "maskedKey": "agt_provider...", - "status": "active", - "rpm": 180, - "creditLimit": 25.0, - "resetInterval": "monthly", - "createdAt": "2026-04-03T10:00:00Z", - }, - }) - return DummyResponse(json_data={ - "status": "success", - "secrets": [ - { - "id": "psk_123", - "name": "provider-key", - "ownerAddress": "0xabc", - "maskedKey": "agt_provider...", - "status": "active", - "rpm": 180, - "creditLimit": 25.0, - "resetInterval": "monthly", - "createdAt": "2026-04-03T10:00:00Z", + return DummyResponse( + json_data={ + "status": "success", + "secret": { + "id": "psk_123", + "name": "provider-key", + "ownerAddress": "0xabc", + "secretKey": "agt_provider_123", + "maskedKey": "agt_provider...", + "status": "active", + "rpm": 180, + "creditLimit": 25.0, + "resetInterval": "monthly", + "createdAt": "2026-04-03T10:00:00Z", + }, } - ], - }) + ) + return DummyResponse( + json_data={ + "status": "success", + "secrets": [ + { + "id": "psk_123", + "name": "provider-key", + "ownerAddress": "0xabc", + "maskedKey": "agt_provider...", + "status": "active", + "rpm": 180, + "creditLimit": 25.0, + "resetInterval": "monthly", + "createdAt": "2026-04-03T10:00:00Z", + } + ], + } + ) monkeypatch.setattr("synapse_client.auth.requests.request", fake_request) @@ -278,51 +310,79 @@ def test_register_provider_service_derives_defaults_and_reads_status(monkeypatch calls = [] def fake_request(method, url, headers, json, timeout): - calls.append({ - "method": method, - "url": url, - "headers": headers, - "json": json, - "timeout": timeout, - }) + calls.append( + { + "method": method, + "url": url, + "headers": headers, + "json": json, + "timeout": timeout, + } + ) if url.endswith("/api/v1/auth/challenge?address=0xabc"): return DummyResponse(json_data={"success": True, "challenge": "sign-me", "domain": "a2a-pay-network"}) if url.endswith("/api/v1/auth/verify"): - return DummyResponse(json_data={"success": True, "access_token": "jwt-token", "token_type": "bearer", "expires_in": 3600}) + return DummyResponse( + json_data={"success": True, "access_token": "jwt-token", "token_type": "bearer", "expires_in": 3600} + ) if url.endswith("/api/v1/services") and method == "POST": - return DummyResponse(json_data={ - "status": "success", - "serviceId": "sea_invoice_ocr", - "service": { + return DummyResponse( + json_data={ + "status": "success", "serviceId": "sea_invoice_ocr", - "ownerAddress": "0xabc", - "serviceName": "SEA Invoice OCR", - "summary": "Extract invoice fields.", - "status": "active", - "isActive": True, - "pricing": {"amount": "0.008", "currency": "USDC"}, - "health": {"overallStatus": "healthy", "healthyTargets": 1, "totalTargets": 1, "runtimeAvailable": True}, - "runtimeAvailable": True, - "invoke": {"method": "POST", "targets": [{"url": "https://provider.example.com/invoke"}], "request": {}, "response": {}}, - }, - }) - return DummyResponse(json_data={ - "status": "success", - "services": [ - { - "serviceId": "sea_invoice_ocr", - "ownerAddress": "0xabc", - "serviceName": "SEA Invoice OCR", - "summary": "Extract invoice fields.", - "status": "active", - "isActive": True, - "pricing": {"amount": "0.008", "currency": "USDC"}, - "health": {"overallStatus": "healthy", "healthyTargets": 1, "totalTargets": 1, "runtimeAvailable": True}, - "runtimeAvailable": True, - "invoke": {"method": "POST", "targets": [{"url": "https://provider.example.com/invoke"}], "request": {}, "response": {}}, + "service": { + "serviceId": "sea_invoice_ocr", + "ownerAddress": "0xabc", + "serviceName": "SEA Invoice OCR", + "summary": "Extract invoice fields.", + "status": "active", + "isActive": True, + "pricing": {"amount": "0.008", "currency": "USDC"}, + "health": { + "overallStatus": "healthy", + "healthyTargets": 1, + "totalTargets": 1, + "runtimeAvailable": True, + }, + "runtimeAvailable": True, + "invoke": { + "method": "POST", + "targets": [{"url": "https://provider.example.com/invoke"}], + "request": {}, + "response": {}, + }, + }, } - ], - }) + ) + return DummyResponse( + json_data={ + "status": "success", + "services": [ + { + "serviceId": "sea_invoice_ocr", + "ownerAddress": "0xabc", + "serviceName": "SEA Invoice OCR", + "summary": "Extract invoice fields.", + "status": "active", + "isActive": True, + "pricing": {"amount": "0.008", "currency": "USDC"}, + "health": { + "overallStatus": "healthy", + "healthyTargets": 1, + "totalTargets": 1, + "runtimeAvailable": True, + }, + "runtimeAvailable": True, + "invoke": { + "method": "POST", + "targets": [{"url": "https://provider.example.com/invoke"}], + "request": {}, + "response": {}, + }, + } + ], + } + ) monkeypatch.setattr("synapse_client.auth.requests.request", fake_request) @@ -353,7 +413,9 @@ def fake_request(method, url, headers, json, timeout): if url.endswith("/api/v1/auth/challenge?address=0xabc"): return DummyResponse(json_data={"success": True, "challenge": "sign-me", "domain": "a2a-pay-network"}) if url.endswith("/api/v1/auth/verify"): - return DummyResponse(json_data={"success": True, "access_token": "jwt-token", "token_type": "bearer", "expires_in": 3600}) + return DummyResponse( + json_data={"success": True, "access_token": "jwt-token", "token_type": "bearer", "expires_in": 3600} + ) return DummyResponse(json_data={"status": "success", "credentialId": "cred_1"}) monkeypatch.setattr("synapse_client.auth.requests.request", fake_request) @@ -391,7 +453,9 @@ def fake_request(method, url, headers, json, timeout): if url.endswith("/api/v1/auth/challenge?address=0xabc"): return DummyResponse(json_data={"success": True, "challenge": "sign-me", "domain": "a2a-pay-network"}) if url.endswith("/api/v1/auth/verify"): - return DummyResponse(json_data={"success": True, "access_token": "jwt-token", "token_type": "bearer", "expires_in": 3600}) + return DummyResponse( + json_data={"success": True, "access_token": "jwt-token", "token_type": "bearer", "expires_in": 3600} + ) return DummyResponse(json_data={"status": "success"}) monkeypatch.setattr("synapse_client.auth.requests.request", fake_request) diff --git a/python/synapse_client/test/test_client_unit.py b/python/synapse_client/test/test_client_unit.py index 7b743bf..c9cfb71 100644 --- a/python/synapse_client/test/test_client_unit.py +++ b/python/synapse_client/test/test_client_unit.py @@ -115,7 +115,11 @@ def fake_post(url, headers, json, timeout): "invoke": {"method": "POST", "request": {}, "response": {}}, "quoteTemplate": { "serviceId": "svc_quotes_famous_top3", - "inputPreview": {"contentType": "application/json", "payloadSchema": {"body": {}}, "sample": {"body": {}}}, + "inputPreview": { + "contentType": "application/json", + "payloadSchema": {"body": {}}, + "sample": {"body": {}}, + }, "responseMode": "sync", }, } @@ -247,14 +251,21 @@ def test_alias_methods_delegate_to_runtime_client(monkeypatch): ("status_code", "json_data", "error_type", "match"), [ (401, {"detail": {"code": "CREDENTIAL_INVALID", "message": "bad key"}}, AuthenticationError, "bad key"), - (402, {"detail": {"code": "BUDGET_EXHAUSTED", "message": "budget empty"}}, InsufficientFundsError, "budget empty"), + ( + 402, + {"detail": {"code": "BUDGET_EXHAUSTED", "message": "budget empty"}}, + InsufficientFundsError, + "budget empty", + ), (500, {}, InvokeError, "upstream exploded"), ], ) def test_invoke_maps_error_status_codes(monkeypatch, status_code, json_data, error_type, match): monkeypatch.setattr( "synapse_client.client.requests.post", - lambda url, headers, json, timeout: DummyResponse(status_code=status_code, json_data=json_data, text="upstream exploded", ok=False), + lambda url, headers, json, timeout: DummyResponse( + status_code=status_code, json_data=json_data, text="upstream exploded", ok=False + ), ) client = SynapseClient(api_key="agt_test") with pytest.raises(error_type, match=match): @@ -271,9 +282,7 @@ def test_invoke_with_cost_usdc_calls_agent_invoke_endpoint(monkeypatch): def fake_post(url, headers, json, timeout): calls.append({"url": url, "json": json}) - return DummyResponse( - json_data={"invocationId": "inv_cost", "status": "SUCCEEDED", "chargedUsdc": 0.05} - ) + return DummyResponse(json_data={"invocationId": "inv_cost", "status": "SUCCEEDED", "chargedUsdc": 0.05}) monkeypatch.setattr("synapse_client.client.requests.post", fake_post) client = SynapseClient(api_key="agt_test") @@ -291,9 +300,7 @@ def test_invoke_with_cost_usdc_sends_payload_body(monkeypatch): def fake_post(url, headers, json, timeout): captured.append(json) - return DummyResponse( - json_data={"invocationId": "inv_b", "status": "SUCCEEDED", "chargedUsdc": 0.10} - ) + return DummyResponse(json_data={"invocationId": "inv_b", "status": "SUCCEEDED", "chargedUsdc": 0.10}) monkeypatch.setattr("synapse_client.client.requests.post", fake_post) client = SynapseClient(api_key="agt_test") @@ -333,7 +340,10 @@ def test_invoke_with_rediscovery_retries_once_on_price_mismatch(monkeypatch): def fake_post(url, headers, json, timeout): post_calls.append({"url": url, "json": json}) - if url.endswith("/api/v1/agent/invoke") and len([c for c in post_calls if c["url"].endswith("/api/v1/agent/invoke")]) == 1: + if ( + url.endswith("/api/v1/agent/invoke") + and len([c for c in post_calls if c["url"].endswith("/api/v1/agent/invoke")]) == 1 + ): return DummyResponse( status_code=422, ok=False, diff --git a/python/synapse_client/test/test_consumer_e2e.py b/python/synapse_client/test/test_consumer_e2e.py index b5eb9fd..f24da74 100644 --- a/python/synapse_client/test/test_consumer_e2e.py +++ b/python/synapse_client/test/test_consumer_e2e.py @@ -18,7 +18,6 @@ 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 @@ -85,7 +84,7 @@ def _fund_and_deposit(w3: Web3, fresh_account, deployer_account, amount_usdc: in 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)) + amount_wei = int(amount_usdc * (10**decimals)) deployer_nonce = w3.eth.get_transaction_count(deployer_account.address, "pending") _send_transaction( @@ -371,9 +370,7 @@ def test_python_sdk_credential_management_e2e(): # ── 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}" - ) + 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}" @@ -389,19 +386,13 @@ def test_python_sdk_credential_management_e2e(): 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}" - ) + 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}" - ) + 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) @@ -417,6 +408,4 @@ def test_python_sdk_credential_management_e2e(): # 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 brand_new_name in final_names, f"ensure_credential-created credential not found: {final_names}" diff --git a/python/synapse_client/test/test_provider_e2e.py b/python/synapse_client/test/test_provider_e2e.py index ebfa3f8..6c1e724 100644 --- a/python/synapse_client/test/test_provider_e2e.py +++ b/python/synapse_client/test/test_provider_e2e.py @@ -13,7 +13,6 @@ from eth_account import Account - GATEWAY_URL = "http://127.0.0.1:8000" MOCK_PROVIDER_PORT = 9499 SESSION_ID = uuid4().hex[:8] diff --git a/scripts/ci/pr_checks.sh b/scripts/ci/pr_checks.sh index 3209eb9..e9fe9ff 100755 --- a/scripts/ci/pr_checks.sh +++ b/scripts/ci/pr_checks.sh @@ -9,5 +9,6 @@ echo "[ci:pr] running SDK pull request quality gates" bash scripts/ci/repo_hygiene_checks.sh bash scripts/ci/python_checks.sh bash scripts/ci/typescript_checks.sh +bash scripts/ci/security_checks.sh echo "[ci:pr] all SDK quality gates passed" diff --git a/scripts/ci/python_checks.sh b/scripts/ci/python_checks.sh index 9486090..147df3d 100755 --- a/scripts/ci/python_checks.sh +++ b/scripts/ci/python_checks.sh @@ -16,16 +16,34 @@ fi echo "[ci:python] installing Python SDK dev dependencies" "$PYTHON_BIN" -m pip install --upgrade pip -"$PYTHON_BIN" -m pip install -e "./python[dev]" pytest-cov +"$PYTHON_BIN" -m pip install -e "./python[dev]" -echo "[ci:python] running Python unit tests with coverage" +echo "[ci:python] running Python format check" +"$PYTHON_BIN" -m ruff format --check python/synapse_client + +echo "[ci:python] running Python lint" +"$PYTHON_BIN" -m ruff check python/synapse_client + +echo "[ci:python] running Python type checks" +"$PYTHON_BIN" -m mypy --config-file python/pyproject.toml python/synapse_client + +echo "[ci:python] running Python unit tests with 80% coverage gate" "$PYTHON_BIN" -m pytest -q \ python/synapse_client/test/test_auth_unit.py \ python/synapse_client/test/test_client_unit.py \ --cov=synapse_client \ --cov-report=term-missing \ --cov-report=xml:python/coverage.xml \ - --cov-fail-under=75 + --cov-fail-under=80 + +echo "[ci:python] running Python complexity gate" +"$PYTHON_BIN" scripts/ci/source_quality_checks.py +radon_output="$("$PYTHON_BIN" -m radon cc python/synapse_client -n C -s --exclude "*/test/*")" +if [[ -n "$radon_output" ]]; then + echo "$radon_output" + echo "[ci:python] cyclomatic complexity C or worse detected; keep functions at radon grade A or B" >&2 + exit 1 +fi echo "[ci:python] building Python package" "$PYTHON_BIN" -m build python diff --git a/scripts/ci/security_checks.sh b/scripts/ci/security_checks.sh new file mode 100644 index 0000000..043bb6c --- /dev/null +++ b/scripts/ci/security_checks.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT_DIR" + +PYTHON_BIN="${PYTHON_BIN:-python}" +if [[ -z "${VIRTUAL_ENV:-}" ]]; then + VENV_DIR="${PYTHON_VENV_DIR:-$ROOT_DIR/python/.venv}" + if [[ ! -x "$VENV_DIR/bin/python" ]]; then + echo "[ci:security] creating virtual environment at $VENV_DIR" + "$PYTHON_BIN" -m venv "$VENV_DIR" + fi + PYTHON_BIN="$VENV_DIR/bin/python" +fi + +echo "[ci:security] installing Python security scanner" +"$PYTHON_BIN" -m pip install --upgrade pip +"$PYTHON_BIN" -m pip install bandit + +echo "[ci:security] running Python static security scan" +"$PYTHON_BIN" -m bandit -q -r python/synapse_client -x python/synapse_client/test + +echo "[ci:security] running production npm audit" +npm_config_registry="${NPM_CONFIG_REGISTRY:-https://registry.npmjs.org}" \ + npm audit --prefix typescript --omit=dev --audit-level=high + +echo "[ci:security] security checks passed" diff --git a/scripts/ci/source_quality_checks.py b/scripts/ci/source_quality_checks.py new file mode 100644 index 0000000..b856bbe --- /dev/null +++ b/scripts/ci/source_quality_checks.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import ast +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +PYTHON_SOURCE = ROOT / "python" / "synapse_client" +TYPESCRIPT_SOURCE = ROOT / "typescript" / "src" + +MAX_SOURCE_LINES = 500 +MAX_TEST_LINES = 700 +MAX_PYTHON_FUNCTION_LINES = 40 +IGNORED_DIRS = {"__pycache__", ".pytest_cache", ".venv", "dist", "build", "coverage", "node_modules"} + + +def iter_files(root: Path, suffix: str) -> list[Path]: + files: list[Path] = [] + for path in root.rglob(f"*{suffix}"): + if any(part in IGNORED_DIRS for part in path.parts): + continue + files.append(path) + return sorted(files) + + +def relative(path: Path) -> str: + return str(path.relative_to(ROOT)) + + +def effective_lines(path: Path) -> int: + count = 0 + for line in path.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if stripped and not stripped.startswith("#") and not stripped.startswith("//"): + count += 1 + return count + + +def check_file_lengths() -> list[str]: + failures: list[str] = [] + for path in iter_files(PYTHON_SOURCE, ".py") + iter_files(TYPESCRIPT_SOURCE, ".ts"): + limit = MAX_TEST_LINES if "/test/" in f"/{relative(path)}" or "/tests/" in f"/{relative(path)}" else MAX_SOURCE_LINES + total = len(path.read_text(encoding="utf-8").splitlines()) + if total > limit: + failures.append(f"{relative(path)} has {total} lines; limit is {limit}") + return failures + + +def check_python_function_lengths() -> list[str]: + failures: list[str] = [] + for path in iter_files(PYTHON_SOURCE, ".py"): + if "/test/" in f"/{relative(path)}": + continue + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and hasattr(node, "end_lineno"): + lines = effective_lines_for_node(path, node) + if lines > MAX_PYTHON_FUNCTION_LINES: + failures.append( + f"{relative(path)}:{node.lineno} {node.name} has {lines} effective lines; " + f"limit is {MAX_PYTHON_FUNCTION_LINES}" + ) + return failures + + +def effective_lines_for_node(path: Path, node: ast.AST) -> int: + source = path.read_text(encoding="utf-8").splitlines() + body = getattr(node, "body", []) + start = getattr(body[0], "lineno", getattr(node, "lineno", 1)) if body else getattr(node, "lineno", 1) + end = getattr(node, "end_lineno", start) + return sum(1 for line in source[start - 1 : end] if line.strip() and not line.strip().startswith("#")) + + +def main() -> int: + failures = check_file_lengths() + check_python_function_lengths() + if failures: + print("[ci:quality] source quality gate failed") + for failure in failures: + print(f" - {failure}") + return 1 + print("[ci:quality] source line and function size gates passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/typescript_checks.sh b/scripts/ci/typescript_checks.sh index 4e56708..3a451a6 100755 --- a/scripts/ci/typescript_checks.sh +++ b/scripts/ci/typescript_checks.sh @@ -5,9 +5,9 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$ROOT_DIR" echo "[ci:typescript] installing TypeScript SDK dependencies" -npm ci --prefix typescript +npm_config_registry="${NPM_CONFIG_REGISTRY:-https://registry.npmjs.org}" npm ci --prefix typescript -echo "[ci:typescript] running TypeScript type checks" +echo "[ci:typescript] running TypeScript format, lint, and type checks" npm run lint --prefix typescript echo "[ci:typescript] building TypeScript package" @@ -19,4 +19,7 @@ npm run test:unit --prefix typescript echo "[ci:typescript] running TypeScript unit coverage gate" npm run test:unit:coverage --prefix typescript +echo "[ci:typescript] running duplication gate" +npm run duplication --prefix typescript + echo "[ci:typescript] TypeScript SDK checks passed" diff --git a/typescript/.jscpd.json b/typescript/.jscpd.json new file mode 100644 index 0000000..b076c4d --- /dev/null +++ b/typescript/.jscpd.json @@ -0,0 +1,17 @@ +{ + "threshold": 3, + "minTokens": 50, + "reporters": ["console"], + "pattern": ["src/**/*.ts", "../python/synapse_client/**/*.py"], + "ignore": [ + "**/coverage/**", + "**/dist/**", + "**/build/**", + "**/.venv/**", + "**/node_modules/**", + "**/package-lock.json", + "**/*.d.ts", + "**/test/**", + "**/tests/**" + ] +} diff --git a/typescript/.prettierignore b/typescript/.prettierignore new file mode 100644 index 0000000..18f2b36 --- /dev/null +++ b/typescript/.prettierignore @@ -0,0 +1,3 @@ +coverage +dist +node_modules diff --git a/typescript/.prettierrc.json b/typescript/.prettierrc.json new file mode 100644 index 0000000..b1ca726 --- /dev/null +++ b/typescript/.prettierrc.json @@ -0,0 +1,4 @@ +{ + "printWidth": 120, + "trailingComma": "es5" +} diff --git a/typescript/eslint.config.mjs b/typescript/eslint.config.mjs new file mode 100644 index 0000000..95b8f81 --- /dev/null +++ b/typescript/eslint.config.mjs @@ -0,0 +1,28 @@ +import tseslint from "@typescript-eslint/eslint-plugin"; +import parser from "@typescript-eslint/parser"; + +export default [ + { + ignores: ["dist/**", "coverage/**", "node_modules/**"], + }, + { + files: ["src/**/*.ts"], + languageOptions: { + parser, + parserOptions: { + ecmaVersion: 2022, + sourceType: "module", + }, + }, + plugins: { + "@typescript-eslint": tseslint, + }, + rules: { + "complexity": ["error", 8], + "max-lines": ["error", { max: 500, skipBlankLines: true, skipComments: true }], + "max-lines-per-function": ["error", { max: 60, skipBlankLines: true, skipComments: true }], + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], + }, + }, +]; diff --git a/typescript/jest.config.js b/typescript/jest.config.js index dec16ab..ab5f264 100644 --- a/typescript/jest.config.js +++ b/typescript/jest.config.js @@ -19,12 +19,13 @@ module.exports = { ], }, collectCoverageFrom: ["src/**/*.ts", "!src/**/*.d.ts"], + coverageProvider: "v8", coverageThreshold: { global: { - branches: 75, - functions: 75, - lines: 75, - statements: 75, + branches: 80, + functions: 80, + lines: 80, + statements: 80, }, }, testTimeout: 120000, diff --git a/typescript/package-lock.json b/typescript/package-lock.json index 2f0ed7e..ce5a2e4 100644 --- a/typescript/package-lock.json +++ b/typescript/package-lock.json @@ -14,8 +14,13 @@ "@types/jest": "^29.5.12", "@types/node": "^20.0.0", "@types/uuid": "^9.0.7", + "@typescript-eslint/eslint-plugin": "^8.59.1", + "@typescript-eslint/parser": "^8.59.1", + "eslint": "^10.2.1", "ethers": "^6.13.0", "jest": "^29.7.0", + "jscpd": "^4.0.9", + "prettier": "^3.8.3", "ts-jest": "^29.1.4", "typescript": "^5.4.2" }, @@ -526,6 +531,216 @@ "dev": true, "license": "MIT" }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", + "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -895,6 +1110,71 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jscpd/badge-reporter": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@jscpd/badge-reporter/-/badge-reporter-4.0.5.tgz", + "integrity": "sha512-SLVhP00R9lkQ//Ivaanfm7k0L9sewpBven670kk1uGec2SWUOa7MVQcuad/TV59KEZ73UIC1lXvi6O9hAnbpUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "badgen": "^3.2.3", + "colors": "^1.4.0", + "fs-extra": "^11.2.0" + } + }, + "node_modules/@jscpd/core": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@jscpd/core/-/core-4.0.5.tgz", + "integrity": "sha512-Udvym21nWzxjYRVXwwpYNBqZ6b50QV2zHN3fFNzOPPg4cfQVYOZerILB7xNDUsXHC1PCr/N52Tq3q7AElvjWWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.1" + } + }, + "node_modules/@jscpd/finder": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@jscpd/finder/-/finder-4.0.5.tgz", + "integrity": "sha512-/2VkRoVrrfya+51sitZo5I9MdwsRaPKB8X3L3khAYoHFXk4L/mUuG81RmGazDHjUIGg22ItlkQtwzorNZ2+aPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jscpd/core": "4.0.5", + "@jscpd/tokenizer": "4.0.5", + "blamer": "^1.0.6", + "bytes": "^3.1.2", + "cli-table3": "^0.6.5", + "colors": "^1.4.0", + "fast-glob": "^3.3.2", + "fs-extra": "^11.2.0", + "markdown-table": "^2.0.0", + "pug": "^3.0.3" + } + }, + "node_modules/@jscpd/html-reporter": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@jscpd/html-reporter/-/html-reporter-4.0.5.tgz", + "integrity": "sha512-drK2J8KyPIW9wvaElSIobZFp4dBO9GA++JW4gx3oihvLdDSp8qSo/CNqH47Dw0XkjQTxND3j/+Wz5JWvYRBgFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "colors": "1.4.0", + "fs-extra": "^11.2.0", + "pug": "^3.0.3" + } + }, + "node_modules/@jscpd/tokenizer": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@jscpd/tokenizer/-/tokenizer-4.0.5.tgz", + "integrity": "sha512-WzRujQtN5WedxZVDKuoanxmKAFrxcLrHpcA6kaM4z8AhGtWXZ325yseqgL5TZ8OK7Auwu7kQLlqhfk05fGYG7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jscpd/core": "4.0.5", + "reprism": "^0.0.11", + "spark-md5": "^3.0.2" + } + }, "node_modules/@noble/curves": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", @@ -921,6 +1201,44 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", @@ -993,6 +1311,20 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -1041,6 +1373,13 @@ "pretty-format": "^29.0.0" } }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.37", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", @@ -1051,6 +1390,13 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/sarif": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@types/sarif/-/sarif-2.1.7.tgz", + "integrity": "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -1082,104 +1428,443 @@ "dev": true, "license": "MIT" }, - "node_modules/aes-js": { - "version": "4.0.0-beta.5", - "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", - "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz", + "integrity": "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==", "dev": true, "license": "MIT", "dependencies": { - "type-fest": "^0.21.3" + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/type-utils": "8.59.1", + "@typescript-eslint/utils": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": ">=8" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/@typescript-eslint/parser": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.1.tgz", + "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", "dev": true, "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", + "debug": "^4.4.3" + }, "engines": { - "node": ">=8" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.1.tgz", + "integrity": "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==", "dev": true, "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" + "@typescript-eslint/tsconfig-utils": "^8.59.1", + "@typescript-eslint/types": "^8.59.1", + "debug": "^4.4.3" }, "engines": { - "node": ">=8" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz", + "integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1" }, "engines": { - "node": ">= 8" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz", + "integrity": "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==", "dev": true, "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/babel-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", - "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz", + "integrity": "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==", "dev": true, "license": "MIT", "dependencies": { - "@jest/transform": "^29.7.0", - "@types/babel__core": "^7.1.14", - "babel-plugin-istanbul": "^6.1.1", - "babel-preset-jest": "^29.6.3", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "slash": "^3.0.0" + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/utils": "8.59.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@babel/core": "^7.8.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/babel-plugin-istanbul": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "node_modules/@typescript-eslint/types": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz", + "integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz", + "integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.1", + "@typescript-eslint/tsconfig-utils": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz", + "integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz", + "integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/aes-js": { + "version": "4.0.0-beta.5", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", + "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/assert-never": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/assert-never/-/assert-never-1.4.0.tgz", + "integrity": "sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA==", + "dev": true, + "license": "MIT" + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", "dev": true, "license": "BSD-3-Clause", @@ -1271,6 +1956,26 @@ "@babel/core": "^7.0.0" } }, + "node_modules/babel-walk": { + "version": "3.0.0-canary-5", + "resolved": "https://registry.npmjs.org/babel-walk/-/babel-walk-3.0.0-canary-5.tgz", + "integrity": "sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.9.6" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/badgen": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/badgen/-/badgen-3.2.3.tgz", + "integrity": "sha512-svDuwkc63E/z0ky3drpUppB83s/nlgDciH9m+STwwQoWyq7yCgew1qEfJ+9axkKdNq7MskByptWUN9j1PGMwFA==", + "dev": true, + "license": "MIT" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1291,6 +1996,70 @@ "node": ">=6.0.0" } }, + "node_modules/blamer": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/blamer/-/blamer-1.0.7.tgz", + "integrity": "sha512-GbBStl/EVlSWkiJQBZps3H1iARBrC7vt++Jb/TTmCNu/jZ04VW7tSN1nScbFXBUy1AN+jzeL7Zep9sbQxLhXKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^4.0.0", + "which": "^2.0.2" + }, + "engines": { + "node": ">=8.9" + } + }, + "node_modules/blamer/node_modules/execa": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", + "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.0", + "get-stream": "^5.0.0", + "human-signals": "^1.1.1", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.0", + "onetime": "^5.1.0", + "signal-exit": "^3.0.2", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/blamer/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/blamer/node_modules/human-signals": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", + "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.12.0" + } + }, "node_modules/brace-expansion": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", @@ -1372,12 +2141,53 @@ "node-int64": "^0.4.0" } }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/callsites": { "version": "3.1.0", @@ -1447,6 +2257,16 @@ "node": ">=10" } }, + "node_modules/character-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/character-parser/-/character-parser-2.2.0.tgz", + "integrity": "sha512-+UqJQjFEFaTAs3bNsF2j2kEN1baG/zghZbdqoYEDxGZtJo9LBzl1A+m0D4n3qKx8N2FNv8/Xp6yV9mQmBuptaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-regex": "^1.0.3" + } + }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -1470,6 +2290,22 @@ "dev": true, "license": "MIT" }, + "node_modules/cli-table3": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -1523,6 +2359,26 @@ "dev": true, "license": "MIT" }, + "node_modules/colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1530,6 +2386,17 @@ "dev": true, "license": "MIT" }, + "node_modules/constantinople": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/constantinople/-/constantinople-4.0.1.tgz", + "integrity": "sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.6.0", + "@babel/types": "^7.6.1" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1607,6 +2474,13 @@ } } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -1637,6 +2511,28 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/doctypes": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/doctypes/-/doctypes-1.1.0.tgz", + "integrity": "sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.331", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", @@ -1664,6 +2560,16 @@ "dev": true, "license": "MIT" }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -1674,38 +2580,360 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, - "license": "MIT", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.1.tgz", + "integrity": "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.5.5", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/eslint/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, "engines": { - "node": ">=6" + "node": ">=4.0" } }, - "node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "engines": { - "node": ">=8" + "node": ">=4.0" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, "engines": { - "node": ">=4" + "node": ">=0.10.0" } }, "node_modules/ethers": { @@ -1754,6 +2982,13 @@ "dev": true, "license": "MIT" }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -1804,6 +3039,43 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -1811,6 +3083,23 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -1821,6 +3110,19 @@ "bser": "2.1.1" } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -1848,6 +3150,42 @@ "node": ">=8" } }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fs-extra": { + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -1900,6 +3238,31 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", @@ -1910,6 +3273,20 @@ "node": ">=8.0.0" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -1945,6 +3322,32 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -1984,6 +3387,35 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -2014,6 +3446,16 @@ "node": ">=10.17.0" } }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/import-local": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", @@ -2086,6 +3528,40 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-expression": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-expression/-/is-expression-4.0.0.tgz", + "integrity": "sha512-zMIXX63sxzG3XrkHkrAPvm/OVZVSCPNkwMHU8oTX7/U3AL78I0QXCEICXUM13BIa8TYGZ68PiTKfQz3yaTNr4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^7.1.1", + "object-assign": "^4.1.1" + } + }, + "node_modules/is-expression/node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -2106,6 +3582,19 @@ "node": ">=6" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -2116,6 +3605,32 @@ "node": ">=0.12.0" } }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -2816,6 +4331,13 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/js-stringify": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/js-stringify/-/js-stringify-1.0.2.tgz", + "integrity": "sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==", + "dev": true, + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2837,6 +4359,39 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jscpd": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/jscpd/-/jscpd-4.0.9.tgz", + "integrity": "sha512-fp6Sh42W3mIPoQgZmgYmKDLQzEDnnX2vaGlTN4haILkB2vsi+ewcCHEtWR/2CR/QbsBvAvsNo8U5Sa+p9aHiGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jscpd/badge-reporter": "4.0.5", + "@jscpd/core": "4.0.5", + "@jscpd/finder": "4.0.5", + "@jscpd/html-reporter": "4.0.5", + "@jscpd/tokenizer": "4.0.5", + "colors": "^1.4.0", + "commander": "^5.0.0", + "fs-extra": "^11.2.0", + "jscpd-sarif-reporter": "4.0.7" + }, + "bin": { + "jscpd": "bin/jscpd" + } + }, + "node_modules/jscpd-sarif-reporter": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/jscpd-sarif-reporter/-/jscpd-sarif-reporter-4.0.7.tgz", + "integrity": "sha512-Q/VlfTI/Nbjc8dZ/2pDVIf1aRi2bM2CTYujcAoeYr7brRnS4o5ZeW86W8q7MM7cQu40gezlNckl+E9wKFSMFiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "colors": "^1.4.0", + "fs-extra": "^11.2.0", + "node-sarif-builder": "^3.4.0" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2850,6 +4405,13 @@ "node": ">=6" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -2857,6 +4419,20 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -2870,6 +4446,40 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jstransformer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/jstransformer/-/jstransformer-1.0.0.tgz", + "integrity": "sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-promise": "^2.0.0", + "promise": "^7.0.1" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -2890,6 +4500,20 @@ "node": ">=6" } }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -2973,6 +4597,30 @@ "tmpl": "1.0.5" } }, + "node_modules/markdown-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-2.0.0.tgz", + "integrity": "sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "repeat-string": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -2980,6 +4628,16 @@ "dev": true, "license": "MIT" }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -3062,6 +4720,20 @@ "dev": true, "license": "MIT" }, + "node_modules/node-sarif-builder": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/node-sarif-builder/-/node-sarif-builder-3.4.0.tgz", + "integrity": "sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/sarif": "^2.1.7", + "fs-extra": "^11.1.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -3085,6 +4757,16 @@ "node": ">=8" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -3111,6 +4793,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -3265,6 +4965,32 @@ "node": ">=8" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -3293,6 +5019,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "asap": "~2.0.3" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -3307,6 +5043,163 @@ "node": ">= 6" } }, + "node_modules/pug": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pug/-/pug-3.0.4.tgz", + "integrity": "sha512-kFfq5mMzrS7+wrl5pLJzZEzemx34OQ0w4SARfhy/3yxTlhbstsudDwJzhf1hP02yHzbjoVMSXUj/Sz6RNfMyXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "pug-code-gen": "^3.0.4", + "pug-filters": "^4.0.0", + "pug-lexer": "^5.0.1", + "pug-linker": "^4.0.0", + "pug-load": "^3.0.0", + "pug-parser": "^6.0.0", + "pug-runtime": "^3.0.1", + "pug-strip-comments": "^2.0.0" + } + }, + "node_modules/pug-attrs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pug-attrs/-/pug-attrs-3.0.0.tgz", + "integrity": "sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "constantinople": "^4.0.1", + "js-stringify": "^1.0.2", + "pug-runtime": "^3.0.0" + } + }, + "node_modules/pug-code-gen": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pug-code-gen/-/pug-code-gen-3.0.4.tgz", + "integrity": "sha512-6okWYIKdasTyXICyEtvobmTZAVX57JkzgzIi4iRJlin8kmhG+Xry2dsus+Mun/nGCn6F2U49haHI5mkELXB14g==", + "dev": true, + "license": "MIT", + "dependencies": { + "constantinople": "^4.0.1", + "doctypes": "^1.1.0", + "js-stringify": "^1.0.2", + "pug-attrs": "^3.0.0", + "pug-error": "^2.1.0", + "pug-runtime": "^3.0.1", + "void-elements": "^3.1.0", + "with": "^7.0.0" + } + }, + "node_modules/pug-error": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pug-error/-/pug-error-2.1.0.tgz", + "integrity": "sha512-lv7sU9e5Jk8IeUheHata6/UThZ7RK2jnaaNztxfPYUY+VxZyk/ePVaNZ/vwmH8WqGvDz3LrNYt/+gA55NDg6Pg==", + "dev": true, + "license": "MIT" + }, + "node_modules/pug-filters": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pug-filters/-/pug-filters-4.0.0.tgz", + "integrity": "sha512-yeNFtq5Yxmfz0f9z2rMXGw/8/4i1cCFecw/Q7+D0V2DdtII5UvqE12VaZ2AY7ri6o5RNXiweGH79OCq+2RQU4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "constantinople": "^4.0.1", + "jstransformer": "1.0.0", + "pug-error": "^2.0.0", + "pug-walk": "^2.0.0", + "resolve": "^1.15.1" + } + }, + "node_modules/pug-lexer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pug-lexer/-/pug-lexer-5.0.1.tgz", + "integrity": "sha512-0I6C62+keXlZPZkOJeVam9aBLVP2EnbeDw3An+k0/QlqdwH6rv8284nko14Na7c0TtqtogfWXcRoFE4O4Ff20w==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-parser": "^2.2.0", + "is-expression": "^4.0.0", + "pug-error": "^2.0.0" + } + }, + "node_modules/pug-linker": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pug-linker/-/pug-linker-4.0.0.tgz", + "integrity": "sha512-gjD1yzp0yxbQqnzBAdlhbgoJL5qIFJw78juN1NpTLt/mfPJ5VgC4BvkoD3G23qKzJtIIXBbcCt6FioLSFLOHdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pug-error": "^2.0.0", + "pug-walk": "^2.0.0" + } + }, + "node_modules/pug-load": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pug-load/-/pug-load-3.0.0.tgz", + "integrity": "sha512-OCjTEnhLWZBvS4zni/WUMjH2YSUosnsmjGBB1An7CsKQarYSWQ0GCVyd4eQPMFJqZ8w9xgs01QdiZXKVjk92EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4.1.1", + "pug-walk": "^2.0.0" + } + }, + "node_modules/pug-parser": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/pug-parser/-/pug-parser-6.0.0.tgz", + "integrity": "sha512-ukiYM/9cH6Cml+AOl5kETtM9NR3WulyVP2y4HOU45DyMim1IeP/OOiyEWRr6qk5I5klpsBnbuHpwKmTx6WURnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pug-error": "^2.0.0", + "token-stream": "1.0.0" + } + }, + "node_modules/pug-runtime": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/pug-runtime/-/pug-runtime-3.0.1.tgz", + "integrity": "sha512-L50zbvrQ35TkpHwv0G6aLSuueDRwc/97XdY8kL3tOT0FmhgG7UypU3VztfV/LATAvmUfYi4wNxSajhSAeNN+Kg==", + "dev": true, + "license": "MIT" + }, + "node_modules/pug-strip-comments": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pug-strip-comments/-/pug-strip-comments-2.0.0.tgz", + "integrity": "sha512-zo8DsDpH7eTkPHCXFeAk1xZXJbyoTfdPlNR0bK7rpOMuhBYb0f5qUVCO1xlsitYd3w5FQTK7zpNVKb3rZoUrrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "pug-error": "^2.0.0" + } + }, + "node_modules/pug-walk": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pug-walk/-/pug-walk-2.0.0.tgz", + "integrity": "sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -3324,6 +5217,27 @@ ], "license": "MIT" }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -3331,6 +5245,23 @@ "dev": true, "license": "MIT" }, + "node_modules/repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/reprism": { + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/reprism/-/reprism-0.0.11.tgz", + "integrity": "sha512-VsxDR5QxZo08M/3nRypNlScw5r3rKeSOPdU/QhDmu3Ai3BJxHn/qgfXGWQp/tAxUtzwYNo9W6997JZR0tPLZsA==", + "dev": true, + "license": "MIT" + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -3395,6 +5326,41 @@ "node": ">=10" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -3473,6 +5439,13 @@ "source-map": "^0.6.0" } }, + "node_modules/spark-md5": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/spark-md5/-/spark-md5-3.0.2.tgz", + "integrity": "sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -3609,6 +5582,54 @@ "node": ">=8" } }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -3629,6 +5650,26 @@ "node": ">=8.0" } }, + "node_modules/token-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/token-stream/-/token-stream-1.0.0.tgz", + "integrity": "sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/ts-jest": { "version": "29.4.9", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.9.tgz", @@ -3715,6 +5756,19 @@ "dev": true, "license": "0BSD" }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -3773,6 +5827,16 @@ "dev": true, "license": "MIT" }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -3804,6 +5868,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", @@ -3832,6 +5906,16 @@ "node": ">=10.12.0" } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -3858,6 +5942,32 @@ "node": ">= 8" } }, + "node_modules/with": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/with/-/with-7.0.2.tgz", + "integrity": "sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.9.6", + "@babel/types": "^7.9.6", + "assert-never": "^1.2.1", + "babel-walk": "3.0.0-canary-5" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", diff --git a/typescript/package.json b/typescript/package.json index 828e071..b442314 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -12,7 +12,11 @@ "test:e2e": "jest --testPathPattern='tests/e2e/consumer' --runInBand --forceExit", "test:new-consumer": "jest --testPathPattern='tests/e2e/new-consumer' --runInBand --forceExit --verbose", "test:provider": "jest --testPathPattern='tests/e2e/provider' --runInBand --forceExit --verbose", - "lint": "tsc --noEmit" + "format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\"", + "lint:eslint": "eslint \"src/**/*.ts\"", + "typecheck": "tsc --noEmit", + "lint": "npm run format:check && npm run lint:eslint && npm run typecheck", + "duplication": "jscpd" }, "dependencies": { "uuid": "^9.0.0" @@ -21,11 +25,16 @@ "ethers": ">=5.0.0 <7.0.0" }, "devDependencies": { + "@typescript-eslint/eslint-plugin": "^8.59.1", + "@typescript-eslint/parser": "^8.59.1", "@types/jest": "^29.5.12", "@types/node": "^20.0.0", "@types/uuid": "^9.0.7", + "eslint": "^10.2.1", "ethers": "^6.13.0", "jest": "^29.7.0", + "jscpd": "^4.0.9", + "prettier": "^3.8.3", "ts-jest": "^29.1.4", "typescript": "^5.4.2" } diff --git a/typescript/src/auth.ts b/typescript/src/auth.ts index 2f88c67..bfdfd37 100644 --- a/typescript/src/auth.ts +++ b/typescript/src/auth.ts @@ -23,6 +23,28 @@ import { import { AuthenticationError } from "./errors"; import { resolveGatewayUrl } from "./config"; import { SynapseProvider } from "./provider"; +import { AuthCredentialContext, issueCredential } from "./auth_credentials"; +import { fetchJson } from "./http"; +import { + AuthProviderControlContext, + createProviderWithdrawalIntent, + deleteProviderSecret, + deleteProviderService, + getProviderEarningsSummary, + getProviderService, + getProviderServiceHealthHistory, + getProviderServiceStatus, + getProviderWithdrawalCapability, + getRegistrationGuide, + issueProviderSecret, + listProviderServices, + listProviderSecrets, + listProviderWithdrawals, + parseCurlToServiceManifest, + pingProviderService, + registerProviderService, + updateProviderService, +} from "./auth_provider_control"; type SignerFn = (message: string) => Promise; @@ -53,18 +75,6 @@ export class SynapseAuth { return new SynapseProvider(this); } - private defaultServiceId(serviceName: string): string { - const normalized = serviceName - .trim() - .toLowerCase() - .replace(/\s+/g, "_"); - const sanitized = normalized - .replace(/[^a-z0-9_-]+/g, "_") - .replace(/_+/g, "_") - .replace(/^_+|_+$/g, ""); - return sanitized || `service_${Date.now().toString(36)}`; - } - /** * Create a SynapseAuth instance from an ethers.js Wallet (v6 or v5). */ @@ -139,93 +149,22 @@ export class SynapseAuth { /** Issue a new agent credential and return its token + metadata. */ async issueCredential(opts: IssueCredentialOptions = {}): Promise { - const token = await this.getToken(); - const body: Record = {}; - if (opts.name) body["name"] = opts.name; - if (opts.maxCalls != null) body["maxCalls"] = opts.maxCalls; - if (opts.creditLimit != null) body["creditLimit"] = opts.creditLimit; - if (opts.resetInterval != null) body["resetInterval"] = opts.resetInterval; - if (opts.rpm != null) body["rpm"] = opts.rpm; - if (opts.expiresInSec != null) body["expiresInSec"] = opts.expiresInSec; - - const resp = await this._fetch>( - `${this.gatewayUrl}/api/v1/credentials/agent/issue`, - { - method: "POST", - headers: { Authorization: `Bearer ${token}` }, - body: JSON.stringify(body), - } - ); - - const credToken = - (resp["token"] as string) || - ((resp["credential"] as Record)?.["token"] as string) || - (resp["credential_token"] as string); - const credId = - (resp["credential_id"] as string) || - (resp["id"] as string) || - ((resp["credential"] as Record)?.["id"] as string) || - ((resp["credential"] as Record)?.["credential_id"] as string); - - if (!credToken) throw new AuthenticationError(`Credential token missing: ${JSON.stringify(resp)}`); - if (!credId) throw new AuthenticationError(`Credential ID missing: ${JSON.stringify(resp)}`); - - const credential: AgentCredential = { - id: credId, - credential_id: credId, - token: credToken, - name: opts.name, - status: "active", - ...((resp["credential"] as Record) ?? {}), - }; - - return { credential, token: credToken }; + return issueCredential(this.credentialContext(), opts); } /** Issue a provider control-plane secret for the current owner wallet. */ async issueProviderSecret(opts: IssueCredentialOptions = {}): Promise { - const token = await this.getToken(); - const body: Record = {}; - if (opts.name) body["name"] = opts.name; - if (opts.maxCalls != null) body["maxCalls"] = opts.maxCalls; - if (opts.creditLimit != null) body["creditLimit"] = opts.creditLimit; - if (opts.resetInterval != null) body["resetInterval"] = opts.resetInterval; - if (opts.rpm != null) body["rpm"] = opts.rpm; - if (opts.expiresInSec != null) body["expiresInSec"] = opts.expiresInSec; - - const resp = await this._fetch>( - `${this.gatewayUrl}/api/v1/secrets/provider/issue`, - { - method: "POST", - headers: { Authorization: `Bearer ${token}` }, - body: JSON.stringify(body), - } - ); - const secret = (resp["secret"] as ProviderSecret | undefined) ?? undefined; - if (!secret?.id) { - throw new AuthenticationError(`Provider secret payload missing: ${JSON.stringify(resp)}`); - } - return { status: resp["status"] as string | undefined, secret }; + return issueProviderSecret(this.providerControlContext(), opts); } /** List provider control-plane secrets for the current wallet. */ async listProviderSecrets(): Promise { - const token = await this.getToken(); - const resp = await this._fetch<{ secrets?: ProviderSecret[] }>( - `${this.gatewayUrl}/api/v1/secrets/provider/list`, - { headers: { Authorization: `Bearer ${token}` } } - ); - return resp.secrets ?? []; + return listProviderSecrets(this.providerControlContext()); } /** Delete a provider control-plane secret. */ async deleteProviderSecret(secretId: string): Promise> { - const token = await this.getToken(); - const id = this.requireValue(secretId, "secretId"); - return this._fetch>(`${this.gatewayUrl}/api/v1/secrets/provider/${encodeURIComponent(id)}`, { - method: "DELETE", - headers: { Authorization: `Bearer ${token}` }, - }); + return deleteProviderSecret(this.providerControlContext(), secretId); } /** List all agent credentials for this wallet. */ @@ -286,7 +225,14 @@ export class SynapseAuth { /** Update spend/call/rate quota fields for an agent credential. */ async updateCredentialQuota( credentialId: string, - opts: { maxCalls?: number; rpm?: number; creditLimit?: number; resetInterval?: string; expiresAt?: string | number; expiration?: number } = {} + opts: { + maxCalls?: number; + rpm?: number; + creditLimit?: number; + resetInterval?: string; + expiresAt?: string | number; + expiration?: number; + } = {} ): Promise> { const token = await this.getToken(); const id = this.requireValue(credentialId, "credentialId"); @@ -312,10 +258,9 @@ export class SynapseAuth { /** Get balance summary for this wallet. */ async getBalance(): Promise { const token = await this.getToken(); - const resp = await this._fetch<{ balance?: BalanceSummary }>( - `${this.gatewayUrl}/api/v1/balance`, - { headers: { Authorization: `Bearer ${token}` } } - ); + const resp = await this._fetch<{ balance?: BalanceSummary }>(`${this.gatewayUrl}/api/v1/balance`, { + headers: { Authorization: `Bearer ${token}` }, + }); return resp.balance ?? ({} as BalanceSummary); } @@ -330,31 +275,25 @@ export class SynapseAuth { ): Promise { const token = await this.getToken(); const idemKey = idempotencyKey ?? `deposit-${Date.now()}-${Math.random().toString(36).slice(2)}`; - const resp = await this._fetch( - `${this.gatewayUrl}/api/v1/balance/deposit/intent`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "X-Idempotency-Key": idemKey, - }, - body: JSON.stringify({ txHash, amountUsdc }), - } - ); + const resp = await this._fetch(`${this.gatewayUrl}/api/v1/balance/deposit/intent`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "X-Idempotency-Key": idemKey, + }, + body: JSON.stringify({ txHash, amountUsdc }), + }); return resp; } /** Confirm a previously registered deposit intent. */ async confirmDeposit(intentId: string, eventKey: string): Promise<{ status: string }> { const token = await this.getToken(); - return this._fetch<{ status: string }>( - `${this.gatewayUrl}/api/v1/balance/deposit/intents/${intentId}/confirm`, - { - method: "POST", - headers: { Authorization: `Bearer ${token}` }, - body: JSON.stringify({ eventKey, confirmations: 1 }), - } - ); + return this._fetch<{ status: string }>(`${this.gatewayUrl}/api/v1/balance/deposit/intents/${intentId}/confirm`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: JSON.stringify({ eventKey, confirmations: 1 }), + }); } /** @@ -367,9 +306,7 @@ export class SynapseAuth { method: "PUT", headers: { Authorization: `Bearer ${token}` }, body: JSON.stringify( - usdc === null - ? { allowUnlimited: true } - : { spendingLimitUsdc: usdc, allowUnlimited: false } + usdc === null ? { allowUnlimited: true } : { spendingLimitUsdc: usdc, allowUnlimited: false } ), }); } @@ -411,148 +348,41 @@ export class SynapseAuth { } /** Register a provider service using the minimum-contract onboarding shape. */ - async registerProviderService( - opts: RegisterProviderServiceOptions - ): Promise { - const serviceName = opts.serviceName?.trim(); - const endpointUrl = opts.endpointUrl?.trim(); - const description = opts.descriptionForModel?.trim(); - if (!serviceName) throw new Error("serviceName is required"); - if (!endpointUrl) throw new Error("endpointUrl is required"); - if (!description) throw new Error("descriptionForModel is required"); - - const token = await this.getToken(); - const serviceId = opts.serviceId?.trim() || this.defaultServiceId(serviceName); - const body = { - serviceId, - agentToolName: serviceId, - serviceName, - role: "Provider", - status: opts.status ?? "active", - isActive: opts.isActive ?? true, - pricing: { - amount: String(opts.basePriceUsdc), - currency: "USDC", - }, - summary: description, - tags: opts.tags ?? [], - auth: { type: "gateway_signed" }, - invoke: { - method: opts.endpointMethod ?? "POST", - targets: [{ url: endpointUrl }], - timeoutMs: opts.requestTimeoutMs ?? 15_000, - request: { - body: opts.inputSchema ?? { type: "object", properties: {}, required: [] }, - }, - response: { - body: opts.outputSchema ?? { type: "object", properties: {} }, - }, - }, - healthCheck: { - path: opts.healthPath ?? "/health", - method: opts.healthMethod ?? "GET", - timeoutMs: opts.healthTimeoutMs ?? 3_000, - successCodes: [200], - healthyThreshold: 1, - unhealthyThreshold: 3, - }, - providerProfile: { - displayName: opts.providerDisplayName?.trim() || serviceName, - }, - payoutAccount: { - payoutAddress: opts.payoutAddress?.trim() || this.walletAddress, - chainId: opts.chainId ?? 31337, - settlementCurrency: opts.settlementCurrency ?? "USDC", - }, - governance: { - termsAccepted: true, - riskAcknowledged: true, - note: opts.governanceNote ?? null, - }, - }; - - const resp = await this._fetch>( - `${this.gatewayUrl}/api/v1/services`, - { - method: "POST", - headers: { Authorization: `Bearer ${token}` }, - body: JSON.stringify(body), - } - ); - - const service = - (resp["service"] as ProviderServiceRecord | undefined) ?? - ({ serviceId } as ProviderServiceRecord); - const responseServiceId = - (resp["serviceId"] as string | undefined) ?? - service.serviceId ?? - service.id ?? - serviceId; - return { - status: resp["status"] as string | undefined, - serviceId: responseServiceId, - service, - }; + async registerProviderService(opts: RegisterProviderServiceOptions): Promise { + return registerProviderService(this.providerControlContext(), opts); } /** List provider-owned services from the control plane. */ async listProviderServices(): Promise { - const token = await this.getToken(); - const resp = await this._fetch<{ services?: ProviderServiceRecord[] }>( - `${this.gatewayUrl}/api/v1/services`, - { headers: { Authorization: `Bearer ${token}` } } - ); - return resp.services ?? []; + return listProviderServices(this.providerControlContext()); } /** Fetch the provider registration guide from the gateway control plane. */ async getRegistrationGuide(): Promise> { - const token = await this.getToken(); - return this._fetch>(`${this.gatewayUrl}/api/v1/services/registration-guide`, { - headers: { Authorization: `Bearer ${token}` }, - }); + return getRegistrationGuide(this.providerControlContext()); } /** Convert a curl command into a provider service manifest draft. */ async parseCurlToServiceManifest(curlCommand: string): Promise> { - const token = await this.getToken(); - const command = this.requireValue(curlCommand, "curlCommand"); - return this._fetch>(`${this.gatewayUrl}/api/v1/services/parse-curl`, { - method: "POST", - headers: { Authorization: `Bearer ${token}` }, - body: JSON.stringify({ curlCommand: command }), - }); + return parseCurlToServiceManifest(this.providerControlContext(), curlCommand); } /** Patch a provider service registration by gateway record ID. */ - async updateProviderService(serviceRecordId: string, patch: Record): Promise> { - const token = await this.getToken(); - const id = this.requireValue(serviceRecordId, "serviceRecordId"); - return this._fetch>(`${this.gatewayUrl}/api/v1/services/${encodeURIComponent(id)}`, { - method: "PUT", - headers: { Authorization: `Bearer ${token}` }, - body: JSON.stringify(patch ?? {}), - }); + async updateProviderService( + serviceRecordId: string, + patch: Record + ): Promise> { + return updateProviderService(this.providerControlContext(), serviceRecordId, patch); } /** Delete a provider service registration by gateway record ID. */ async deleteProviderService(serviceRecordId: string): Promise> { - const token = await this.getToken(); - const id = this.requireValue(serviceRecordId, "serviceRecordId"); - return this._fetch>(`${this.gatewayUrl}/api/v1/services/${encodeURIComponent(id)}`, { - method: "DELETE", - headers: { Authorization: `Bearer ${token}` }, - }); + return deleteProviderService(this.providerControlContext(), serviceRecordId); } /** Force a provider service health ping. */ async pingProviderService(serviceRecordId: string): Promise> { - const token = await this.getToken(); - const id = this.requireValue(serviceRecordId, "serviceRecordId"); - return this._fetch>(`${this.gatewayUrl}/api/v1/services/${encodeURIComponent(id)}/ping`, { - method: "POST", - headers: { Authorization: `Bearer ${token}` }, - }); + return pingProviderService(this.providerControlContext(), serviceRecordId); } /** Fetch health history for a provider service. */ @@ -560,28 +390,17 @@ export class SynapseAuth { serviceRecordId: string, opts: { limitPerTarget?: number } = {} ): Promise> { - const token = await this.getToken(); - const id = this.requireValue(serviceRecordId, "serviceRecordId"); - const url = this.withQuery(`${this.gatewayUrl}/api/v1/services/${encodeURIComponent(id)}/health/history`, { - limitPerTarget: opts.limitPerTarget ?? 100, - }); - return this._fetch>(url, { headers: { Authorization: `Bearer ${token}` } }); + return getProviderServiceHealthHistory(this.providerControlContext(), serviceRecordId, opts); } /** Return provider earnings summary for the authenticated owner. */ async getProviderEarningsSummary(): Promise> { - const token = await this.getToken(); - return this._fetch>(`${this.gatewayUrl}/api/v1/providers/earnings/summary`, { - headers: { Authorization: `Bearer ${token}` }, - }); + return getProviderEarningsSummary(this.providerControlContext()); } /** Return whether provider withdrawals are currently available. */ async getProviderWithdrawalCapability(): Promise> { - const token = await this.getToken(); - return this._fetch>(`${this.gatewayUrl}/api/v1/providers/withdrawals/capability`, { - headers: { Authorization: `Bearer ${token}` }, - }); + return getProviderWithdrawalCapability(this.providerControlContext()); } /** Create a provider withdrawal intent. This does not auto-submit funds on-chain. */ @@ -589,50 +408,47 @@ export class SynapseAuth { amountUsdc: number, opts: { idempotencyKey?: string; destinationAddress?: string } = {} ): Promise> { - const token = await this.getToken(); - const body: Record = { amountUsdc }; - if (opts.destinationAddress) body["destinationAddress"] = opts.destinationAddress; - return this._fetch>(`${this.gatewayUrl}/api/v1/providers/withdrawals/intent`, { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "X-Idempotency-Key": opts.idempotencyKey ?? `provider-withdraw-${Date.now()}-${Math.random().toString(36).slice(2)}`, - }, - body: JSON.stringify(body), - }); + return createProviderWithdrawalIntent(this.providerControlContext(), amountUsdc, opts); } /** List provider withdrawal records. */ async listProviderWithdrawals(opts: { limit?: number } = {}): Promise> { - const token = await this.getToken(); - const url = this.withQuery(`${this.gatewayUrl}/api/v1/providers/withdrawals`, { limit: opts.limit ?? 100 }); - return this._fetch>(url, { headers: { Authorization: `Bearer ${token}` } }); + return listProviderWithdrawals(this.providerControlContext(), opts); } /** Return one provider-owned service by serviceId. */ async getProviderService(serviceId: string): Promise { - const resolvedServiceId = serviceId?.trim(); - if (!resolvedServiceId) throw new Error("serviceId is required"); - const services = await this.listProviderServices(); - const found = services.find((service) => service.serviceId === resolvedServiceId); - if (!found) { - throw new AuthenticationError(`Provider service not found: ${resolvedServiceId}`); - } - return found; + return getProviderService(this.providerControlContext(), serviceId); } /** Read lifecycle + runtime status for a provider-owned service. */ async getProviderServiceStatus(serviceId: string): Promise { - const service = await this.getProviderService(serviceId); + return getProviderServiceStatus(this.providerControlContext(), serviceId); + } + + // ── Internal helpers ─────────────────────────────────────────────────────── + + private credentialContext(): AuthCredentialContext { return { - serviceId: service.serviceId ?? service.id ?? serviceId, - lifecycleStatus: service.status ?? "unknown", - runtimeAvailable: Boolean(service.runtimeAvailable), - health: (service.health ?? {}) as ProviderServiceStatus["health"], + gatewayUrl: this.gatewayUrl, + getToken: () => this.getToken(), + fetchJson: (url: string, init?: { method?: string; body?: string; headers?: Record }) => + this._fetch(url, init), }; } - // ── Internal helpers ─────────────────────────────────────────────────────── + private providerControlContext(): AuthProviderControlContext { + return { + gatewayUrl: this.gatewayUrl, + walletAddress: this.walletAddress, + getToken: () => this.getToken(), + fetchJson: (url: string, init?: { method?: string; body?: string; headers?: Record }) => + this._fetch(url, init), + requireValue: (value: string, name: string) => this.requireValue(value, name), + withQuery: (url: string, params: Record) => + this.withQuery(url, params), + }; + } private requireValue(value: string, name: string): string { const resolved = String(value ?? "").trim(); @@ -649,34 +465,14 @@ export class SynapseAuth { return query ? `${url}?${query}` : url; } - private async _fetch(url: string, init: { method?: string; body?: string; headers?: Record } = {}): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), this.timeoutMs); - + private async _fetch( + url: string, + init: { method?: string; body?: string; headers?: Record } = {} + ): Promise { const headers: Record = { "Content-Type": "application/json", ...Object.fromEntries(Object.entries(init.headers ?? {})), }; - - try { - const resp = await fetch(url, { - method: init.method ?? "GET", - body: init.body, - headers, - signal: controller.signal, - }); - const text = await resp.text(); - let data: unknown; - try { data = JSON.parse(text); } catch { data = { raw: text }; } - if (!resp.ok) { - const detail = (data as Record)?.["detail"]; - const msg = typeof detail === "string" ? detail : typeof detail === "object" && detail !== null - ? JSON.stringify(detail) : text; - throw new Error(`HTTP ${resp.status}: ${msg}`); - } - return data as T; - } finally { - clearTimeout(timer); - } + return fetchJson(url, { method: init.method, body: init.body, headers, timeoutMs: this.timeoutMs }); } } diff --git a/typescript/src/auth_credentials.ts b/typescript/src/auth_credentials.ts new file mode 100644 index 0000000..46b096a --- /dev/null +++ b/typescript/src/auth_credentials.ts @@ -0,0 +1,64 @@ +import { AgentCredential, IssueCredentialOptions, IssueCredentialResult } from "./types"; +import { AuthenticationError } from "./errors"; + +type FetchJson = ( + url: string, + init?: { method?: string; body?: string; headers?: Record } +) => Promise; + +export interface AuthCredentialContext { + gatewayUrl: string; + getToken: () => Promise; + fetchJson: FetchJson; +} + +function credentialOptionsBody(opts: IssueCredentialOptions): Record { + const body: Record = {}; + if (opts.name) body["name"] = opts.name; + if (opts.maxCalls != null) body["maxCalls"] = opts.maxCalls; + if (opts.creditLimit != null) body["creditLimit"] = opts.creditLimit; + if (opts.resetInterval != null) body["resetInterval"] = opts.resetInterval; + if (opts.rpm != null) body["rpm"] = opts.rpm; + if (opts.expiresInSec != null) body["expiresInSec"] = opts.expiresInSec; + return body; +} + +export async function issueCredential( + ctx: AuthCredentialContext, + opts: IssueCredentialOptions = {} +): Promise { + const token = await ctx.getToken(); + const resp = await ctx.fetchJson>(`${ctx.gatewayUrl}/api/v1/credentials/agent/issue`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: JSON.stringify(credentialOptionsBody(opts)), + }); + + const credentialPayload = (resp["credential"] as Record) ?? {}; + const credToken = firstString([resp["token"], credentialPayload["token"], resp["credential_token"]]); + const credId = firstString([ + resp["credential_id"], + resp["id"], + credentialPayload["id"], + credentialPayload["credential_id"], + ]); + + if (!credToken) throw new AuthenticationError(`Credential token missing: ${JSON.stringify(resp)}`); + if (!credId) throw new AuthenticationError(`Credential ID missing: ${JSON.stringify(resp)}`); + + const credential: AgentCredential = { + id: credId, + credential_id: credId, + token: credToken, + name: opts.name, + status: "active", + ...credentialPayload, + }; + + return { credential, token: credToken }; +} + +function firstString(values: unknown[]): string | null { + const found = values.find((value) => typeof value === "string" && value.length > 0); + return (found as string | undefined) ?? null; +} diff --git a/typescript/src/auth_provider_control.ts b/typescript/src/auth_provider_control.ts new file mode 100644 index 0000000..bb1d5e6 --- /dev/null +++ b/typescript/src/auth_provider_control.ts @@ -0,0 +1,344 @@ +import { + RegisterProviderServiceOptions, + RegisterProviderServiceResult, + ProviderServiceRecord, + ProviderServiceStatus, + IssueCredentialOptions, + IssueProviderSecretResult, + ProviderSecret, +} from "./types"; +import { AuthenticationError } from "./errors"; + +type FetchJson = ( + url: string, + init?: { method?: string; body?: string; headers?: Record } +) => Promise; + +export interface AuthProviderControlContext { + gatewayUrl: string; + walletAddress: string; + getToken: () => Promise; + fetchJson: FetchJson; + requireValue: (value: string, name: string) => string; + withQuery: (url: string, params: Record) => string; +} + +function credentialOptionsBody(opts: IssueCredentialOptions): Record { + const body: Record = {}; + if (opts.name) body["name"] = opts.name; + if (opts.maxCalls != null) body["maxCalls"] = opts.maxCalls; + if (opts.creditLimit != null) body["creditLimit"] = opts.creditLimit; + if (opts.resetInterval != null) body["resetInterval"] = opts.resetInterval; + if (opts.rpm != null) body["rpm"] = opts.rpm; + if (opts.expiresInSec != null) body["expiresInSec"] = opts.expiresInSec; + return body; +} + +export async function issueProviderSecret( + ctx: AuthProviderControlContext, + opts: IssueCredentialOptions = {} +): Promise { + const token = await ctx.getToken(); + const resp = await ctx.fetchJson>(`${ctx.gatewayUrl}/api/v1/secrets/provider/issue`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: JSON.stringify(credentialOptionsBody(opts)), + }); + const secret = (resp["secret"] as ProviderSecret | undefined) ?? undefined; + if (!secret?.id) { + throw new AuthenticationError(`Provider secret payload missing: ${JSON.stringify(resp)}`); + } + return { status: resp["status"] as string | undefined, secret }; +} + +export async function listProviderSecrets(ctx: AuthProviderControlContext): Promise { + const token = await ctx.getToken(); + const resp = await ctx.fetchJson<{ secrets?: ProviderSecret[] }>(`${ctx.gatewayUrl}/api/v1/secrets/provider/list`, { + headers: { Authorization: `Bearer ${token}` }, + }); + return resp.secrets ?? []; +} + +export async function deleteProviderSecret( + ctx: AuthProviderControlContext, + secretId: string +): Promise> { + const token = await ctx.getToken(); + const id = ctx.requireValue(secretId, "secretId"); + return ctx.fetchJson>(`${ctx.gatewayUrl}/api/v1/secrets/provider/${encodeURIComponent(id)}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }); +} + +function defaultServiceId(serviceName: string): string { + const normalized = serviceName.trim().toLowerCase().replace(/\s+/g, "_"); + const sanitized = normalized + .replace(/[^a-z0-9_-]+/g, "_") + .replace(/_+/g, "_") + .replace(/^_+|_+$/g, ""); + return sanitized || `service_${Date.now().toString(36)}`; +} + +function requiredTrim(value: string | undefined, name: string): string { + const resolved = String(value ?? "").trim(); + if (!resolved) throw new Error(`${name} is required`); + return resolved; +} + +function optionalTrim(value: string | undefined): string | null { + const resolved = String(value ?? "").trim(); + return resolved.length > 0 ? resolved : null; +} + +function valueOr(value: T | null | undefined, fallback: T): T { + return value === null || value === undefined ? fallback : value; +} + +function providerServiceValues(opts: RegisterProviderServiceOptions) { + const serviceName = requiredTrim(opts.serviceName, "serviceName"); + const endpointUrl = requiredTrim(opts.endpointUrl, "endpointUrl"); + return { + serviceName, + endpointUrl, + description: requiredTrim(opts.descriptionForModel, "descriptionForModel"), + serviceId: optionalTrim(opts.serviceId) ?? defaultServiceId(serviceName), + }; +} + +function providerServiceBody(ctx: AuthProviderControlContext, opts: RegisterProviderServiceOptions) { + const service = providerServiceValues(opts); + return { + serviceId: service.serviceId, + agentToolName: service.serviceId, + serviceName: service.serviceName, + role: "Provider", + status: valueOr(opts.status, "active"), + isActive: valueOr(opts.isActive, true), + pricing: { + amount: String(opts.basePriceUsdc), + currency: "USDC", + }, + summary: service.description, + tags: valueOr(opts.tags, []), + auth: { type: "gateway_signed" }, + invoke: providerInvokeConfig(service.endpointUrl, opts), + healthCheck: providerHealthCheck(opts), + providerProfile: providerProfile(opts.providerDisplayName, service.serviceName), + payoutAccount: providerPayoutAccount(ctx, opts), + governance: { + termsAccepted: true, + riskAcknowledged: true, + note: valueOr(opts.governanceNote, null), + }, + }; +} + +function providerInvokeConfig(endpointUrl: string, opts: RegisterProviderServiceOptions) { + return { + method: valueOr(opts.endpointMethod, "POST"), + targets: [{ url: endpointUrl }], + timeoutMs: valueOr(opts.requestTimeoutMs, 15_000), + request: { body: valueOr(opts.inputSchema, { type: "object", properties: {}, required: [] }) }, + response: { body: valueOr(opts.outputSchema, { type: "object", properties: {} }) }, + }; +} + +function providerHealthCheck(opts: RegisterProviderServiceOptions) { + return { + path: valueOr(opts.healthPath, "/health"), + method: valueOr(opts.healthMethod, "GET"), + timeoutMs: valueOr(opts.healthTimeoutMs, 3_000), + successCodes: [200], + healthyThreshold: 1, + unhealthyThreshold: 3, + }; +} + +function providerProfile(providerDisplayName: string | undefined, serviceName: string) { + return { displayName: optionalTrim(providerDisplayName) ?? serviceName }; +} + +function providerPayoutAccount(ctx: AuthProviderControlContext, opts: RegisterProviderServiceOptions) { + return { + payoutAddress: optionalTrim(opts.payoutAddress) ?? ctx.walletAddress, + chainId: valueOr(opts.chainId, 31337), + settlementCurrency: valueOr(opts.settlementCurrency, "USDC"), + }; +} + +export async function registerProviderService( + ctx: AuthProviderControlContext, + opts: RegisterProviderServiceOptions +): Promise { + const body = providerServiceBody(ctx, opts); + const token = await ctx.getToken(); + const resp = await ctx.fetchJson>(`${ctx.gatewayUrl}/api/v1/services`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: JSON.stringify(body), + }); + + const service = + (resp["service"] as ProviderServiceRecord | undefined) ?? + ({ + serviceId: body.serviceId, + } as ProviderServiceRecord); + const responseServiceId = + (resp["serviceId"] as string | undefined) ?? service.serviceId ?? service.id ?? body.serviceId; + return { + status: resp["status"] as string | undefined, + serviceId: responseServiceId, + service, + }; +} + +export async function listProviderServices(ctx: AuthProviderControlContext): Promise { + const token = await ctx.getToken(); + const resp = await ctx.fetchJson<{ services?: ProviderServiceRecord[] }>(`${ctx.gatewayUrl}/api/v1/services`, { + headers: { Authorization: `Bearer ${token}` }, + }); + return resp.services ?? []; +} + +export async function getRegistrationGuide(ctx: AuthProviderControlContext): Promise> { + const token = await ctx.getToken(); + return ctx.fetchJson>(`${ctx.gatewayUrl}/api/v1/services/registration-guide`, { + headers: { Authorization: `Bearer ${token}` }, + }); +} + +export async function parseCurlToServiceManifest( + ctx: AuthProviderControlContext, + curlCommand: string +): Promise> { + const token = await ctx.getToken(); + const command = ctx.requireValue(curlCommand, "curlCommand"); + return ctx.fetchJson>(`${ctx.gatewayUrl}/api/v1/services/parse-curl`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: JSON.stringify({ curlCommand: command }), + }); +} + +export async function updateProviderService( + ctx: AuthProviderControlContext, + serviceRecordId: string, + patch: Record +): Promise> { + const token = await ctx.getToken(); + const id = ctx.requireValue(serviceRecordId, "serviceRecordId"); + return ctx.fetchJson>(`${ctx.gatewayUrl}/api/v1/services/${encodeURIComponent(id)}`, { + method: "PUT", + headers: { Authorization: `Bearer ${token}` }, + body: JSON.stringify(patch ?? {}), + }); +} + +export async function deleteProviderService( + ctx: AuthProviderControlContext, + serviceRecordId: string +): Promise> { + const token = await ctx.getToken(); + const id = ctx.requireValue(serviceRecordId, "serviceRecordId"); + return ctx.fetchJson>(`${ctx.gatewayUrl}/api/v1/services/${encodeURIComponent(id)}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }); +} + +export async function pingProviderService( + ctx: AuthProviderControlContext, + serviceRecordId: string +): Promise> { + const token = await ctx.getToken(); + const id = ctx.requireValue(serviceRecordId, "serviceRecordId"); + return ctx.fetchJson>(`${ctx.gatewayUrl}/api/v1/services/${encodeURIComponent(id)}/ping`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + }); +} + +export async function getProviderServiceHealthHistory( + ctx: AuthProviderControlContext, + serviceRecordId: string, + opts: { limitPerTarget?: number } = {} +): Promise> { + const token = await ctx.getToken(); + const id = ctx.requireValue(serviceRecordId, "serviceRecordId"); + const url = ctx.withQuery(`${ctx.gatewayUrl}/api/v1/services/${encodeURIComponent(id)}/health/history`, { + limitPerTarget: opts.limitPerTarget ?? 100, + }); + return ctx.fetchJson>(url, { headers: { Authorization: `Bearer ${token}` } }); +} + +export async function getProviderEarningsSummary(ctx: AuthProviderControlContext): Promise> { + const token = await ctx.getToken(); + return ctx.fetchJson>(`${ctx.gatewayUrl}/api/v1/providers/earnings/summary`, { + headers: { Authorization: `Bearer ${token}` }, + }); +} + +export async function getProviderWithdrawalCapability( + ctx: AuthProviderControlContext +): Promise> { + const token = await ctx.getToken(); + return ctx.fetchJson>(`${ctx.gatewayUrl}/api/v1/providers/withdrawals/capability`, { + headers: { Authorization: `Bearer ${token}` }, + }); +} + +export async function createProviderWithdrawalIntent( + ctx: AuthProviderControlContext, + amountUsdc: number, + opts: { idempotencyKey?: string; destinationAddress?: string } = {} +): Promise> { + const token = await ctx.getToken(); + const body: Record = { amountUsdc }; + if (opts.destinationAddress) body["destinationAddress"] = opts.destinationAddress; + return ctx.fetchJson>(`${ctx.gatewayUrl}/api/v1/providers/withdrawals/intent`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "X-Idempotency-Key": + opts.idempotencyKey ?? `provider-withdraw-${Date.now()}-${Math.random().toString(36).slice(2)}`, + }, + body: JSON.stringify(body), + }); +} + +export async function listProviderWithdrawals( + ctx: AuthProviderControlContext, + opts: { limit?: number } = {} +): Promise> { + const token = await ctx.getToken(); + const url = ctx.withQuery(`${ctx.gatewayUrl}/api/v1/providers/withdrawals`, { limit: opts.limit ?? 100 }); + return ctx.fetchJson>(url, { headers: { Authorization: `Bearer ${token}` } }); +} + +export async function getProviderService( + ctx: AuthProviderControlContext, + serviceId: string +): Promise { + const resolvedServiceId = serviceId?.trim(); + if (!resolvedServiceId) throw new Error("serviceId is required"); + const services = await listProviderServices(ctx); + const found = services.find((service) => service.serviceId === resolvedServiceId); + if (!found) { + throw new AuthenticationError(`Provider service not found: ${resolvedServiceId}`); + } + return found; +} + +export async function getProviderServiceStatus( + ctx: AuthProviderControlContext, + serviceId: string +): Promise { + const service = await getProviderService(ctx, serviceId); + return { + serviceId: service.serviceId ?? service.id ?? serviceId, + lifecycleStatus: service.status ?? "unknown", + runtimeAvailable: Boolean(service.runtimeAvailable), + health: (service.health ?? {}) as ProviderServiceStatus["health"], + }; +} diff --git a/typescript/src/client.ts b/typescript/src/client.ts index f015c0d..b6225ef 100644 --- a/typescript/src/client.ts +++ b/typescript/src/client.ts @@ -23,6 +23,7 @@ import { TimeoutError, PriceMismatchError, } from "./errors"; +import { fetchJson, HttpErrorContext } from "./http"; export class SynapseClient { private readonly credential: string; @@ -51,26 +52,14 @@ export class SynapseClient { /** Search for services by text query. */ async search(query: string, opts: DiscoverOptions = {}): Promise { - const pageSize = Math.max(1, opts.limit ?? 20); - const offset = Math.max(0, opts.offset ?? 0); - const page = Math.floor(offset / pageSize) + 1; try { const resp = await this._fetch<{ services?: ServiceRecord[]; results?: ServiceRecord[] }>( `${this.gatewayUrl}/api/v1/agent/discovery/search`, - { - method: "POST", - body: JSON.stringify({ - query: query.trim() || undefined, - tags: opts.tags ?? [], - page, - pageSize, - sort: opts.sort ?? "best_match", - }), - } + discoveryRequest(query, opts) ); - return resp.services ?? resp.results ?? (Array.isArray(resp) ? resp : []); + return discoveryServices(resp); } catch (err) { - throw new DiscoveryError(String(err instanceof Error ? err.message : err)); + throw discoveryError(err); } } @@ -81,7 +70,7 @@ export class SynapseClient { try { const resp = await fetch(`${this.gatewayUrl}/health`, { signal: controller.signal }); const text = await resp.text(); - const data = text ? JSON.parse(text) as Record : {}; + const data = text ? (JSON.parse(text) as Record) : {}; if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${text}`); return data; } finally { @@ -121,39 +110,15 @@ export class SynapseClient { payload: Record = {}, opts: InvokeOptions ): Promise { - const idempotencyKey = opts.idempotencyKey ?? uuidv4(); - const requestHeaders: Record = {}; - if (opts.requestId) requestHeaders["X-Request-Id"] = opts.requestId; - - let resp: Record; try { - resp = await this._fetch>( + const resp = await this._fetch>( `${this.gatewayUrl}/api/v1/agent/invoke`, - { - method: "POST", - extraHeaders: requestHeaders, - body: JSON.stringify({ - serviceId, - idempotencyKey, - costUsdc: opts.costUsdc, - payload: { body: payload }, - responseMode: opts.responseMode ?? "sync", - }), - } + invokeRequest(serviceId, payload, opts) ); + return this.completeInvocation(resp, opts); } catch (err) { - if ( - err instanceof AuthenticationError || - err instanceof InsufficientFundsError || - err instanceof PriceMismatchError - ) throw err; - throw new InvokeError(String(err instanceof Error ? err.message : err)); + throw invokeError(err); } - - const result = this._parseInvocationResponse(resp); - if (TERMINAL_STATUSES.has(result.status)) return result; - if (opts.pollTimeoutMs === 0) return result; - return this.waitForInvocation(result.invocationId, opts); } /** @@ -175,11 +140,7 @@ export class SynapseClient { throw err; } - let livePrice = err.currentPriceUsdc; - const services = await this.search(opts.query ?? serviceId, { limit: 10, tags: opts.tags }); - const matched = services.find((service) => (service.serviceId ?? service.id) === serviceId); - const discoveredPrice = matched ? this.extractServicePrice(matched) : null; - if (discoveredPrice != null) livePrice = discoveredPrice; + const livePrice = await this.rediscoveredPrice(serviceId, err.currentPriceUsdc, opts); if (!livePrice || livePrice <= 0) throw err; return this.invoke(serviceId, payload, { @@ -222,39 +183,8 @@ export class SynapseClient { // ── Internal helpers ─────────────────────────────────────────────────────── - private extractServicePrice(service: ServiceRecord): number | null { - const pricing = service.pricing; - if (typeof pricing === "string" || typeof pricing === "number") { - const parsed = Number(pricing); - return Number.isFinite(parsed) ? parsed : null; - } - if (pricing && typeof pricing === "object") { - const parsed = Number((pricing as { amount?: unknown }).amount); - return Number.isFinite(parsed) ? parsed : null; - } - return null; - } - private _parseInvocationResponse(resp: Record): InvocationResult { - const invocationId = - (resp["invocationId"] as string) || - (resp["id"] as string) || - (resp["invocation_id"] as string) || - ""; - const status = - ((resp["status"] as string) || "PENDING") as InvocationStatus; - const chargedUsdc = Number(resp["chargedUsdc"] ?? resp["charged_usdc"] ?? 0); - - return { - invocationId, - status, - chargedUsdc, - result: resp["result"] ?? null, - error: (resp["error"] as Record) ?? null, - receipt: (resp["receipt"] as Record) ?? null, - quoteId: (resp["quoteId"] as string) ?? undefined, - ...resp, - }; + return parseInvocationResponse(resp); } private async _fetch( @@ -265,52 +195,158 @@ export class SynapseClient { extraHeaders?: Record; } = {} ): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), this.timeoutMs); - const headers: Record = { "Content-Type": "application/json", "X-Credential": this.credential, ...(init.extraHeaders ?? {}), }; + return fetchJson( + url, + { method: init.method, body: init.body, headers, timeoutMs: this.timeoutMs }, + clientHttpError + ); + } - try { - const resp = await fetch(url, { - method: init.method ?? "GET", - body: init.body, - headers, - signal: controller.signal, - }); - const text = await resp.text(); - let data: unknown; - try { data = JSON.parse(text); } catch { data = { raw: text }; } - - if (!resp.ok) { - const d = data as Record; - const detail = d?.["detail"]; - const msg = typeof detail === "string" ? detail - : typeof detail === "object" && detail !== null ? JSON.stringify(detail) - : text; - const detailObj = typeof detail === "object" && detail !== null ? detail as Record : null; - - if (resp.status === 401) throw new AuthenticationError(`401: ${msg}`); - if (resp.status === 402) throw new InsufficientFundsError(`402: ${msg}`); - if (resp.status === 422 && detailObj?.["code"] === "PRICE_MISMATCH") { - throw new PriceMismatchError( - String(detailObj["message"] ?? msg), - Number(detailObj["expectedPriceUsdc"] ?? 0), - Number(detailObj["currentPriceUsdc"] ?? 0) - ); - } - throw new Error(`HTTP ${resp.status}: ${msg}`); - } - return data as T; - } finally { - clearTimeout(timer); - } + private async rediscoveredPrice( + serviceId: string, + fallbackPrice: number | null, + opts: InvokeOptions & { query?: string; tags?: string[] } + ): Promise { + const services = await this.search(opts.query ?? serviceId, { limit: 10, tags: opts.tags }); + const matched = services.find((service) => serviceKey(service) === serviceId); + return matched ? (extractServicePrice(matched) ?? fallbackPrice) : fallbackPrice; + } + + private completeInvocation( + resp: Record, + opts: InvokeOptions + ): Promise | InvocationResult { + const result = this._parseInvocationResponse(resp); + if (TERMINAL_STATUSES.has(result.status)) return result; + if (opts.pollTimeoutMs === 0) return result; + return this.waitForInvocation(result.invocationId, opts); } } function _sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } + +function discoveryServices( + resp: { services?: ServiceRecord[]; results?: ServiceRecord[] } | ServiceRecord[] +): ServiceRecord[] { + if (Array.isArray(resp)) return resp; + return resp.services ?? resp.results ?? []; +} + +function discoveryRequest(query: string, opts: DiscoverOptions) { + const pageSize = Math.max(1, opts.limit ?? 20); + const offset = Math.max(0, opts.offset ?? 0); + return { + method: "POST", + body: JSON.stringify({ + query: query.trim() || undefined, + tags: opts.tags ?? [], + page: Math.floor(offset / pageSize) + 1, + pageSize, + sort: opts.sort ?? "best_match", + }), + }; +} + +function discoveryError(err: unknown): DiscoveryError { + return new DiscoveryError(String(err instanceof Error ? err.message : err)); +} + +function invokeRequest(serviceId: string, payload: Record, opts: InvokeOptions) { + return { + method: "POST", + extraHeaders: requestHeaders(opts.requestId), + body: JSON.stringify({ + serviceId, + idempotencyKey: opts.idempotencyKey ?? uuidv4(), + costUsdc: opts.costUsdc, + payload: { body: payload }, + responseMode: opts.responseMode ?? "sync", + }), + }; +} + +function requestHeaders(requestId: string | undefined): Record { + return requestId ? { "X-Request-Id": requestId } : {}; +} + +function isInvokePassthroughError(err: unknown): boolean { + return ( + err instanceof AuthenticationError || err instanceof InsufficientFundsError || err instanceof PriceMismatchError + ); +} + +function invokeError(err: unknown): Error { + return isInvokePassthroughError(err) + ? (err as Error) + : new InvokeError(String(err instanceof Error ? err.message : err)); +} + +function serviceKey(service: ServiceRecord): string | undefined { + return service.serviceId ?? service.id; +} + +function extractServicePrice(service: ServiceRecord): number | null { + const pricing = service.pricing; + if (typeof pricing === "string" || typeof pricing === "number") return finiteNumber(pricing); + if (pricing && typeof pricing === "object") return finiteNumber((pricing as { amount?: unknown }).amount); + return null; +} + +function finiteNumber(value: unknown): number | null { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function parseInvocationResponse(resp: Record): InvocationResult { + return { + invocationId: firstString([resp["invocationId"], resp["id"], resp["invocation_id"]]) ?? "", + status: (firstString([resp["status"]]) ?? "PENDING") as InvocationStatus, + chargedUsdc: Number(firstPresent([resp["chargedUsdc"], resp["charged_usdc"], 0])), + result: firstPresent([resp["result"], null]), + error: firstPresent([resp["error"], null]) as Record | null, + receipt: firstPresent([resp["receipt"], null]) as Record | null, + quoteId: firstString([resp["quoteId"]]) ?? undefined, + ...resp, + }; +} + +function firstString(values: unknown[]): string | null { + const found = values.find((value) => typeof value === "string" && value.length > 0); + return (found as string | undefined) ?? null; +} + +function firstPresent(values: unknown[]): unknown { + const found = values.find((value) => value !== null && value !== undefined); + return found === undefined ? values.at(-1) : found; +} + +function clientHttpError(context: HttpErrorContext): Error | null { + if (context.status === 401) return new AuthenticationError(`401: ${context.message}`); + if (context.status === 402) return new InsufficientFundsError(`402: ${context.message}`); + if (isPriceMismatchContext(context)) return priceMismatchError(context); + return null; +} + +function isPriceMismatchContext(context: HttpErrorContext): boolean { + return context.status === 422 && detailObject(context.detail)?.["code"] === "PRICE_MISMATCH"; +} + +function priceMismatchError(context: HttpErrorContext): PriceMismatchError { + const detail = detailObject(context.detail) ?? {}; + return new PriceMismatchError( + String(detail["message"] ?? context.message), + Number(detail["expectedPriceUsdc"] ?? 0), + Number(detail["currentPriceUsdc"] ?? 0) + ); +} + +function detailObject(detail: unknown): Record | null { + return typeof detail === "object" && detail !== null ? (detail as Record) : null; +} diff --git a/typescript/src/config.ts b/typescript/src/config.ts index 16b0608..fd07843 100644 --- a/typescript/src/config.ts +++ b/typescript/src/config.ts @@ -8,10 +8,12 @@ export const GATEWAY_URLS: Record = { prod: "https://api.synapse-network.ai", }; -export function resolveGatewayUrl(opts: { - environment?: SynapseEnvironment; - gatewayUrl?: string; -} = {}): string { +export function resolveGatewayUrl( + opts: { + environment?: SynapseEnvironment; + gatewayUrl?: string; + } = {} +): string { const explicitUrl = opts.gatewayUrl?.trim(); if (explicitUrl) return explicitUrl.replace(/\/+$/, ""); diff --git a/typescript/src/http.ts b/typescript/src/http.ts new file mode 100644 index 0000000..a0e0842 --- /dev/null +++ b/typescript/src/http.ts @@ -0,0 +1,60 @@ +export interface FetchJsonInit { + method?: string; + body?: string; + headers?: Record; + timeoutMs: number; +} + +export interface HttpErrorContext { + status: number; + text: string; + detail: unknown; + message: string; +} + +export type HttpErrorMapper = (context: HttpErrorContext) => Error | null; + +export async function fetchJson( + url: string, + init: FetchJsonInit, + mapError: HttpErrorMapper = () => null +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), init.timeoutMs); + try { + const resp = await fetch(url, { + method: init.method ?? "GET", + body: init.body, + headers: init.headers ?? {}, + signal: controller.signal, + }); + const text = await resp.text(); + const data = parseJsonText(text); + if (!resp.ok) { + throw mappedHttpError(resp.status, text, data, mapError); + } + return data as T; + } finally { + clearTimeout(timer); + } +} + +function parseJsonText(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return { raw: text }; + } +} + +function mappedHttpError(status: number, text: string, data: unknown, mapError: HttpErrorMapper): Error { + const detail = (data as Record)?.["detail"]; + const message = detailMessage(detail, text); + return mapError({ status, text, detail, message }) ?? new Error(`HTTP ${status}: ${message}`); +} + +function detailMessage(detail: unknown, text: string): string { + if (typeof detail === "string") return detail; + if (typeof detail === "object" && detail !== null) return JSON.stringify(detail); + return text; +} diff --git a/typescript/src/types.ts b/typescript/src/types.ts index 90da3e8..fcd3b3c 100644 --- a/typescript/src/types.ts +++ b/typescript/src/types.ts @@ -202,13 +202,7 @@ export interface DiscoverOptions { // ── Invocation ─────────────────────────────────────────────────────────────── -export type InvocationStatus = - | "PENDING" - | "PROCESSING" - | "SUCCEEDED" - | "SETTLED" - | "FAILED_RETRYABLE" - | "FAILED_FINAL"; +export type InvocationStatus = "PENDING" | "PROCESSING" | "SUCCEEDED" | "SETTLED" | "FAILED_RETRYABLE" | "FAILED_FINAL"; export const TERMINAL_STATUSES = new Set([ "SUCCEEDED", diff --git a/typescript/tests/e2e/consumer.test.ts b/typescript/tests/e2e/consumer.test.ts index 98751a6..1651463 100644 --- a/typescript/tests/e2e/consumer.test.ts +++ b/typescript/tests/e2e/consumer.test.ts @@ -30,12 +30,12 @@ const MOCK_PROVIDER_PORT = 9199; // Hardhat default keys (localhost only) const DEPLOYER_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; -const OWNER_KEY = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"; +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 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 DEPOSIT_USDC = 10; @@ -81,7 +81,11 @@ async function doFetch(url: string, init: RequestInit = {}): Promise; } @@ -181,7 +185,6 @@ async function registerTestService( // ── Test Suite ──────────────────────────────────────────────────────────────── describe("Synapse TypeScript SDK — Consumer E2E Pipeline", () => { - // ── Suite Setup ──────────────────────────────────────────────────────────── beforeAll(async () => { @@ -190,7 +193,7 @@ describe("Synapse TypeScript SDK — Consumer E2E Pipeline", () => { // 2. Setup ethers wallets const rpcProvider = new JsonRpcProvider(RPC_URL); - const ownerWallet = new Wallet(OWNER_KEY, rpcProvider); + const ownerWallet = new Wallet(OWNER_KEY, rpcProvider); const providerWallet = new Wallet(PROVIDER_KEY, rpcProvider); const deployerWallet = new Wallet(DEPLOYER_KEY, rpcProvider); @@ -298,11 +301,10 @@ describe("Synapse TypeScript SDK — Consumer E2E Pipeline", () => { 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; + 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); }); }); @@ -351,11 +353,15 @@ describe("Synapse TypeScript SDK — Consumer E2E Pipeline", () => { 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, - }); + 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); diff --git a/typescript/tests/e2e/new-consumer.test.ts b/typescript/tests/e2e/new-consumer.test.ts index 82fc32c..913a6ec 100644 --- a/typescript/tests/e2e/new-consumer.test.ts +++ b/typescript/tests/e2e/new-consumer.test.ts @@ -19,13 +19,7 @@ * Run: cd sdk/typescript && npm run test:new-consumer */ -import { - Wallet, - JsonRpcProvider, - Contract, - parseUnits, - parseEther, -} from "ethers"; +import { Wallet, JsonRpcProvider, Contract, parseUnits, parseEther } from "ethers"; import { v4 as uuidv4 } from "uuid"; import * as fs from "fs"; import * as path from "path"; @@ -36,34 +30,34 @@ 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 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 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 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}`; +const SERVICE_NAME = `nc_e2e_svc_${SESSION_ID}`; +const CRED_NAME = `nc-cred-${SESSION_ID}`; // ── Shared state ────────────────────────────────────────────────────────────── -let freshAuth: SynapseAuth; +let freshAuth: SynapseAuth; let providerAuth: SynapseAuth; -let client: SynapseClient; -let agentToken: string; -let serviceId: string; +let client: SynapseClient; +let agentToken: string; +let serviceId: string; let discoveredServiceId: string; -let mockServer: http.Server; +let mockServer: http.Server; let balanceBeforeInvocations: number; // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -103,7 +97,11 @@ async function doFetch( }); const text = await resp.text(); let data: unknown; - try { data = JSON.parse(text); } catch { data = { raw: text }; } + 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; } @@ -118,14 +116,14 @@ async function fundAndDeposit( 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 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 usdc = new Contract(config.MockUSDC, usdcAbi, deployerWallet); const core = new Contract(config.SynapseCore, coreAbi, freshWallet); - const decimals = Number(await usdc.decimals()); + const decimals = Number(await usdc.decimals()); const amountWei = parseUnits(String(amountUsdc), decimals); // Fetch deployer's current pending nonce ONCE and manage manually to @@ -147,7 +145,7 @@ async function fundAndDeposit( await mintTx.wait(); // (c) Verify USDC balance - const usdcBal = await (usdc as Contract).balanceOf(freshWallet.address) as bigint; + 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"); @@ -158,9 +156,9 @@ async function fundAndDeposit( // (d) Approve SynapseCore console.log(` → Approving SynapseCore for ${amountUsdc} USDC`); - const approveTx = await (usdc.connect(freshWallet) as Contract).approve( - config.SynapseCore, amountWei, { nonce: freshNonce++ } - ); + const approveTx = await (usdc.connect(freshWallet) as Contract).approve(config.SynapseCore, amountWei, { + nonce: freshNonce++, + }); await approveTx.wait(); // (e) On-chain deposit @@ -190,7 +188,7 @@ async function registerTestService( invoke: { method: "POST", targets: [{ url: mockServiceUrl }], - request: { body: { type: "object", properties: { prompt: { type: "string" } } } }, + request: { body: { type: "object", properties: { prompt: { type: "string" } } } }, response: { body: { type: "object", properties: { result: { type: "string" } } } }, }, healthCheck: { @@ -218,10 +216,10 @@ async function registerTestService( const svcId = (resp["serviceId"] as string) || - (resp["id"] 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) || + ((resp["service"] as Record)?.["id"] as string) || SERVICE_NAME; return svcId; @@ -230,7 +228,6 @@ async function registerTestService( // ── Test Suite ──────────────────────────────────────────────────────────────── describe("Synapse TS SDK — New Consumer Cold-Start E2E", () => { - // ── beforeAll: chain setup + service registration ────────────────────────── beforeAll(async () => { @@ -241,18 +238,18 @@ describe("Synapse TS SDK — New Consumer Cold-Start E2E", () => { console.log(`[setup] Mock provider: ${mockServiceUrl}`); // 2. Ethers setup - const rpcProvider = new JsonRpcProvider(RPC_URL); + 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); + 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 }); + freshAuth = SynapseAuth.fromWallet(freshWallet, { gatewayUrl: GATEWAY_URL }); providerAuth = SynapseAuth.fromWallet(providerWallet, { gatewayUrl: GATEWAY_URL }); // 4. Fund fresh wallet + on-chain deposit @@ -268,12 +265,8 @@ describe("Synapse TS SDK — New Consumer Cold-Start E2E", () => { 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(); + 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}`); @@ -319,7 +312,7 @@ describe("Synapse TS SDK — New Consumer Cold-Start E2E", () => { describe("2. Balance After Deposit", () => { it("should show consumerAvailableBalance >= DEPOSIT_USDC after on-chain deposit", async () => { - const balance = await freshAuth.getBalance(); + 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 @@ -363,16 +356,12 @@ describe("Synapse TS SDK — New Consumer Cold-Start E2E", () => { 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 ?? "" - ); + 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 ?? + 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); }); @@ -385,9 +374,7 @@ describe("Synapse TS SDK — New Consumer Cold-Start E2E", () => { beforeAll(async () => { const bal = await freshAuth.getBalance(); - balanceBeforeInvocations = Number( - bal.consumerAvailableBalance ?? bal.ownerBalance ?? 0 - ); + balanceBeforeInvocations = Number(bal.consumerAvailableBalance ?? bal.ownerBalance ?? 0); console.log(` Balance before invocations: ${balanceBeforeInvocations}`); }); @@ -467,10 +454,8 @@ describe("Synapse TS SDK — New Consumer Cold-Start E2E", () => { 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 - ); + 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); diff --git a/typescript/tests/e2e/provider.test.ts b/typescript/tests/e2e/provider.test.ts index d2d2ea7..408c1d8 100644 --- a/typescript/tests/e2e/provider.test.ts +++ b/typescript/tests/e2e/provider.test.ts @@ -94,8 +94,6 @@ describe("Synapse TS SDK — Provider Onboarding E2E", () => { 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") - ); + expect(["healthy", "unknown", "degraded"]).toContain(String(status.health.overallStatus ?? "unknown")); }); }); diff --git a/typescript/tests/unit/auth.test.ts b/typescript/tests/unit/auth.test.ts index 38f3da9..993c849 100644 --- a/typescript/tests/unit/auth.test.ts +++ b/typescript/tests/unit/auth.test.ts @@ -5,20 +5,17 @@ type MockResponse = { status?: number; body: unknown }; function mockFetch(responses: MockResponse[]) { const calls: Array<{ url: string; init?: RequestInit }> = []; let index = 0; - (globalThis as unknown as Record).fetch = jest.fn( - async (url: string, init?: RequestInit) => { - calls.push({ url, init }); - const response = responses[Math.min(index, responses.length - 1)]; - index += 1; - const status = response.status ?? 200; - return { - ok: status >= 200 && status < 300, - status, - text: async () => - typeof response.body === "string" ? response.body : JSON.stringify(response.body), - } as Response; - } - ); + (globalThis as unknown as Record).fetch = jest.fn(async (url: string, init?: RequestInit) => { + calls.push({ url, init }); + const response = responses[Math.min(index, responses.length - 1)]; + index += 1; + const status = response.status ?? 200; + return { + ok: status >= 200 && status < 300, + status, + text: async () => (typeof response.body === "string" ? response.body : JSON.stringify(response.body)), + } as Response; + }); return calls; } @@ -81,10 +78,7 @@ test("authenticate maps unsuccessful challenge and verify responses to Authentic mockFetch([{ body: { success: false, error: "bad challenge" } }]); await expect(authForTests().authenticate()).rejects.toThrow(AuthenticationError); - mockFetch([ - { body: { success: true, challenge: "sign this" } }, - { body: { success: false, error: "bad verify" } }, - ]); + mockFetch([{ body: { success: true, challenge: "sign this" } }, { body: { success: false, error: "bad verify" } }]); await expect(authForTests().authenticate()).rejects.toThrow(AuthenticationError); }); @@ -190,38 +184,46 @@ test("balance, deposit, confirm, and spending limit APIs send expected payloads" }); test("registerProviderService validates input and builds provider service contract", async () => { - await expect(authForTests().registerProviderService({ - serviceName: "", - endpointUrl: "http://provider.local/invoke", - descriptionForModel: "Summarize text", - basePriceUsdc: 0.01, - })).rejects.toThrow("serviceName is required"); - await expect(authForTests().registerProviderService({ - serviceName: "Summarizer", - endpointUrl: "", - descriptionForModel: "Summarize text", - basePriceUsdc: 0.01, - })).rejects.toThrow("endpointUrl is required"); - await expect(authForTests().registerProviderService({ - serviceName: "Summarizer", - endpointUrl: "http://provider.local/invoke", - descriptionForModel: "", - basePriceUsdc: 0.01, - })).rejects.toThrow("descriptionForModel is required"); + await expect( + authForTests().registerProviderService({ + serviceName: "", + endpointUrl: "http://provider.local/invoke", + descriptionForModel: "Summarize text", + basePriceUsdc: 0.01, + }) + ).rejects.toThrow("serviceName is required"); + await expect( + authForTests().registerProviderService({ + serviceName: "Summarizer", + endpointUrl: "", + descriptionForModel: "Summarize text", + basePriceUsdc: 0.01, + }) + ).rejects.toThrow("endpointUrl is required"); + await expect( + authForTests().registerProviderService({ + serviceName: "Summarizer", + endpointUrl: "http://provider.local/invoke", + descriptionForModel: "", + basePriceUsdc: 0.01, + }) + ).rejects.toThrow("descriptionForModel is required"); const calls = mockFetch([ ...authHandshakeResponses(), { body: { status: "created", service: { serviceId: "summarizer", status: "active" } } }, ]); - await expect(authForTests().registerProviderService({ - serviceName: "Summarizer Pro", - endpointUrl: "http://provider.local/invoke", - descriptionForModel: "Summarize text", - basePriceUsdc: 0.05, - tags: ["text"], - providerDisplayName: "Provider Inc", - })).resolves.toMatchObject({ + await expect( + authForTests().registerProviderService({ + serviceName: "Summarizer Pro", + endpointUrl: "http://provider.local/invoke", + descriptionForModel: "Summarize text", + basePriceUsdc: 0.05, + tags: ["text"], + providerDisplayName: "Provider Inc", + }) + ).resolves.toMatchObject({ status: "created", serviceId: "summarizer", }); @@ -233,6 +235,69 @@ test("registerProviderService validates input and builds provider service contra expect(body.payoutAccount.payoutAddress).toBe("0xabcdef"); }); +test("registerProviderService preserves explicit provider manifest options", async () => { + const calls = mockFetch([ + ...authHandshakeResponses(), + { body: { status: "created", serviceId: "svc_custom", service: { id: "record_1" } } }, + ]); + + await expect( + authForTests().registerProviderService({ + serviceId: "svc_custom", + serviceName: "Custom Provider", + endpointUrl: "https://provider.example/invoke", + descriptionForModel: "Run the custom provider.", + basePriceUsdc: "0.25", + providerDisplayName: "Custom Team", + payoutAddress: "0x123", + chainId: 84532, + settlementCurrency: "USDC", + tags: ["custom", "paid"], + status: "paused", + isActive: false, + inputSchema: { type: "object", properties: { q: { type: "string" } }, required: ["q"] }, + outputSchema: { type: "object", properties: { answer: { type: "string" } } }, + endpointMethod: "PUT", + healthPath: "/ready", + healthMethod: "POST", + healthTimeoutMs: 5_000, + requestTimeoutMs: 30_000, + governanceNote: "accepted in test", + }) + ).resolves.toMatchObject({ serviceId: "svc_custom" }); + + const body = JSON.parse((calls[2].init?.body as string) ?? "{}"); + expect(body).toMatchObject({ + serviceId: "svc_custom", + status: "paused", + isActive: false, + tags: ["custom", "paid"], + invoke: { + method: "PUT", + targets: [{ url: "https://provider.example/invoke" }], + timeoutMs: 30_000, + }, + healthCheck: { + path: "/ready", + method: "POST", + timeoutMs: 5_000, + }, + providerProfile: { displayName: "Custom Team" }, + payoutAccount: { + payoutAddress: "0x123", + chainId: 84532, + settlementCurrency: "USDC", + }, + governance: { + termsAccepted: true, + riskAcknowledged: true, + note: "accepted in test", + }, + }); + expect(body.invoke.request.body.required).toEqual(["q"]); + expect(body.invoke.response.body.properties.answer.type).toBe("string"); +}); + test("provider service lookup and status derive from listed services", async () => { mockFetch([ ...authHandshakeResponses(), diff --git a/typescript/tests/unit/client.test.ts b/typescript/tests/unit/client.test.ts index 7777577..0594982 100644 --- a/typescript/tests/unit/client.test.ts +++ b/typescript/tests/unit/client.test.ts @@ -19,9 +19,7 @@ import { // ── Helpers ─────────────────────────────────────────────────────────────────── -function makeFetchMock( - responses: Array<{ status: number; body: unknown }> -): jest.Mock { +function makeFetchMock(responses: Array<{ status: number; body: unknown }>): jest.Mock { let callCount = 0; return jest.fn(async (_url: string, _init?: RequestInit) => { const resp = responses[callCount % responses.length]; @@ -58,7 +56,9 @@ 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("https://gateway.example"); + expect(resolveGatewayUrl({ environment: "prod", gatewayUrl: "https://gateway.example/" })).toBe( + "https://gateway.example" + ); }); test("resolveGatewayUrl rejects invalid environment", () => { @@ -95,22 +95,20 @@ test("SynapseAuth defaults to staging gateway", async () => { test("invoke() calls /agent/invoke and returns InvocationResult", async () => { const urls: string[] = []; - (globalThis as unknown as Record).fetch = jest.fn( - async (url: string) => { - urls.push(url as string); - return { - ok: true, - status: 200, - text: async () => - JSON.stringify({ - invocationId: "inv_001", - status: "SUCCEEDED", - chargedUsdc: 0.05, - result: { answer: "hello world" }, - }), - } as Response; - } - ); + (globalThis as unknown as Record).fetch = jest.fn(async (url: string) => { + urls.push(url as string); + return { + ok: true, + status: 200, + text: async () => + JSON.stringify({ + invocationId: "inv_001", + status: "SUCCEEDED", + chargedUsdc: 0.05, + result: { answer: "hello world" }, + }), + } as Response; + }); const client = new SynapseClient({ credential: "agt_test", @@ -133,8 +131,7 @@ test("invoke() skips polling when invocation already terminal", async () => { return { ok: true, status: 200, - text: async () => - JSON.stringify({ invocationId: "inv_002", status: "SUCCEEDED", chargedUsdc: 0.01 }), + text: async () => JSON.stringify({ invocationId: "inv_002", status: "SUCCEEDED", chargedUsdc: 0.01 }), } as Response; }); @@ -152,16 +149,16 @@ test("invoke() sends correct body to /agent/invoke", async () => { return { ok: true, status: 200, - text: async () => JSON.stringify({ invocationId: "inv_b", status: "SUCCEEDED", chargedUsdc: 0.10 }), + text: async () => JSON.stringify({ invocationId: "inv_b", status: "SUCCEEDED", chargedUsdc: 0.1 }), } as Response; }); const client = new SynapseClient({ credential: "agt_test" }); - await client.invoke("svc_2", { text: "test" }, { costUsdc: 0.10, idempotencyKey: "ik-2" }); + await client.invoke("svc_2", { text: "test" }, { costUsdc: 0.1, idempotencyKey: "ik-2" }); const body = capturedBody as Record; expect(body["serviceId"]).toBe("svc_2"); - expect(body["costUsdc"]).toBe(0.10); + expect(body["costUsdc"]).toBe(0.1); expect(body["idempotencyKey"]).toBe("ik-2"); expect((body["payload"] as Record)["body"]).toEqual({ text: "test" }); }); @@ -169,33 +166,42 @@ test("invoke() sends correct body to /agent/invoke", async () => { // ── Error mapping ───────────────────────────────────────────────────────────── test("401 response from invoke throws AuthenticationError", async () => { - (globalThis as unknown as Record).fetch = jest.fn(async () => ({ - ok: false, - status: 401, - text: async () => JSON.stringify({ detail: "Invalid credential" }), - } as Response)); + (globalThis as unknown as Record).fetch = jest.fn( + async () => + ({ + ok: false, + status: 401, + text: async () => JSON.stringify({ detail: "Invalid credential" }), + }) as Response + ); const client = new SynapseClient({ credential: "agt_bad" }); await expect(client.invoke("svc_1", {}, { costUsdc: 0.01 })).rejects.toThrow(AuthenticationError); }); test("402 response from invoke throws InsufficientFundsError", async () => { - (globalThis as unknown as Record).fetch = jest.fn(async () => ({ - ok: false, - status: 402, - text: async () => JSON.stringify({ detail: "Insufficient funds" }), - } as Response)); + (globalThis as unknown as Record).fetch = jest.fn( + async () => + ({ + ok: false, + status: 402, + text: async () => JSON.stringify({ detail: "Insufficient funds" }), + }) as Response + ); const client = new SynapseClient({ credential: "agt_test" }); await expect(client.invoke("svc_1", {}, { costUsdc: 0.05 })).rejects.toThrow(InsufficientFundsError); }); test("500 from invoke throws InvokeError", async () => { - (globalThis as unknown as Record).fetch = jest.fn(async () => ({ - ok: false, - status: 500, - text: async () => "internal server error", - } as Response)); + (globalThis as unknown as Record).fetch = jest.fn( + async () => + ({ + ok: false, + status: 500, + text: async () => "internal server error", + }) as Response + ); const client = new SynapseClient({ credential: "agt_test" }); await expect(client.invoke("svc_bad", {}, { costUsdc: 0.01 })).rejects.toThrow(InvokeError); @@ -210,15 +216,13 @@ test("discover() returns service array from response.services", async () => { capturedUrl = url; capturedBody = JSON.parse((init?.body as string) ?? "{}"); return { - ok: true, - status: 200, - text: async () => - JSON.stringify({ - services: [ - { serviceId: "svc_a", serviceName: "Service A", summary: "test", status: "online" }, - ], - }), - } as Response; + ok: true, + status: 200, + text: async () => + JSON.stringify({ + services: [{ serviceId: "svc_a", serviceName: "Service A", summary: "test", status: "online" }], + }), + } as Response; }); const client = new SynapseClient({ credential: "agt_test" }); @@ -260,19 +264,22 @@ test("search() sends current gateway discovery request shape", async () => { // ── PRICE_MISMATCH ──────────────────────────────────────────────────────────── test("invoke() throws PriceMismatchError on 422 PRICE_MISMATCH", async () => { - (globalThis as unknown as Record).fetch = jest.fn(async () => ({ - ok: false, - status: 422, - text: async () => - JSON.stringify({ - detail: { - code: "PRICE_MISMATCH", - message: "Price changed: expected 0.05, current 0.15", - expectedPriceUsdc: 0.05, - currentPriceUsdc: 0.15, - }, - }), - } as Response)); + (globalThis as unknown as Record).fetch = jest.fn( + async () => + ({ + ok: false, + status: 422, + text: async () => + JSON.stringify({ + detail: { + code: "PRICE_MISMATCH", + message: "Price changed: expected 0.05, current 0.15", + expectedPriceUsdc: 0.05, + currentPriceUsdc: 0.15, + }, + }), + }) as Response + ); const client = new SynapseClient({ credential: "agt_test" }); const err = await client.invoke("svc_x", {}, { costUsdc: 0.05 }).catch((e) => e); @@ -282,31 +289,37 @@ test("invoke() throws PriceMismatchError on 422 PRICE_MISMATCH", async () => { }); test("invoke() returns pending result without polling when pollTimeoutMs is zero", async () => { - (globalThis as unknown as Record).fetch = jest.fn(async () => ({ - ok: true, - status: 200, - text: async () => JSON.stringify({ invocationId: "inv_pending", status: "PENDING" }), - } as Response)); + (globalThis as unknown as Record).fetch = jest.fn( + async () => + ({ + ok: true, + status: 200, + text: async () => JSON.stringify({ invocationId: "inv_pending", status: "PENDING" }), + }) as Response + ); const client = new SynapseClient({ credential: "agt_test" }); const result = await client.invoke("svc_pending", {}, { costUsdc: 0.01, pollTimeoutMs: 0 }); expect(result.status).toBe("PENDING"); - expect((globalThis.fetch as jest.Mock)).toHaveBeenCalledTimes(1); + expect(globalThis.fetch as jest.Mock).toHaveBeenCalledTimes(1); }); test("invoke() polls when sync response is non-terminal and polling remains enabled", async () => { const statuses = ["PENDING", "SUCCEEDED"]; - (globalThis as unknown as Record).fetch = jest.fn(async (url: string) => ({ - ok: true, - status: 200, - text: async () => - JSON.stringify( - url.includes("/api/v1/agent/invoke") - ? { invocationId: "inv_poll", status: "PENDING", chargedUsdc: 0 } - : { invocationId: "inv_poll", status: statuses.shift() ?? "SUCCEEDED", chargedUsdc: 0.03 } - ), - } as Response)); + (globalThis as unknown as Record).fetch = jest.fn( + async (url: string) => + ({ + ok: true, + status: 200, + text: async () => + JSON.stringify( + url.includes("/api/v1/agent/invoke") + ? { invocationId: "inv_poll", status: "PENDING", chargedUsdc: 0 } + : { invocationId: "inv_poll", status: statuses.shift() ?? "SUCCEEDED", chargedUsdc: 0.03 } + ), + }) as Response + ); const client = new SynapseClient({ credential: "agt_test" }); const result = await client.invoke("svc_poll", {}, { costUsdc: 0.03, pollIntervalMs: 0, pollTimeoutMs: 50 }); @@ -317,16 +330,19 @@ test("invoke() polls when sync response is non-terminal and polling remains enab test("waitForInvocation polls until a terminal receipt is returned", async () => { const statuses = ["PENDING", "SUCCEEDED"]; - (globalThis as unknown as Record).fetch = jest.fn(async (url: string) => ({ - ok: true, - status: 200, - text: async () => - JSON.stringify({ - id: decodeURIComponent(url.split("/").pop() ?? ""), - status: statuses.shift() ?? "SUCCEEDED", - charged_usdc: 0.2, - }), - } as Response)); + (globalThis as unknown as Record).fetch = jest.fn( + async (url: string) => + ({ + ok: true, + status: 200, + text: async () => + JSON.stringify({ + id: decodeURIComponent(url.split("/").pop() ?? ""), + status: statuses.shift() ?? "SUCCEEDED", + charged_usdc: 0.2, + }), + }) as Response + ); const client = new SynapseClient({ credential: "agt_test" }); const result = await client.waitForInvocation("inv spaced", { pollTimeoutMs: 50, pollIntervalMs: 0 }); @@ -345,11 +361,14 @@ test("waitForInvocation times out when receipt never reaches terminal state", as }); test("discover and search map gateway failures to DiscoveryError", async () => { - (globalThis as unknown as Record).fetch = jest.fn(async () => ({ - ok: false, - status: 500, - text: async () => JSON.stringify({ detail: { code: "DISCOVERY_DOWN" } }), - } as Response)); + (globalThis as unknown as Record).fetch = jest.fn( + async () => + ({ + ok: false, + status: 500, + text: async () => JSON.stringify({ detail: { code: "DISCOVERY_DOWN" } }), + }) as Response + ); const client = new SynapseClient({ credential: "agt_test" }); await expect(client.search("broken")).rejects.toThrow(DiscoveryError); @@ -357,11 +376,14 @@ test("discover and search map gateway failures to DiscoveryError", async () => { }); test("search accepts legacy array discovery response", async () => { - (globalThis as unknown as Record).fetch = jest.fn(async () => ({ - ok: true, - status: 200, - text: async () => JSON.stringify([{ serviceId: "svc_array", serviceName: "Array Service" }]), - } as Response)); + (globalThis as unknown as Record).fetch = jest.fn( + async () => + ({ + ok: true, + status: 200, + text: async () => JSON.stringify([{ serviceId: "svc_array", serviceName: "Array Service" }]), + }) as Response + ); const client = new SynapseClient({ credential: "agt_test" }); const services = await client.search("array", { limit: 0, offset: -10 }); @@ -373,7 +395,7 @@ test("invokeWithRediscovery retries once with live discovered price", async () = const calls: Array<{ url: string; body?: Record }> = []; let invokeCount = 0; (globalThis as unknown as Record).fetch = jest.fn(async (url: string, init?: RequestInit) => { - const body = init?.body ? JSON.parse(init.body as string) as Record : undefined; + const body = init?.body ? (JSON.parse(init.body as string) as Record) : undefined; calls.push({ url, body }); if (url.includes("/api/v1/agent/invoke")) { invokeCount += 1; @@ -409,11 +431,15 @@ test("invokeWithRediscovery retries once with live discovered price", async () = }); const client = new SynapseClient({ credential: "agt_test" }); - const result = await client.invokeWithRediscovery("svc_1", { prompt: "hi" }, { - costUsdc: 0.05, - query: "market data", - idempotencyKey: "ik-retry", - }); + const result = await client.invokeWithRediscovery( + "svc_1", + { prompt: "hi" }, + { + costUsdc: 0.05, + query: "market data", + idempotencyKey: "ik-retry", + } + ); expect(result.invocationId).toBe("inv_retry"); expect(calls[1].url).toContain("/api/v1/agent/discovery/search"); @@ -422,14 +448,17 @@ test("invokeWithRediscovery retries once with live discovered price", async () = }); test("gateway health, invocation receipt alias, and empty discovery diagnostics are exposed", async () => { - (globalThis as unknown as Record).fetch = jest.fn(async (url: string) => ({ - ok: true, - status: 200, - text: async () => - url.endsWith("/health") - ? JSON.stringify({ status: "ok" }) - : JSON.stringify({ invocationId: "inv_1", status: "SUCCEEDED", chargedUsdc: 0 }), - } as Response)); + (globalThis as unknown as Record).fetch = jest.fn( + async (url: string) => + ({ + ok: true, + status: 200, + text: async () => + url.endsWith("/health") + ? JSON.stringify({ status: "ok" }) + : JSON.stringify({ invocationId: "inv_1", status: "SUCCEEDED", chargedUsdc: 0 }), + }) as Response + ); const client = new SynapseClient({ credential: "agt_test", environment: "local" }); await expect(client.checkGatewayHealth()).resolves.toEqual({ status: "ok" }); @@ -438,32 +467,41 @@ test("gateway health, invocation receipt alias, and empty discovery diagnostics }); test("invokeWithRediscovery respects disabled retry and falls back to gateway live price", async () => { - (globalThis as unknown as Record).fetch = jest.fn(async () => ({ - ok: false, - status: 422, - text: async () => - JSON.stringify({ - detail: { - code: "PRICE_MISMATCH", - message: "Price changed", - expectedPriceUsdc: 0.05, - currentPriceUsdc: 0.12, - }, - }), - } as Response)); + (globalThis as unknown as Record).fetch = jest.fn( + async () => + ({ + ok: false, + status: 422, + text: async () => + JSON.stringify({ + detail: { + code: "PRICE_MISMATCH", + message: "Price changed", + expectedPriceUsdc: 0.05, + currentPriceUsdc: 0.12, + }, + }), + }) as Response + ); const client = new SynapseClient({ credential: "agt_test" }); - await expect(client.invokeWithRediscovery("svc_1", {}, { - costUsdc: 0.05, - maxRediscoveryRetries: 0, - })).rejects.toThrow(PriceMismatchError); + await expect( + client.invokeWithRediscovery( + "svc_1", + {}, + { + costUsdc: 0.05, + maxRediscoveryRetries: 0, + } + ) + ).rejects.toThrow(PriceMismatchError); }); test("invokeWithRediscovery handles string prices and missing discovered prices", async () => { const calls: Array<{ url: string; body?: Record }> = []; let invokeCount = 0; (globalThis as unknown as Record).fetch = jest.fn(async (url: string, init?: RequestInit) => { - const body = init?.body ? JSON.parse(init.body as string) as Record : undefined; + const body = init?.body ? (JSON.parse(init.body as string) as Record) : undefined; calls.push({ url, body }); if (url.includes("/api/v1/agent/invoke")) { invokeCount += 1; From eb660638d82a805e383ab67650a57497769a056f Mon Sep 17 00:00:00 2001 From: cc-mac-mini Date: Wed, 29 Apr 2026 10:11:15 +0800 Subject: [PATCH 2/3] Implement typed SDK return objects --- README.md | 24 ++ README.zh-CN.md | 24 ++ docs/agent-map/index.json | 6 +- docs/quality-gates.md | 2 + docs/sdk/README.md | 9 +- docs/sdk/README.zh-CN.md | 9 +- docs/sdk/capability_inventory.md | 2 + docs/sdk/python_integration.md | 37 ++- docs/sdk/python_provider_integration.md | 25 ++ docs/sdk/typescript_integration.md | 37 ++- docs/sdk/typescript_provider_integration.md | 25 ++ llms.txt | 2 + python/synapse_client/__init__.py | 53 +++- python/synapse_client/_auth_credentials.py | 37 ++- python/synapse_client/_auth_finance.py | 37 ++- .../synapse_client/_auth_provider_control.py | 148 ++++++++-- .../synapse_client/_control_result_models.py | 117 ++++++++ python/synapse_client/auth.py | 11 +- python/synapse_client/client.py | 101 +++---- python/synapse_client/models.py | 68 +++++ python/synapse_client/provider.py | 36 ++- python/synapse_client/test/test_auth_unit.py | 264 +++++++++--------- .../synapse_client/test/test_client_unit.py | 61 ++++ .../test/test_provider_facade_unit.py | 121 ++++++++ python/synapse_client/wallet.py | 57 ++++ scripts/ci/python_checks.sh | 1 + scripts/ci/repo_hygiene_checks.sh | 2 + scripts/ci/source_quality_checks.py | 102 ++++++- typescript/src/auth.ts | 106 ++++--- typescript/src/auth_provider_control.ts | 95 +++++-- typescript/src/client.ts | 66 ++++- typescript/src/provider.ts | 43 ++- typescript/src/types.ts | 239 +++++++++++++++- typescript/tests/unit/auth.test.ts | 132 +++++++-- typescript/tests/unit/client.test.ts | 47 ++++ 35 files changed, 1748 insertions(+), 398 deletions(-) create mode 100644 python/synapse_client/_control_result_models.py create mode 100644 python/synapse_client/test/test_provider_facade_unit.py create mode 100644 python/synapse_client/wallet.py diff --git a/README.md b/README.md index f3d2671..989b9f2 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,28 @@ 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. +### 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. + +```python +result = client.invoke_llm( + "svc_deepseek_chat", + {"messages": [{"role": "user", "content": "hello"}], "max_tokens": 512}, + max_cost_usdc="0.010000", +) +print(result.usage.input_tokens, result.synapse.charged_usdc, result.synapse.released_usdc) +``` + +```ts +const result = await client.invokeLlm( + "svc_deepseek_chat", + { messages: [{ role: "user", content: "hello" }], max_tokens: 512 }, + { maxCostUsdc: "0.010000" } +); +console.log(result.usage?.inputTokens, result.synapse?.chargedUsdc, result.synapse?.releasedUsdc); +``` + ## Advanced: Programmatic Credential Issuance Use `SynapseAuth` only when an owner/backend service needs to issue credentials or register provider services programmatically. Ordinary agent runtime code should use `SynapseClient` with an existing `agt_xxx` key. @@ -274,6 +296,8 @@ Supported today: - Provider service register/list/get/status/update/delete/ping/registration guide/health history - Provider earnings and withdrawal intent/list/capability helpers +Public owner/provider helpers return named SDK objects such as `UsageLogList`, `ProviderRegistrationGuide`, and `ProviderWithdrawalIntentResult`, not raw maps. + Not yet wrapped: - Refunds, notifications, community, and event APIs diff --git a/README.zh-CN.md b/README.zh-CN.md index 7256d9c..536bd5a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -138,6 +138,28 @@ console.log(receipt.invocationId, receipt.status, receipt.chargedUsdc); TypeScript SDK 不会自动读取环境变量。请在你的应用中读取环境变量,然后显式传入 `environment` 或 `gatewayUrl`。 +### LLM 按 token 计费调用 + +使用 `serviceKind=llm` 和 `priceModel=token_metered` 注册的 LLM 服务,需要调用 `invoke_llm()` / `invokeLlm()`。不要传 `cost_usdc` / `costUsdc`;可以传可选的 `max_cost_usdc` / `maxCostUsdc`,也可以交给 Gateway 自动冻结。V1 会拒绝 streaming,确保 Gateway 能拿到 final usage 后再扣费。 + +```python +result = client.invoke_llm( + "svc_deepseek_chat", + {"messages": [{"role": "user", "content": "hello"}], "max_tokens": 512}, + max_cost_usdc="0.010000", +) +print(result.usage.input_tokens, result.synapse.charged_usdc, result.synapse.released_usdc) +``` + +```ts +const result = await client.invokeLlm( + "svc_deepseek_chat", + { messages: [{ role: "user", content: "hello" }], max_tokens: 512 }, + { maxCostUsdc: "0.010000" } +); +console.log(result.usage?.inputTokens, result.synapse?.chargedUsdc, result.synapse?.releasedUsdc); +``` + ## 高级用法:以代码方式签发凭据 只有当 owner/backend service 需要以代码方式签发凭据或注册 provider service 时,才使用 `SynapseAuth`。普通 Agent 运行时代码应使用已有 `agt_xxx` key 和 `SynapseClient`。 @@ -274,6 +296,8 @@ PYTHONPATH="$PWD" .venv/bin/python examples/consumer_wallet_to_invoke.py \ - Provider service register/list/get/status/update/delete/ping/registration guide/health history - Provider earnings and withdrawal intent/list/capability helpers +公开 owner/provider helper 返回 `UsageLogList`、`ProviderRegistrationGuide`、`ProviderWithdrawalIntentResult` 等命名 SDK 对象,而不是 raw map。 + 尚未封装: - Refunds、notifications、community、event APIs diff --git a/docs/agent-map/index.json b/docs/agent-map/index.json index 26dbbf3..7c368c4 100644 --- a/docs/agent-map/index.json +++ b/docs/agent-map/index.json @@ -71,7 +71,8 @@ ], "notes": [ "Credential and finance-adjacent helpers must not log real tokens or secrets.", - "High-impact finance helpers may wrap API calls, but should not auto-execute irreversible user actions." + "High-impact finance helpers may wrap API calls, but should not auto-execute irreversible user actions.", + "Public owner-auth returns must use named SDK models/interfaces, not raw dict or Record." ] }, { @@ -99,7 +100,8 @@ ], "notes": [ "Provider onboarding success is measured through owner control-plane service records, not public discovery alone.", - "Provider is an owner-scoped supply-side role; use auth.provider() / SynapseProvider rather than a separate root identity." + "Provider is an owner-scoped supply-side role; use auth.provider() / SynapseProvider rather than a separate root identity.", + "Public provider facade returns must use named SDK models/interfaces, not raw dict or Record." ] }, { diff --git a/docs/quality-gates.md b/docs/quality-gates.md index 39cebc8..4cbd604 100644 --- a/docs/quality-gates.md +++ b/docs/quality-gates.md @@ -26,6 +26,7 @@ This repository uses `bash scripts/ci/pr_checks.sh` as the single PR quality gat - Python coverage must be at least `80%`. - TypeScript global lines, branches, functions, and statements coverage must each be at least `80%`. - Duplicate code across `python/synapse_client` and `typescript/src` must stay at or below `3%` with a `50` token minimum clone size. +- Public `SynapseAuth` and `SynapseProvider` methods must return named SDK models/interfaces, never raw Python `dict` or TypeScript `Record`. Internal HTTP payloads, schemas, and patch inputs may remain map-shaped. ## Refactor Rules @@ -33,6 +34,7 @@ This repository uses `bash scripts/ci/pr_checks.sh` as the single PR quality gat - If logic appears in three places, extract a shared helper or module. - If a function exceeds `40` Python effective lines, `60` TypeScript lines, or the complexity threshold, split pure decisions from I/O and orchestration. - Bug fixes need regression tests. New observable behavior needs unit tests. +- New owner/provider SDK methods need a named result model/interface before they are exposed publicly. - Public SDK API changes must update docs and `docs/sdk/capability_inventory.md` when the implementation state changes. ## GitHub Enforcement diff --git a/docs/sdk/README.md b/docs/sdk/README.md index 5ecf583..115bb53 100644 --- a/docs/sdk/README.md +++ b/docs/sdk/README.md @@ -31,7 +31,11 @@ The SDK currently has three explicit public surfaces: Provider remains an owner-scoped supply-side role. `SynapseProvider` improves discoverability but does not introduce a second provider root identity. -Python quote-first methods `create_quote()`, `create_invocation()`, and `invoke_service()` are deprecated. They no longer call old endpoints and instead tell users to use discovery/search + `invoke(..., cost_usdc=...)`. +Owner/provider helper returns are typed SDK objects. Do not document or add public `SynapseAuth` / `SynapseProvider` methods that return raw Python `dict` or TypeScript `Record`; add a named result model/interface instead. + +Python quote-first methods `create_quote()`, `create_invocation()`, and `invoke_service()` are deprecated. They no longer call old endpoints and instead tell users to use discovery/search + `invoke(..., cost_usdc=...)` for fixed-price APIs. + +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. ## Staging Docs @@ -95,7 +99,8 @@ Runtime calls should include: 1. `request_id` / request header for gateway log correlation. 2. `idempotency_key` / `idempotencyKey` to avoid duplicate charges or duplicate execution. -3. `cost_usdc` / `costUsdc` from latest discovery price. If price changes, the gateway rejects the call and the caller should rediscover. +3. For fixed-price APIs, pass `cost_usdc` / `costUsdc` from latest discovery price. If price changes, the gateway rejects the call and the caller should rediscover. +4. For token-metered LLM services, call `invoke_llm()` / `invokeLlm()` with optional `max_cost_usdc` / `maxCostUsdc`; final Provider `usage` drives the actual charge. ## Common Failures diff --git a/docs/sdk/README.zh-CN.md b/docs/sdk/README.zh-CN.md index 66f1576..13bf6f6 100644 --- a/docs/sdk/README.zh-CN.md +++ b/docs/sdk/README.zh-CN.md @@ -31,7 +31,11 @@ SDK 当前有三个明确的公开入口: Provider 仍然是 owner scope 下的供给侧角色。`SynapseProvider` 只是让 provider 接入更容易发现,不引入第二套 provider root 身份。 -Python 旧的 quote-first 方法 `create_quote()`、`create_invocation()`、`invoke_service()` 已经废弃。它们不会再访问旧 endpoint,而是直接提示改用 discovery/search + `invoke(..., cost_usdc=...)`。 +Owner/provider helper 的返回值必须是命名 SDK 对象。不要新增或记录返回 raw Python `dict` / TypeScript `Record` 的公开 `SynapseAuth` / `SynapseProvider` 方法;应先新增命名 result model/interface。 + +Python 旧的 quote-first 方法 `create_quote()`、`create_invocation()`、`invoke_service()` 已经废弃。它们不会再访问旧 endpoint,而是直接提示普通 fixed-price API 改用 discovery/search + `invoke(..., cost_usdc=...)`。 + +LLM 服务使用 `serviceKind=llm` + `priceModel=token_metered`。Runtime 代码应调用 `invoke_llm()` / `invokeLlm()`,并读取返回里的 `usage` 与 `synapse` 计费元数据。LLM 调用不要传 `cost_usdc` / `costUsdc`;可以传可选的 `max_cost_usdc` / `maxCostUsdc`,也可以交给 Gateway 自动冻结。V1 禁用 streaming。 ## Staging 产品文档 @@ -95,7 +99,8 @@ TypeScript: 1. `request_id` / request header,用于串联 gateway 日志。 2. `idempotency_key` / `idempotencyKey`,用于避免重复扣费或重复执行。 -3. `cost_usdc` / `costUsdc`,来自最新 discovery price。若价格变化,gateway 会拒绝本次调用,调用方应重新 discovery。 +3. 普通 fixed-price API 传 `cost_usdc` / `costUsdc`,来自最新 discovery price。若价格变化,gateway 会拒绝本次调用,调用方应重新 discovery。 +4. 按 token 计费的 LLM 服务调用 `invoke_llm()` / `invokeLlm()`,可选传 `max_cost_usdc` / `maxCostUsdc`;最终按 Provider 返回的 `usage` 精准扣费。 ## 常见故障 diff --git a/docs/sdk/capability_inventory.md b/docs/sdk/capability_inventory.md index f82a26f..c396cc4 100644 --- a/docs/sdk/capability_inventory.md +++ b/docs/sdk/capability_inventory.md @@ -13,6 +13,8 @@ Consumer runtime is: 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. +Public `SynapseAuth` and `SynapseProvider` owner/provider helpers return named SDK objects. Python uses `SDKModel` result classes such as `CredentialRevokeResult`, `UsageLogList`, `ProviderRegistrationGuide`, and `ProviderWithdrawalIntentResult`; TypeScript exports matching interfaces. Raw `dict` / `Record` is allowed for internal HTTP payloads, schemas, patch inputs, and dynamic runtime payload fields, but not as the top-level public Auth/Provider return contract. + ## Python Consumer Supported: diff --git a/docs/sdk/python_integration.md b/docs/sdk/python_integration.md index 91f48f4..1a9c3c4 100644 --- a/docs/sdk/python_integration.md +++ b/docs/sdk/python_integration.md @@ -9,7 +9,8 @@ Consumer runtime 主链固定为: 1. owner 钱包登录 2. 创建 agent credential 3. discovery/search -4. `invoke(service_id, payload, cost_usdc=...)` +4. fixed API: `invoke(service_id, payload, cost_usdc=...)` +5. LLM service: `invoke_llm(service_id, payload, max_cost_usdc=...)` 5. receipt 查询 旧的 quote-first 方法 `create_quote()`、`create_invocation()`、`invoke_service()` 已废弃,不再访问旧 endpoint。调用这些方法会直接提示改用 discovery/search + price-asserted invoke。 @@ -130,6 +131,33 @@ print(result.invocation_id, result.status, result.charged_usdc) 如果你已经缓存了稳定 `service_id`,仍然建议先读取或保存 discovery 返回的最新价格,再传给 `cost_usdc`。gateway 会用该价格断言保护调用方,避免服务价格变化后静默扣费。 +## LLM token-metered invoke + +按 token 计费的 LLM 服务使用 `serviceKind=llm` 和 +`priceModel=token_metered`。SDK helper 不发送 `cost_usdc`;Gateway 使用可选 +`max_cost_usdc` 作为上限,或自动根据 prompt 字符数与 `max_tokens` 冻结额度, +最后只按 Provider 返回的 final `usage` 扣费。 + +```python +result = client.invoke_llm( + "svc_deepseek_chat", + { + "model": "deepseek-chat", + "messages": [{"role": "user", "content": "Summarize this document."}], + "max_tokens": 512, + # "stream": True 会在 Synapse V1 被拒绝 + }, + max_cost_usdc="0.010000", # optional + idempotency_key="llm-job-001", +) + +print(result.usage.input_tokens, result.usage.output_tokens) +print(result.synapse.charged_usdc, result.synapse.released_usdc) +``` + +超时、连接断开、SSE 响应或缺少 final `usage` 时完整释放冻结金额,不扣费。V1 +绝不使用预估 hold 作为最终扣费依据。 + ## Python Examples 示例脚本位于 `python/examples`: @@ -168,8 +196,9 @@ PYTHONPATH="$PWD" .venv/bin/python examples/consumer_wallet_to_invoke.py \ 3. `discover()` 4. `search()` 5. `invoke()` -6. `get_invocation()` -7. `get_invocation_receipt()` +6. `invoke_llm()` +7. `get_invocation()` +8. `get_invocation_receipt()` 已废弃兼容方法: @@ -193,6 +222,8 @@ Python SDK 当前通过 `auth.provider()` / `SynapseProvider` 支持: 4. provider 服务 ping、状态查询、health history 5. provider earnings 与 withdrawals helper +Owner/provider helper 返回命名 `SDKModel` 对象;例如 usage logs 返回 `UsageLogList`,registration guide 返回 `ProviderRegistrationGuide`,withdrawal intent 返回 `ProviderWithdrawalIntentResult`。公开 API 不返回 raw `dict`。 + Provider onboarding 成功标准以 owner `/api/v1/services` 列表为准,不以 public discovery 为准。 ## 自动化验收 diff --git a/docs/sdk/python_provider_integration.md b/docs/sdk/python_provider_integration.md index 5040d7e..1091d4f 100644 --- a/docs/sdk/python_provider_integration.md +++ b/docs/sdk/python_provider_integration.md @@ -36,6 +36,8 @@ 17. `provider.create_withdrawal_intent()` 18. `provider.list_withdrawals()` +这些公开方法返回命名 `SDKModel` 对象,例如 `ProviderRegistrationGuide`、`ServiceManifestDraft`、`ProviderServiceUpdateResult`、`ProviderWithdrawalIntentResult`,不返回 raw `dict`。 + --- ## 3. 最小接入代码 @@ -87,6 +89,24 @@ status = provider.get_service_status(registered.service_id) print(status.lifecycle_status, status.health.overall_status, status.runtime_available) ``` +LLM 服务使用专用 token-metered helper: + +```python +llm = provider.register_llm_service( + service_name="DeepSeek Chat", + service_id="svc_deepseek_chat", + endpoint_url="https://provider.example.com/llm/deepseek-chat", + description_for_model="OpenAI-compatible chat completion endpoint.", + input_price_per_1m_tokens_usdc="0.140000", + output_price_per_1m_tokens_usdc="0.280000", + default_max_output_tokens=2048, + max_auto_hold_usdc="0.050000", + request_timeout_ms=120000, +) + +print(llm.service_id) +``` + --- ## 4. SDK 设计原则 @@ -110,6 +130,11 @@ print(status.lifecycle_status, status.health.overall_status, status.runtime_avai 3. `base_price_usdc` 4. `description_for_model` +`provider.register_llm_service()` 使用同样的 owner scope,但固定写入 +`serviceKind=llm` 和 `priceModel=token_metered`。LLM 价格必须使用 +`input_price_per_1m_tokens_usdc` / `output_price_per_1m_tokens_usdc`,不要使用 +`pricePerToken`。 + SDK 自动补: 1. `service_id` / `agentToolName` diff --git a/docs/sdk/typescript_integration.md b/docs/sdk/typescript_integration.md index 151eb02..32a703b 100644 --- a/docs/sdk/typescript_integration.md +++ b/docs/sdk/typescript_integration.md @@ -9,7 +9,8 @@ Consumer runtime 主链固定为: 1. owner 钱包登录 2. 创建 agent credential 3. discovery/search -4. `invoke(serviceId, payload, { costUsdc })` +4. fixed API: `invoke(serviceId, payload, { costUsdc })` +5. LLM service: `invokeLlm(serviceId, payload, { maxCostUsdc })` 5. receipt 查询 TypeScript SDK 不暴露 quote public API。当前 gateway 的正式运行时入口是单步 price-asserted invoke。 @@ -173,12 +174,42 @@ const result = await client.invoke( console.log(result.invocationId, result.status, result.chargedUsdc); ``` +## LLM token-metered invoke + +Provider-registered LLM services use `serviceKind=llm` and +`priceModel=token_metered`. The SDK helper intentionally does not send +`costUsdc`; Gateway either uses the optional `maxCostUsdc` cap or computes an +automatic pre-authorization hold, then captures only final Provider `usage`. + +```ts +const result = await client.invokeLlm( + "svc_deepseek_chat", + { + model: "deepseek-chat", + messages: [{ role: "user", content: "Summarize this document." }], + max_tokens: 512, + // stream: true is rejected in Synapse V1 + }, + { + idempotencyKey: "llm-job-001", + maxCostUsdc: "0.010000", // optional + } +); + +console.log(result.usage?.inputTokens, result.usage?.outputTokens); +console.log(result.synapse?.chargedUsdc, result.synapse?.releasedUsdc); +``` + +Timeouts, disconnects, SSE responses, or missing final `usage` release the +entire hold and do not charge. V1 never bills from the estimated hold. + ## 当前 Consumer API 1. `discover(opts)` 2. `search(query, opts)` 3. `invoke(serviceId, payload, opts)` -4. `getInvocation(invocationId)` +4. `invokeLlm(serviceId, payload, opts)` +5. `getInvocation(invocationId)` ## Provider 侧接入 @@ -195,6 +226,8 @@ TypeScript SDK 当前通过 `auth.provider()` / `SynapseProvider` 支持: 4. provider 服务 ping、状态查询、health history 5. provider earnings 与 withdrawals helper +Owner/provider helper 返回命名 TypeScript interface;例如 usage logs 返回 `UsageLogList`,registration guide 返回 `ProviderRegistrationGuide`,withdrawal intent 返回 `ProviderWithdrawalIntentResult`。公开 API 不返回 raw `Record`。 + Provider onboarding 成功标准以 owner `/api/v1/services` 列表为准,不以 public discovery 为准。 ## 自动化验收 diff --git a/docs/sdk/typescript_provider_integration.md b/docs/sdk/typescript_provider_integration.md index de7c207..6a2ecb4 100644 --- a/docs/sdk/typescript_provider_integration.md +++ b/docs/sdk/typescript_provider_integration.md @@ -36,6 +36,8 @@ 17. `provider.createWithdrawalIntent()` 18. `provider.listWithdrawals()` +这些公开方法返回命名 TypeScript interface,例如 `ProviderRegistrationGuide`、`ServiceManifestDraft`、`ProviderServiceUpdateResult`、`ProviderWithdrawalIntentResult`,不返回 raw `Record`。 + --- ## 3. 最小接入代码 @@ -77,6 +79,24 @@ const status = await provider.getServiceStatus(registered.serviceId); console.log(status.lifecycleStatus, status.health.overallStatus, status.runtimeAvailable); ``` +LLM services use the dedicated token-metered helper: + +```ts +const llm = await provider.registerLlmService({ + serviceName: "DeepSeek Chat", + serviceId: "svc_deepseek_chat", + endpointUrl: "https://provider.example.com/llm/deepseek-chat", + descriptionForModel: "OpenAI-compatible chat completion endpoint.", + inputPricePer1MTokensUsdc: "0.140000", + outputPricePer1MTokensUsdc: "0.280000", + defaultMaxOutputTokens: 2048, + maxAutoHoldUsdc: "0.050000", + requestTimeoutMs: 120000, +}); + +console.log(llm.serviceId); +``` + --- ## 4. 设计原则 @@ -104,6 +124,11 @@ console.log(status.lifecycleStatus, status.health.overallStatus, status.runtimeA 3. `basePriceUsdc` 4. `descriptionForModel` +`provider.registerLlmService()` 使用同样的 owner scope,但把 `serviceKind=llm` 和 +`priceModel=token_metered` 固定写入 manifest。LLM 价格必须使用 +`inputPricePer1MTokensUsdc` / `outputPricePer1MTokensUsdc`,不要使用 +`pricePerToken`。 + SDK 自动补: 1. `serviceId` / `agentToolName` diff --git a/llms.txt b/llms.txt index 71788a9..c1b8cc5 100644 --- a/llms.txt +++ b/llms.txt @@ -87,6 +87,7 @@ README 语言结构: - 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. 中文边界: @@ -99,4 +100,5 @@ README 语言结构: - 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。 diff --git a/python/synapse_client/__init__.py b/python/synapse_client/__init__.py index b61048f..45b3051 100644 --- a/python/synapse_client/__init__.py +++ b/python/synapse_client/__init__.py @@ -1,5 +1,5 @@ from .auth import SynapseAuth -from .client import AgentWallet, SynapseClient +from .client import SynapseClient from .config import DEFAULT_ENVIRONMENT, GATEWAY_URLS, resolve_gateway_url from .exceptions import ( AuthenticationError, @@ -14,25 +14,51 @@ ) from .models import ( AgentCredential, + AuthLogoutResult, BalanceSummary, ChallengeResponse, + CredentialAuditLogList, + CredentialDeleteResult, + CredentialQuotaUpdateResult, + CredentialRevokeResult, + CredentialRotateResult, CredentialStatusResult, DepositConfirmResult, DepositIntentResult, DiscoveryResponse, + FinanceAuditLogList, InvocationResponse, IssueCredentialResult, IssueProviderSecretResult, + LlmUsage, + OwnerProfile, + ProviderEarningsSummary, + ProviderRegistrationGuide, ProviderSecret, + ProviderSecretDeleteResult, ProviderService, + ProviderServiceDeleteResult, + ProviderServiceHealthHistory, + ProviderServicePingResult, ProviderServiceRegistrationResult, ProviderServiceStatus, + ProviderServiceUpdateResult, + ProviderWithdrawalCapability, + ProviderWithdrawalIntentResult, + ProviderWithdrawalList, QuoteResponse, + RiskOverview, + ServiceManifestDraft, + ServicePricing, + SynapseBillingMetadata, SynapseResponse, TokenResponse, UpdateCredentialResult, + UsageLogList, + VoucherRedeemResult, ) from .provider import SynapseProvider +from .wallet import AgentWallet __all__ = [ "AgentWallet", @@ -55,18 +81,43 @@ "SynapseResponse", "DiscoveryResponse", "InvocationResponse", + "LlmUsage", + "AuthLogoutResult", "ChallengeResponse", + "CredentialAuditLogList", + "CredentialDeleteResult", + "CredentialQuotaUpdateResult", + "CredentialRevokeResult", + "CredentialRotateResult", "CredentialStatusResult", "TokenResponse", "AgentCredential", "IssueCredentialResult", "IssueProviderSecretResult", + "OwnerProfile", + "ProviderEarningsSummary", + "ProviderRegistrationGuide", "ProviderSecret", + "ProviderSecretDeleteResult", "ProviderService", + "ProviderServiceDeleteResult", + "ProviderServiceHealthHistory", + "ProviderServicePingResult", "ProviderServiceRegistrationResult", "ProviderServiceStatus", + "ProviderServiceUpdateResult", + "ProviderWithdrawalCapability", + "ProviderWithdrawalIntentResult", + "ProviderWithdrawalList", + "RiskOverview", + "ServiceManifestDraft", + "ServicePricing", + "SynapseBillingMetadata", "BalanceSummary", "DepositIntentResult", "DepositConfirmResult", "UpdateCredentialResult", + "UsageLogList", + "VoucherRedeemResult", + "FinanceAuditLogList", ] diff --git a/python/synapse_client/_auth_credentials.py b/python/synapse_client/_auth_credentials.py index fea81f2..9d77034 100644 --- a/python/synapse_client/_auth_credentials.py +++ b/python/synapse_client/_auth_credentials.py @@ -3,7 +3,17 @@ from typing import Any, Dict from .exceptions import AuthenticationError -from .models import AgentCredential, CredentialStatusResult, IssueCredentialResult, UpdateCredentialResult +from .models import ( + AgentCredential, + CredentialAuditLogList, + CredentialDeleteResult, + CredentialQuotaUpdateResult, + CredentialRevokeResult, + CredentialRotateResult, + CredentialStatusResult, + IssueCredentialResult, + UpdateCredentialResult, +) class CredentialManagementMixin: @@ -91,34 +101,37 @@ def get_credential_status(self, credential_id: str) -> CredentialStatusResult: ) return CredentialStatusResult.model_validate(payload) - def revoke_credential(self, credential_id: str) -> Dict[str, Any]: + def revoke_credential(self, credential_id: str) -> CredentialRevokeResult: """Revoke an agent credential without deleting its audit trail.""" credential_id = self._require_value(credential_id, "credential_id") - return self._request( + payload = self._request( "POST", f"/api/v1/credentials/agent/{credential_id}/revoke", headers=self._authorized_headers(), ) + return CredentialRevokeResult.model_validate(payload) - def rotate_credential(self, credential_id: str) -> Dict[str, Any]: + def rotate_credential(self, credential_id: str) -> CredentialRotateResult: """Rotate an agent credential and return the gateway response containing the new token.""" credential_id = self._require_value(credential_id, "credential_id") - return self._request( + payload = self._request( "POST", f"/api/v1/credentials/agent/{credential_id}/rotate", headers=self._authorized_headers(), ) + return CredentialRotateResult.model_validate(payload) - def delete_credential(self, credential_id: str) -> Dict[str, Any]: + def delete_credential(self, credential_id: str) -> CredentialDeleteResult: """Delete an agent credential. Use revoke_credential for emergency shutoff.""" credential_id = self._require_value(credential_id, "credential_id") - return self._request( + payload = self._request( "DELETE", f"/api/v1/credentials/agent/{credential_id}", headers=self._authorized_headers(), ) + return CredentialDeleteResult.model_validate(payload) - def update_credential_quota(self, credential_id: str, **options: Any) -> Dict[str, Any]: + def update_credential_quota(self, credential_id: str, **options: Any) -> CredentialQuotaUpdateResult: """Update spend/call/rate quota fields for an agent credential.""" credential_id = self._require_value(credential_id, "credential_id") aliases = { @@ -135,20 +148,22 @@ def update_credential_quota(self, credential_id: str, **options: Any) -> Dict[st value = options.get(key) if value is not None: body[key] = value - return self._request( + payload = self._request( "PATCH", f"/api/v1/credentials/agent/{credential_id}/quota", headers=self._authorized_headers(), json_body=body, ) + return CredentialQuotaUpdateResult.model_validate(payload) - def get_credential_audit_logs(self, *, limit: int = 100) -> Dict[str, Any]: + def get_credential_audit_logs(self, *, limit: int = 100) -> CredentialAuditLogList: """Fetch credential lifecycle audit logs for the authenticated owner.""" - return self._request( + payload = self._request( "GET", self._query_path("/api/v1/credentials/agent/audit-logs", {"limit": limit}), headers=self._authorized_headers(), ) + return CredentialAuditLogList.model_validate(payload) def check_credential_status(self, credential_id: str) -> CredentialStatusResult: """Alias for get_credential_status().""" diff --git a/python/synapse_client/_auth_finance.py b/python/synapse_client/_auth_finance.py index 1311b75..5989192 100644 --- a/python/synapse_client/_auth_finance.py +++ b/python/synapse_client/_auth_finance.py @@ -1,9 +1,17 @@ from __future__ import annotations -from typing import Any, Dict, Optional +from typing import Optional from uuid import uuid4 -from .models import BalanceSummary, DepositConfirmResult, DepositIntentResult +from .models import ( + BalanceSummary, + DepositConfirmResult, + DepositIntentResult, + FinanceAuditLogList, + RiskOverview, + UsageLogList, + VoucherRedeemResult, +) class FinanceManagementMixin: @@ -51,23 +59,24 @@ def confirm_deposit(self, intent_id: str, event_key: str, confirmations: int = 1 ) return DepositConfirmResult.model_validate(payload) - def set_spending_limit(self, spending_limit_usdc: float | None) -> Dict[str, Any]: + def set_spending_limit(self, spending_limit_usdc: float | None) -> None: body = ( {"allowUnlimited": True} if spending_limit_usdc is None else {"spendingLimitUsdc": spending_limit_usdc, "allowUnlimited": False} ) - return self._request( + self._request( "PUT", "/api/v1/balance/spending-limit", headers=self._authorized_headers(), json_body=body, ) + return None - def redeem_voucher(self, voucher_code: str, *, idempotency_key: Optional[str] = None) -> Dict[str, Any]: + def redeem_voucher(self, voucher_code: str, *, idempotency_key: Optional[str] = None) -> VoucherRedeemResult: """Redeem a voucher into the authenticated owner balance.""" voucher_code = self._require_value(voucher_code, "voucher_code") - return self._request( + payload = self._request( "POST", "/api/v1/balance/vouchers/redeem", headers={ @@ -76,27 +85,31 @@ def redeem_voucher(self, voucher_code: str, *, idempotency_key: Optional[str] = }, json_body={"voucherCode": voucher_code}, ) + return VoucherRedeemResult.model_validate(payload) - def get_usage_logs(self, *, limit: int = 100) -> Dict[str, Any]: + def get_usage_logs(self, *, limit: int = 100) -> UsageLogList: """Fetch owner usage logs for observability and billing review.""" - return self._request( + payload = self._request( "GET", self._query_path("/api/v1/usage/logs", {"limit": limit}), headers=self._authorized_headers(), ) + return UsageLogList.model_validate(payload) - def get_finance_audit_logs(self, *, limit: int = 100) -> Dict[str, Any]: + def get_finance_audit_logs(self, *, limit: int = 100) -> FinanceAuditLogList: """Fetch finance audit logs. High-impact finance actions remain explicit.""" - return self._request( + payload = self._request( "GET", self._query_path("/api/v1/finance/audit-logs", {"limit": limit}), headers=self._authorized_headers(), ) + return FinanceAuditLogList.model_validate(payload) - def get_risk_overview(self) -> Dict[str, Any]: + def get_risk_overview(self) -> RiskOverview: """Return the owner finance risk overview.""" - return self._request( + payload = self._request( "GET", "/api/v1/finance/risk-overview", headers=self._authorized_headers(), ) + return RiskOverview.model_validate(payload) diff --git a/python/synapse_client/_auth_provider_control.py b/python/synapse_client/_auth_provider_control.py index ad2cdff..cfdffd1 100644 --- a/python/synapse_client/_auth_provider_control.py +++ b/python/synapse_client/_auth_provider_control.py @@ -6,10 +6,21 @@ from .exceptions import AuthenticationError from .models import ( IssueProviderSecretResult, + ProviderEarningsSummary, + ProviderRegistrationGuide, ProviderSecret, + ProviderSecretDeleteResult, ProviderService, + ProviderServiceDeleteResult, + ProviderServiceHealthHistory, + ProviderServicePingResult, ProviderServiceRegistrationResult, ProviderServiceStatus, + ProviderServiceUpdateResult, + ProviderWithdrawalCapability, + ProviderWithdrawalIntentResult, + ProviderWithdrawalList, + ServiceManifestDraft, ) @@ -54,22 +65,30 @@ def list_provider_secrets(self) -> list[ProviderSecret]: return [] return [ProviderSecret.model_validate(item) for item in secrets if isinstance(item, dict)] - def delete_provider_secret(self, secret_id: str) -> Dict[str, Any]: + def delete_provider_secret(self, secret_id: str) -> ProviderSecretDeleteResult: """Delete a provider control-plane secret.""" secret_id = self._require_value(secret_id, "secret_id") - return self._request( + payload = self._request( "DELETE", f"/api/v1/secrets/provider/{secret_id}", headers=self._authorized_headers(), ) + return ProviderSecretDeleteResult.model_validate(payload) def register_provider_service( self, *, service_name: str, endpoint_url: str, - base_price_usdc: float | str, + base_price_usdc: Optional[float | str] = None, description_for_model: str, + service_kind: str = "api", + price_model: Optional[str] = None, + input_price_per_1m_tokens_usdc: Optional[float | str] = None, + output_price_per_1m_tokens_usdc: Optional[float | str] = None, + default_max_output_tokens: Optional[int] = None, + hold_buffer_multiplier: Optional[float | str] = None, + max_auto_hold_usdc: Optional[float | str] = None, service_id: Optional[str] = None, provider_display_name: Optional[str] = None, payout_address: Optional[str] = None, @@ -96,6 +115,13 @@ def register_provider_service( body = self._provider_service_body( service_values=service_values, base_price_usdc=base_price_usdc, + service_kind=service_kind, + price_model=price_model, + input_price_per_1m_tokens_usdc=input_price_per_1m_tokens_usdc, + output_price_per_1m_tokens_usdc=output_price_per_1m_tokens_usdc, + default_max_output_tokens=default_max_output_tokens, + hold_buffer_multiplier=hold_buffer_multiplier, + max_auto_hold_usdc=max_auto_hold_usdc, provider_display_name=provider_display_name, payout_address=payout_address, chain_id=chain_id, @@ -144,7 +170,14 @@ def _provider_service_body( self, *, service_values: Dict[str, str], - base_price_usdc: float | str, + base_price_usdc: Optional[float | str], + service_kind: str, + price_model: Optional[str], + input_price_per_1m_tokens_usdc: Optional[float | str], + output_price_per_1m_tokens_usdc: Optional[float | str], + default_max_output_tokens: Optional[int], + hold_buffer_multiplier: Optional[float | str], + max_auto_hold_usdc: Optional[float | str], provider_display_name: Optional[str], payout_address: Optional[str], chain_id: int, @@ -162,17 +195,25 @@ def _provider_service_body( governance_note: Optional[str], ) -> Dict[str, Any]: service_id = service_values["service_id"] + resolved_price_model = price_model or ("token_metered" if service_kind == "llm" else "fixed") return { "serviceId": service_id, "agentToolName": service_id, "serviceName": service_values["name"], + "serviceKind": service_kind, + "priceModel": resolved_price_model, "role": "Provider", "status": status, "isActive": is_active, - "pricing": { - "amount": str(base_price_usdc), - "currency": "USDC", - }, + "pricing": self._provider_pricing( + price_model=resolved_price_model, + base_price_usdc=base_price_usdc, + input_price_per_1m_tokens_usdc=input_price_per_1m_tokens_usdc, + output_price_per_1m_tokens_usdc=output_price_per_1m_tokens_usdc, + default_max_output_tokens=default_max_output_tokens, + hold_buffer_multiplier=hold_buffer_multiplier, + max_auto_hold_usdc=max_auto_hold_usdc, + ), "summary": service_values["summary"], "tags": tags or [], "auth": {"type": "gateway_signed"}, @@ -193,6 +234,45 @@ def _provider_service_body( }, } + def register_llm_service(self, **options: Any) -> ProviderServiceRegistrationResult: + """Register a token-metered LLM Provider service.""" + options["service_kind"] = "llm" + options["price_model"] = "token_metered" + return self.register_provider_service(**options) + + @staticmethod + def _provider_pricing( + *, + price_model: str, + base_price_usdc: Optional[float | str], + input_price_per_1m_tokens_usdc: Optional[float | str], + output_price_per_1m_tokens_usdc: Optional[float | str], + default_max_output_tokens: Optional[int], + hold_buffer_multiplier: Optional[float | str], + max_auto_hold_usdc: Optional[float | str], + ) -> Dict[str, Any]: + if price_model == "token_metered": + if input_price_per_1m_tokens_usdc is None: + raise ValueError("input_price_per_1m_tokens_usdc is required") + if output_price_per_1m_tokens_usdc is None: + raise ValueError("output_price_per_1m_tokens_usdc is required") + pricing: Dict[str, Any] = { + "priceModel": "token_metered", + "inputPricePer1MTokensUsdc": str(input_price_per_1m_tokens_usdc), + "outputPricePer1MTokensUsdc": str(output_price_per_1m_tokens_usdc), + "currency": "USDC", + } + if default_max_output_tokens is not None: + pricing["defaultMaxOutputTokens"] = default_max_output_tokens + if hold_buffer_multiplier is not None: + pricing["holdBufferMultiplier"] = hold_buffer_multiplier + if max_auto_hold_usdc is not None: + pricing["maxAutoHoldUsdc"] = str(max_auto_hold_usdc) + return pricing + if base_price_usdc is None: + raise ValueError("base_price_usdc is required") + return {"amount": str(base_price_usdc), "currency": "USDC"} + @staticmethod def _provider_invoke_config( *, @@ -248,76 +328,86 @@ def list_provider_services(self) -> list[ProviderService]: return [] return [ProviderService.model_validate(item) for item in services if isinstance(item, dict)] - def get_registration_guide(self) -> Dict[str, Any]: + def get_registration_guide(self) -> ProviderRegistrationGuide: """Fetch the provider registration guide from the gateway control plane.""" - return self._request( + payload = self._request( "GET", "/api/v1/services/registration-guide", headers=self._authorized_headers(), ) + return ProviderRegistrationGuide.model_validate(payload) - def parse_curl_to_service_manifest(self, curl_command: str) -> Dict[str, Any]: + def parse_curl_to_service_manifest(self, curl_command: str) -> ServiceManifestDraft: """Convert a curl command into a provider service manifest draft.""" curl_command = self._require_value(curl_command, "curl_command") - return self._request( + payload = self._request( "POST", "/api/v1/services/parse-curl", headers=self._authorized_headers(), json_body={"curlCommand": curl_command}, ) + return ServiceManifestDraft.model_validate(payload) - def update_provider_service(self, service_record_id: str, patch: Dict[str, Any]) -> Dict[str, Any]: + def update_provider_service(self, service_record_id: str, patch: Dict[str, Any]) -> ProviderServiceUpdateResult: """Patch a provider service registration by gateway record ID.""" service_record_id = self._require_value(service_record_id, "service_record_id") - return self._request( + payload = self._request( "PUT", f"/api/v1/services/{service_record_id}", headers=self._authorized_headers(), json_body=patch or {}, ) + return ProviderServiceUpdateResult.model_validate(payload) - def delete_provider_service(self, service_record_id: str) -> Dict[str, Any]: + def delete_provider_service(self, service_record_id: str) -> ProviderServiceDeleteResult: """Delete a provider service registration by gateway record ID.""" service_record_id = self._require_value(service_record_id, "service_record_id") - return self._request( + payload = self._request( "DELETE", f"/api/v1/services/{service_record_id}", headers=self._authorized_headers(), ) + return ProviderServiceDeleteResult.model_validate(payload) - def ping_provider_service(self, service_record_id: str) -> Dict[str, Any]: + def ping_provider_service(self, service_record_id: str) -> ProviderServicePingResult: """Force a provider service health ping.""" service_record_id = self._require_value(service_record_id, "service_record_id") - return self._request( + payload = self._request( "POST", f"/api/v1/services/{service_record_id}/ping", headers=self._authorized_headers(), ) + return ProviderServicePingResult.model_validate(payload) - def get_provider_service_health_history(self, service_record_id: str, *, limit: int = 100) -> Dict[str, Any]: + def get_provider_service_health_history( + self, service_record_id: str, *, limit: int = 100 + ) -> ProviderServiceHealthHistory: """Fetch health history for a provider service.""" service_record_id = self._require_value(service_record_id, "service_record_id") - return self._request( + payload = self._request( "GET", self._query_path(f"/api/v1/services/{service_record_id}/health/history", {"limitPerTarget": limit}), headers=self._authorized_headers(), ) + return ProviderServiceHealthHistory.model_validate(payload) - def get_provider_earnings_summary(self) -> Dict[str, Any]: + def get_provider_earnings_summary(self) -> ProviderEarningsSummary: """Return provider earnings summary for the authenticated owner.""" - return self._request( + payload = self._request( "GET", "/api/v1/providers/earnings/summary", headers=self._authorized_headers(), ) + return ProviderEarningsSummary.model_validate(payload) - def get_provider_withdrawal_capability(self) -> Dict[str, Any]: + def get_provider_withdrawal_capability(self) -> ProviderWithdrawalCapability: """Return whether provider withdrawals are currently available.""" - return self._request( + payload = self._request( "GET", "/api/v1/providers/withdrawals/capability", headers=self._authorized_headers(), ) + return ProviderWithdrawalCapability.model_validate(payload) def create_provider_withdrawal_intent( self, @@ -325,12 +415,12 @@ def create_provider_withdrawal_intent( *, idempotency_key: Optional[str] = None, destination_address: Optional[str] = None, - ) -> Dict[str, Any]: + ) -> ProviderWithdrawalIntentResult: """Create a provider withdrawal intent. This does not auto-submit funds on-chain.""" body: Dict[str, Any] = {"amountUsdc": amount_usdc} if destination_address: body["destinationAddress"] = destination_address - return self._request( + payload = self._request( "POST", "/api/v1/providers/withdrawals/intent", headers={ @@ -339,14 +429,16 @@ def create_provider_withdrawal_intent( }, json_body=body, ) + return ProviderWithdrawalIntentResult.model_validate(payload) - def list_provider_withdrawals(self, *, limit: int = 100) -> Dict[str, Any]: + def list_provider_withdrawals(self, *, limit: int = 100) -> ProviderWithdrawalList: """List provider withdrawal records.""" - return self._request( + payload = self._request( "GET", self._query_path("/api/v1/providers/withdrawals", {"limit": limit}), headers=self._authorized_headers(), ) + return ProviderWithdrawalList.model_validate(payload) def get_provider_service(self, service_id: str) -> ProviderService: resolved_service_id = str(service_id or "").strip() diff --git a/python/synapse_client/_control_result_models.py b/python/synapse_client/_control_result_models.py new file mode 100644 index 0000000..bf0d941 --- /dev/null +++ b/python/synapse_client/_control_result_models.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from decimal import Decimal +from typing import Any, Dict, List, Optional + +from pydantic import Field + +from .models import AgentCredential, ProviderService, SDKModel + + +class AuthLogoutResult(SDKModel): + status: str = "success" + success: Optional[bool] = None + + +class OwnerProfile(SDKModel): + profile: Dict[str, Any] = Field(default_factory=dict) + owner_address: Optional[str] = Field(default=None, alias="ownerAddress") + wallet_address: Optional[str] = Field(default=None, alias="walletAddress") + + +class CredentialRevokeResult(SDKModel): + status: str = "success" + credential_id: str = Field(default="", alias="credentialId") + credential: Optional[AgentCredential] = None + + +class CredentialRotateResult(SDKModel): + status: str = "success" + credential_id: str = Field(default="", alias="credentialId") + token: str = "" + credential: Optional[AgentCredential] = None + + +class CredentialDeleteResult(SDKModel): + status: str = "success" + credential_id: str = Field(default="", alias="credentialId") + + +class CredentialQuotaUpdateResult(SDKModel): + status: str = "success" + credential_id: str = Field(default="", alias="credentialId") + credential: Optional[AgentCredential] = None + + +class CredentialAuditLogList(SDKModel): + logs: List[Dict[str, Any]] = Field(default_factory=list) + + +class VoucherRedeemResult(SDKModel): + status: str = "success" + voucher_code: Optional[str] = Field(default=None, alias="voucherCode") + + +class UsageLogList(SDKModel): + logs: List[Dict[str, Any]] = Field(default_factory=list) + + +class FinanceAuditLogList(SDKModel): + logs: List[Dict[str, Any]] = Field(default_factory=list) + + +class RiskOverview(SDKModel): + risk: Optional[Any] = None + + +class ProviderSecretDeleteResult(SDKModel): + status: str = "success" + secret_id: str = Field(default="", alias="secretId") + + +class ProviderRegistrationGuide(SDKModel): + steps: List[Any] = Field(default_factory=list) + requirements: Dict[str, Any] = Field(default_factory=dict) + + +class ServiceManifestDraft(SDKModel): + data: Dict[str, Any] = Field(default_factory=dict) + manifest: Dict[str, Any] = Field(default_factory=dict) + + +class ProviderServiceUpdateResult(SDKModel): + status: str = "success" + service: Optional[ProviderService] = None + + +class ProviderServiceDeleteResult(SDKModel): + status: str = "success" + service_id: str = Field(default="", alias="serviceId") + + +class ProviderServicePingResult(SDKModel): + status: str = "success" + health: Dict[str, Any] = Field(default_factory=dict) + + +class ProviderServiceHealthHistory(SDKModel): + history: List[Dict[str, Any]] = Field(default_factory=list) + + +class ProviderEarningsSummary(SDKModel): + total: Optional[Decimal | float | int | str] = None + + +class ProviderWithdrawalCapability(SDKModel): + available: Optional[bool] = None + + +class ProviderWithdrawalIntentResult(SDKModel): + status: str = "success" + intent_id: str = Field(default="", alias="intentId") + amount_usdc: Optional[Decimal | float | int | str] = Field(default=None, alias="amountUsdc") + intent: Dict[str, Any] = Field(default_factory=dict) + + +class ProviderWithdrawalList(SDKModel): + withdrawals: List[Dict[str, Any]] = Field(default_factory=list) diff --git a/python/synapse_client/auth.py b/python/synapse_client/auth.py index 63f2b95..da21941 100644 --- a/python/synapse_client/auth.py +++ b/python/synapse_client/auth.py @@ -12,7 +12,7 @@ from ._auth_provider_control import ProviderControlMixin from .config import resolve_gateway_url from .exceptions import AuthenticationError -from .models import ChallengeResponse, TokenResponse +from .models import AuthLogoutResult, ChallengeResponse, OwnerProfile, TokenResponse SignerFn = Callable[[str], str] @@ -195,7 +195,7 @@ def authenticate(self, force_refresh: bool = False) -> str: def get_token(self) -> str: return self.authenticate() - def logout(self) -> Dict[str, Any]: + def logout(self) -> AuthLogoutResult: payload = self._request( "POST", "/api/v1/auth/logout", @@ -203,12 +203,13 @@ def logout(self) -> Dict[str, Any]: ) self._token = None self._token_expires_at = 0 - return payload + return AuthLogoutResult.model_validate(payload) - def get_owner_profile(self) -> Dict[str, Any]: + def get_owner_profile(self) -> OwnerProfile: """Return the authenticated owner profile.""" - return self._request( + payload = self._request( "GET", "/api/v1/auth/me", headers=self._authorized_headers(), ) + return OwnerProfile.model_validate(payload) diff --git a/python/synapse_client/client.py b/python/synapse_client/client.py index ba93dda..f8690ee 100644 --- a/python/synapse_client/client.py +++ b/python/synapse_client/client.py @@ -1,6 +1,6 @@ import os import time -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, Union from uuid import uuid4 import requests @@ -333,11 +333,13 @@ def invoke( service_id: str, payload: Optional[Dict[str, Any]] = None, *, - cost_usdc: float, + cost_usdc: Optional[Union[float, str]] = None, + max_cost_usdc: Optional[Union[float, str]] = None, idempotency_key: Optional[str] = None, response_mode: str = "sync", poll_timeout_sec: int = 90, request_id: Optional[str] = None, + _allow_missing_cost_usdc: bool = False, ) -> InvocationResponse: """Invoke a service with price assertion. @@ -347,16 +349,21 @@ def invoke( """ if not service_id or not service_id.strip(): raise ValueError("service_id is required") + if cost_usdc is None and not _allow_missing_cost_usdc: + raise ValueError("cost_usdc is required for fixed-price API services. Use invoke_llm() for LLM services.") invocation_key = (idempotency_key or f"invoke-{uuid4().hex}").strip() runtime_payload = RuntimePayload(body=payload or {}) body = { "serviceId": service_id.strip(), "idempotencyKey": invocation_key, - "costUsdc": round(float(cost_usdc), 6), "payload": runtime_payload.model_dump(by_alias=True), "responseMode": response_mode, } + if cost_usdc is not None: + body["costUsdc"] = round(float(cost_usdc), 6) + if max_cost_usdc is not None: + body["maxCostUsdc"] = str(max_cost_usdc) response = requests.post( f"{self.gateway_url}/api/v1/agent/invoke", headers=self._headers(request_id=request_id), @@ -370,6 +377,36 @@ def invoke( return invocation return self.wait_for_invocation(invocation.invocation_id, max_wait_sec=poll_timeout_sec) + def invoke_llm( + self, + service_id: str, + payload: Optional[Dict[str, Any]] = None, + *, + max_cost_usdc: Optional[Union[float, str]] = None, + idempotency_key: Optional[str] = None, + poll_timeout_sec: int = 90, + request_id: Optional[str] = None, + ) -> InvocationResponse: + """Invoke a token-metered LLM service. + + ``cost_usdc`` is intentionally not used for LLM services. If + ``max_cost_usdc`` is omitted, Gateway performs automatic + pre-authorization and only captures final Provider-reported usage. + """ + runtime_payload = payload or {} + if runtime_payload.get("stream") is True: + raise InvokeError("LLM_STREAMING_NOT_SUPPORTED: stream=True is not supported for token-metered billing.") + return self.invoke( + service_id, + runtime_payload, + max_cost_usdc=max_cost_usdc, + idempotency_key=idempotency_key, + response_mode="sync", + poll_timeout_sec=poll_timeout_sec, + request_id=request_id, + _allow_missing_cost_usdc=True, + ) + def invoke_with_rediscovery( self, service_id: str, @@ -440,60 +477,4 @@ def _rediscovered_price( return fallback_price -class AgentWallet(SynapseClient): - """Convenience wrapper — the 3-line DX entry point for agent developers. - - Usage:: - - from synapse_client import AgentWallet - wallet = AgentWallet.connect(budget=5.0) # Line 1 - svc = wallet.search_services("market data").services[0] # Line 2 - result = wallet.invoke(svc.service_id, payload={}, cost_usdc=float(svc.price_usdc)) # Line 3 - print(result.status, result.charged_usdc) - - The ``budget`` parameter enforces a spend ceiling tracked client-side. - When cumulative ``charged_usdc`` would exceed it, an ``InsufficientFundsError`` - is raised before the HTTP call is made. - """ - - def __init__(self, budget: float = 5.0, **kwargs): - super().__init__(**kwargs) - self._budget_usdc = float(budget) - self._spent_usdc: float = 0.0 - - @classmethod - def connect( - cls, - budget: float = 5.0, - api_key: Optional[str] = None, - gateway_url: Optional[str] = None, - environment: Optional[str] = None, - ) -> "AgentWallet": - """Factory method — create and validate an AgentWallet in one call.""" - api_key = api_key or os.getenv("SYNAPSE_API_KEY", "") - return cls(budget=budget, api_key=api_key, gateway_url=gateway_url, environment=environment) - - @property - def budget_usdc(self) -> float: - return self._budget_usdc - - @property - def spent_usdc(self) -> float: - return self._spent_usdc - - @property - def remaining_usdc(self) -> float: - return round(self._budget_usdc - self._spent_usdc, 6) - - def invoke( - self, service_id: str, *, payload: Optional[Dict[str, Any]] = None, cost_usdc: float = 0.0, **kwargs - ) -> "InvocationResponse": - """Invoke a service and track spend against the budget ceiling.""" - cost = float(cost_usdc) - if self._spent_usdc + cost > self._budget_usdc: - raise InsufficientFundsError( - f"Budget exceeded: ${self._spent_usdc:.4f} spent + ${cost:.4f} cost > ${self._budget_usdc:.4f} budget" - ) - result = super().invoke(service_id, payload=payload, cost_usdc=cost_usdc, **kwargs) - self._spent_usdc = round(self._spent_usdc + float(result.charged_usdc), 6) - return result +from .wallet import AgentWallet # noqa: E402, F401 diff --git a/python/synapse_client/models.py b/python/synapse_client/models.py index fc473a9..a2c319e 100644 --- a/python/synapse_client/models.py +++ b/python/synapse_client/models.py @@ -141,6 +141,12 @@ class DepositConfirmResult(SDKModel): class ServicePricing(SDKModel): amount: str = "0" currency: str = "USDC" + price_model: Optional[str] = Field(default=None, alias="priceModel") + input_price_per_1m_tokens_usdc: Optional[str] = Field(default=None, alias="inputPricePer1MTokensUsdc") + output_price_per_1m_tokens_usdc: Optional[str] = Field(default=None, alias="outputPricePer1MTokensUsdc") + default_max_output_tokens: Optional[int] = Field(default=None, alias="defaultMaxOutputTokens") + hold_buffer_multiplier: Optional[Decimal | float | int | str] = Field(default=None, alias="holdBufferMultiplier") + max_auto_hold_usdc: Optional[str] = Field(default=None, alias="maxAutoHoldUsdc") class ServiceHealthSummary(SDKModel): @@ -170,7 +176,13 @@ class QuoteTemplate(SDKModel): class DiscoveredService(SDKModel): service_id: str = Field(default="", alias="serviceId") service_name: str = Field(default="", alias="serviceName") + service_kind: str = Field(default="api", alias="serviceKind") + price_model: str = Field(default="fixed", alias="priceModel") pricing: ServicePricing = Field(default_factory=ServicePricing) + input_price_per_1m_tokens_usdc: Optional[str] = Field(default=None, alias="inputPricePer1MTokensUsdc") + output_price_per_1m_tokens_usdc: Optional[str] = Field(default=None, alias="outputPricePer1MTokensUsdc") + default_max_output_tokens: Optional[int] = Field(default=None, alias="defaultMaxOutputTokens") + max_auto_hold_usdc: Optional[str] = Field(default=None, alias="maxAutoHoldUsdc") summary: str = "" tags: List[str] = Field(default_factory=list) status: str = "unknown" @@ -193,6 +205,10 @@ def serviceId(self) -> str: def serviceName(self) -> str: return self.service_name + @property + def is_llm(self) -> bool: + return self.service_kind == "llm" or self.price_model == "token_metered" + class ProviderProfile(SDKModel): display_name: str = Field(default="", alias="displayName") @@ -216,10 +232,16 @@ class ProviderService(SDKModel): service_id: str = Field(default="", alias="serviceId") owner_address: str = Field(default="", alias="ownerAddress") service_name: str = Field(default="", alias="serviceName") + service_kind: str = Field(default="api", alias="serviceKind") + price_model: str = Field(default="fixed", alias="priceModel") summary: str = "" status: str = "unknown" is_active: bool = Field(default=True, alias="isActive") pricing: ServicePricing = Field(default_factory=ServicePricing) + input_price_per_1m_tokens_usdc: Optional[str] = Field(default=None, alias="inputPricePer1MTokensUsdc") + output_price_per_1m_tokens_usdc: Optional[str] = Field(default=None, alias="outputPricePer1MTokensUsdc") + default_max_output_tokens: Optional[int] = Field(default=None, alias="defaultMaxOutputTokens") + max_auto_hold_usdc: Optional[str] = Field(default=None, alias="maxAutoHoldUsdc") tags: List[str] = Field(default_factory=list) role: Optional[str] = None address: Optional[str] = None @@ -255,6 +277,32 @@ class ProviderServiceStatus(SDKModel): health: ServiceHealthSummary = Field(default_factory=ServiceHealthSummary) +from ._control_result_models import ( # noqa: E402, F401 + AuthLogoutResult, + CredentialAuditLogList, + CredentialDeleteResult, + CredentialQuotaUpdateResult, + CredentialRevokeResult, + CredentialRotateResult, + FinanceAuditLogList, + OwnerProfile, + ProviderEarningsSummary, + ProviderRegistrationGuide, + ProviderSecretDeleteResult, + ProviderServiceDeleteResult, + ProviderServiceHealthHistory, + ProviderServicePingResult, + ProviderServiceUpdateResult, + ProviderWithdrawalCapability, + ProviderWithdrawalIntentResult, + ProviderWithdrawalList, + RiskOverview, + ServiceManifestDraft, + UsageLogList, + VoucherRedeemResult, +) + + class DiscoveryResponse(SDKModel): request_id: str = Field(default="", alias="requestId") count: int = 0 @@ -312,11 +360,31 @@ class InvocationError(SDKModel): action: str = "stop" +class LlmUsage(SDKModel): + input_tokens: Optional[int] = Field(default=None, alias="inputTokens") + output_tokens: Optional[int] = Field(default=None, alias="outputTokens") + total_tokens: Optional[int] = Field(default=None, alias="totalTokens") + prompt_tokens: Optional[int] = None + completion_tokens: Optional[int] = None + + +class SynapseBillingMetadata(SDKModel): + price_model: Optional[str] = Field(default=None, alias="priceModel") + hold_usdc: Optional[str] = Field(default=None, alias="holdUsdc") + charged_usdc: Optional[str] = Field(default=None, alias="chargedUsdc") + released_usdc: Optional[str] = Field(default=None, alias="releasedUsdc") + provider_revenue_usdc: Optional[str] = Field(default=None, alias="providerRevenueUsdc") + platform_fee_usdc: Optional[str] = Field(default=None, alias="platformFeeUsdc") + pre_auth_mode: Optional[str] = Field(default=None, alias="preAuthMode") + + class InvocationResponse(SDKModel): invocation_id: str = Field(default="", alias="invocationId") status: str = "PENDING" charged_usdc: float = Field(default=0.0, alias="chargedUsdc") result: Dict[str, Any] = Field(default_factory=dict) + usage: Optional[LlmUsage] = None + synapse: Optional[SynapseBillingMetadata] = None error: Optional[InvocationError] = None receipt: Optional[InvocationReceipt] = None diff --git a/python/synapse_client/provider.py b/python/synapse_client/provider.py index 76e4623..012efc9 100644 --- a/python/synapse_client/provider.py +++ b/python/synapse_client/provider.py @@ -4,10 +4,21 @@ from .models import ( IssueProviderSecretResult, + ProviderEarningsSummary, + ProviderRegistrationGuide, ProviderSecret, + ProviderSecretDeleteResult, ProviderService, + ProviderServiceDeleteResult, + ProviderServiceHealthHistory, + ProviderServicePingResult, ProviderServiceRegistrationResult, ProviderServiceStatus, + ProviderServiceUpdateResult, + ProviderWithdrawalCapability, + ProviderWithdrawalIntentResult, + ProviderWithdrawalList, + ServiceManifestDraft, ) @@ -28,18 +39,21 @@ def issue_secret(self, **options: Any) -> IssueProviderSecretResult: def list_secrets(self) -> list[ProviderSecret]: return self._auth.list_provider_secrets() - def delete_secret(self, secret_id: str) -> Dict[str, Any]: + def delete_secret(self, secret_id: str) -> ProviderSecretDeleteResult: return self._auth.delete_provider_secret(secret_id) - def get_registration_guide(self) -> Dict[str, Any]: + def get_registration_guide(self) -> ProviderRegistrationGuide: return self._auth.get_registration_guide() - def parse_curl_to_service_manifest(self, curl_command: str) -> Dict[str, Any]: + def parse_curl_to_service_manifest(self, curl_command: str) -> ServiceManifestDraft: return self._auth.parse_curl_to_service_manifest(curl_command) def register_service(self, **options: Any) -> ProviderServiceRegistrationResult: return self._auth.register_provider_service(**options) + def register_llm_service(self, **options: Any) -> ProviderServiceRegistrationResult: + return self._auth.register_llm_service(**options) + def list_services(self) -> list[ProviderService]: return self._auth.list_provider_services() @@ -49,22 +63,22 @@ def get_service(self, service_id: str) -> ProviderService: def get_service_status(self, service_id: str) -> ProviderServiceStatus: return self._auth.get_provider_service_status(service_id) - def update_service(self, service_record_id: str, patch: Dict[str, Any]) -> Dict[str, Any]: + def update_service(self, service_record_id: str, patch: Dict[str, Any]) -> ProviderServiceUpdateResult: return self._auth.update_provider_service(service_record_id, patch) - def delete_service(self, service_record_id: str) -> Dict[str, Any]: + def delete_service(self, service_record_id: str) -> ProviderServiceDeleteResult: return self._auth.delete_provider_service(service_record_id) - def ping_service(self, service_record_id: str) -> Dict[str, Any]: + def ping_service(self, service_record_id: str) -> ProviderServicePingResult: return self._auth.ping_provider_service(service_record_id) - def get_service_health_history(self, service_record_id: str, *, limit: int = 100) -> Dict[str, Any]: + def get_service_health_history(self, service_record_id: str, *, limit: int = 100) -> ProviderServiceHealthHistory: return self._auth.get_provider_service_health_history(service_record_id, limit=limit) - def get_earnings_summary(self) -> Dict[str, Any]: + def get_earnings_summary(self) -> ProviderEarningsSummary: return self._auth.get_provider_earnings_summary() - def get_withdrawal_capability(self) -> Dict[str, Any]: + def get_withdrawal_capability(self) -> ProviderWithdrawalCapability: return self._auth.get_provider_withdrawal_capability() def create_withdrawal_intent( @@ -73,12 +87,12 @@ def create_withdrawal_intent( *, idempotency_key: str | None = None, destination_address: str | None = None, - ) -> Dict[str, Any]: + ) -> ProviderWithdrawalIntentResult: return self._auth.create_provider_withdrawal_intent( amount_usdc, idempotency_key=idempotency_key, destination_address=destination_address, ) - def list_withdrawals(self, *, limit: int = 100) -> Dict[str, Any]: + def list_withdrawals(self, *, limit: int = 100) -> ProviderWithdrawalList: return self._auth.list_provider_withdrawals(limit=limit) diff --git a/python/synapse_client/test/test_auth_unit.py b/python/synapse_client/test/test_auth_unit.py index 2d2ed1f..890a7cf 100644 --- a/python/synapse_client/test/test_auth_unit.py +++ b/python/synapse_client/test/test_auth_unit.py @@ -2,8 +2,30 @@ import pytest -from synapse_client import SynapseAuth, SynapseProvider +from synapse_client import SynapseAuth from synapse_client.exceptions import AuthenticationError +from synapse_client.models import ( + CredentialDeleteResult, + CredentialQuotaUpdateResult, + CredentialRevokeResult, + CredentialRotateResult, + FinanceAuditLogList, + OwnerProfile, + ProviderEarningsSummary, + ProviderRegistrationGuide, + ProviderSecretDeleteResult, + ProviderServiceDeleteResult, + ProviderServiceHealthHistory, + ProviderServicePingResult, + ProviderServiceUpdateResult, + ProviderWithdrawalCapability, + ProviderWithdrawalIntentResult, + ProviderWithdrawalList, + RiskOverview, + ServiceManifestDraft, + UsageLogList, + VoucherRedeemResult, +) class DummyResponse: @@ -405,6 +427,52 @@ def fake_request(method, url, headers, json, timeout): assert calls[2]["json"]["healthCheck"]["path"] == "/health" +def test_register_llm_service_builds_token_metered_manifest(monkeypatch): + calls = [] + + def fake_request(method, url, headers, json, timeout): + calls.append({"method": method, "url": url, "headers": headers, "json": json}) + if url.endswith("/api/v1/auth/challenge?address=0xabc"): + return DummyResponse(json_data={"success": True, "challenge": "sign-me", "domain": "synapse"}) + if url.endswith("/api/v1/auth/verify"): + return DummyResponse( + json_data={"success": True, "access_token": "jwt-token", "token_type": "bearer", "expires_in": 3600} + ) + return DummyResponse( + json_data={ + "status": "success", + "serviceId": "svc_deepseek_chat", + "service": {"serviceId": "svc_deepseek_chat", "status": "active"}, + } + ) + + monkeypatch.setattr("synapse_client.auth.requests.request", fake_request) + + auth = SynapseAuth(wallet_address="0xabc", signer=lambda _: "0xsigned") + registered = auth.register_llm_service( + service_name="DeepSeek Chat", + endpoint_url="https://provider.example/llm", + description_for_model="OpenAI-compatible DeepSeek Chat endpoint.", + service_id="svc_deepseek_chat", + input_price_per_1m_tokens_usdc="0.140000", + output_price_per_1m_tokens_usdc="0.280000", + default_max_output_tokens=2048, + max_auto_hold_usdc="0.050000", + request_timeout_ms=120000, + ) + + body = calls[2]["json"] + assert registered.service_id == "svc_deepseek_chat" + assert body["serviceKind"] == "llm" + assert body["priceModel"] == "token_metered" + assert body["pricing"]["inputPricePer1MTokensUsdc"] == "0.140000" + assert body["pricing"]["outputPricePer1MTokensUsdc"] == "0.280000" + assert body["pricing"]["defaultMaxOutputTokens"] == 2048 + assert body["pricing"]["maxAutoHoldUsdc"] == "0.050000" + assert "pricePerToken" not in body["pricing"] + assert body["invoke"]["timeoutMs"] == 120000 + + def test_credential_lifecycle_and_owner_observability_helpers(monkeypatch): calls = [] @@ -421,15 +489,25 @@ 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.revoke_credential("cred_1") - auth.rotate_credential("cred_1") - auth.update_credential_quota("cred_1", credit_limit=5, rpm=60) - auth.delete_credential("cred_1") + revoked = auth.revoke_credential("cred_1") + rotated = auth.rotate_credential("cred_1") + quota = auth.update_credential_quota("cred_1", credit_limit=5, rpm=60) + deleted = auth.delete_credential("cred_1") auth.get_credential_audit_logs(limit=25) - auth.get_owner_profile() - auth.get_usage_logs(limit=10) - auth.get_finance_audit_logs(limit=7) - auth.get_risk_overview() + profile = auth.get_owner_profile() + usage = auth.get_usage_logs(limit=10) + finance_audit = auth.get_finance_audit_logs(limit=7) + risk = auth.get_risk_overview() + + assert isinstance(revoked, CredentialRevokeResult) + assert isinstance(rotated, CredentialRotateResult) + assert isinstance(quota, CredentialQuotaUpdateResult) + assert isinstance(deleted, CredentialDeleteResult) + assert isinstance(profile, OwnerProfile) + assert isinstance(usage, UsageLogList) + assert isinstance(finance_audit, FinanceAuditLogList) + assert isinstance(risk, RiskOverview) + assert not isinstance(revoked, dict) urls = [call["url"] for call in calls[2:]] methods = [call["method"] for call in calls[2:]] @@ -445,6 +523,44 @@ def fake_request(method, url, headers, json, timeout): assert urls[8].endswith("/api/v1/finance/risk-overview") +def _call_provider_lifecycle_helpers(auth): + return ( + auth.delete_provider_secret("psk_1"), + auth.get_registration_guide(), + auth.parse_curl_to_service_manifest("curl https://provider.example/health"), + auth.update_provider_service("svc_rec_1", {"summary": "updated"}), + auth.delete_provider_service("svc_rec_1"), + auth.ping_provider_service("svc_rec_1"), + auth.get_provider_service_health_history("svc_rec_1", limit=12), + auth.get_provider_earnings_summary(), + auth.get_provider_withdrawal_capability(), + auth.create_provider_withdrawal_intent(10, idempotency_key="provider-withdraw-fixed"), + auth.list_provider_withdrawals(limit=5), + auth.redeem_voucher("ABC123DEF456", idempotency_key="voucher-fixed-1234"), + ) + + +def _assert_provider_control_result_types(results): + expected_types = ( + ProviderSecretDeleteResult, + ProviderRegistrationGuide, + ServiceManifestDraft, + ProviderServiceUpdateResult, + ProviderServiceDeleteResult, + ProviderServicePingResult, + ProviderServiceHealthHistory, + ProviderEarningsSummary, + ProviderWithdrawalCapability, + ProviderWithdrawalIntentResult, + ProviderWithdrawalList, + VoucherRedeemResult, + ) + assert len(results) == len(expected_types) + for result, expected_type in zip(results, expected_types): + assert isinstance(result, expected_type) + assert not isinstance(results[9], dict) + + def test_provider_lifecycle_and_finance_helpers(monkeypatch): calls = [] @@ -461,18 +577,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.delete_provider_secret("psk_1") - auth.get_registration_guide() - auth.parse_curl_to_service_manifest("curl https://provider.example/health") - auth.update_provider_service("svc_rec_1", {"summary": "updated"}) - auth.delete_provider_service("svc_rec_1") - auth.ping_provider_service("svc_rec_1") - auth.get_provider_service_health_history("svc_rec_1", limit=12) - auth.get_provider_earnings_summary() - auth.get_provider_withdrawal_capability() - auth.create_provider_withdrawal_intent(10, idempotency_key="provider-withdraw-fixed") - auth.list_provider_withdrawals(limit=5) - auth.redeem_voucher("ABC123DEF456", idempotency_key="voucher-fixed-1234") + _assert_provider_control_result_types(_call_provider_lifecycle_helpers(auth)) urls = [call["url"] for call in calls[2:]] assert urls[0].endswith("/api/v1/secrets/provider/psk_1") @@ -488,120 +593,3 @@ def fake_request(method, url, headers, json, timeout): assert calls[11]["headers"]["X-Idempotency-Key"] == "provider-withdraw-fixed" assert urls[10].endswith("/api/v1/providers/withdrawals?limit=5") assert urls[11].endswith("/api/v1/balance/vouchers/redeem") - - -def test_provider_facade_delegates_to_owner_auth_methods(): - class DummyAuth: - def __init__(self): - self.calls = [] - - def issue_provider_secret(self, **options): - self.calls.append(("issue_provider_secret", options)) - return {"secret": {"id": "psk_1"}} - - def list_provider_secrets(self): - self.calls.append(("list_provider_secrets", None)) - return [] - - def delete_provider_secret(self, secret_id): - self.calls.append(("delete_provider_secret", secret_id)) - return {"status": "deleted"} - - def get_registration_guide(self): - self.calls.append(("get_registration_guide", None)) - return {"steps": []} - - def parse_curl_to_service_manifest(self, curl_command): - self.calls.append(("parse_curl_to_service_manifest", curl_command)) - return {"manifest": {}} - - def register_provider_service(self, **options): - self.calls.append(("register_provider_service", options)) - return {"serviceId": "svc_1"} - - def list_provider_services(self): - self.calls.append(("list_provider_services", None)) - return [] - - def get_provider_service(self, service_id): - self.calls.append(("get_provider_service", service_id)) - return {"serviceId": service_id} - - def get_provider_service_status(self, service_id): - self.calls.append(("get_provider_service_status", service_id)) - return {"serviceId": service_id} - - def update_provider_service(self, service_record_id, patch): - self.calls.append(("update_provider_service", service_record_id, patch)) - return {"status": "updated"} - - def delete_provider_service(self, service_record_id): - self.calls.append(("delete_provider_service", service_record_id)) - return {"status": "deleted"} - - def ping_provider_service(self, service_record_id): - self.calls.append(("ping_provider_service", service_record_id)) - return {"status": "ok"} - - def get_provider_service_health_history(self, service_record_id, *, limit): - self.calls.append(("get_provider_service_health_history", service_record_id, limit)) - return {"history": []} - - def get_provider_earnings_summary(self): - self.calls.append(("get_provider_earnings_summary", None)) - return {"total": "0"} - - def get_provider_withdrawal_capability(self): - self.calls.append(("get_provider_withdrawal_capability", None)) - return {"available": True} - - def create_provider_withdrawal_intent(self, amount_usdc, *, idempotency_key=None, destination_address=None): - self.calls.append(("create_provider_withdrawal_intent", amount_usdc, idempotency_key, destination_address)) - return {"intentId": "wd_1"} - - def list_provider_withdrawals(self, *, limit): - self.calls.append(("list_provider_withdrawals", limit)) - return {"withdrawals": []} - - dummy = DummyAuth() - provider = SynapseProvider(dummy) - - provider.issue_secret(name="provider") - provider.list_secrets() - provider.delete_secret("psk_1") - provider.get_registration_guide() - provider.parse_curl_to_service_manifest("curl https://provider.example/health") - provider.register_service(service_name="Weather", endpoint_url="https://provider.example/invoke") - provider.list_services() - provider.get_service("svc_1") - provider.get_service_status("svc_1") - provider.update_service("rec_1", {"summary": "updated"}) - provider.delete_service("rec_1") - provider.ping_service("rec_1") - provider.get_service_health_history("rec_1", limit=3) - provider.get_earnings_summary() - provider.get_withdrawal_capability() - provider.create_withdrawal_intent(5, idempotency_key="fixed", destination_address="0xabc") - provider.list_withdrawals(limit=2) - - auth = SynapseAuth(wallet_address="0xabc", signer=lambda _: "0xsigned") - assert isinstance(auth.provider(), SynapseProvider) - assert dummy.calls == [ - ("issue_provider_secret", {"name": "provider"}), - ("list_provider_secrets", None), - ("delete_provider_secret", "psk_1"), - ("get_registration_guide", None), - ("parse_curl_to_service_manifest", "curl https://provider.example/health"), - ("register_provider_service", {"service_name": "Weather", "endpoint_url": "https://provider.example/invoke"}), - ("list_provider_services", None), - ("get_provider_service", "svc_1"), - ("get_provider_service_status", "svc_1"), - ("update_provider_service", "rec_1", {"summary": "updated"}), - ("delete_provider_service", "rec_1"), - ("ping_provider_service", "rec_1"), - ("get_provider_service_health_history", "rec_1", 3), - ("get_provider_earnings_summary", None), - ("get_provider_withdrawal_capability", None), - ("create_provider_withdrawal_intent", 5, "fixed", "0xabc"), - ("list_provider_withdrawals", 2), - ] diff --git a/python/synapse_client/test/test_client_unit.py b/python/synapse_client/test/test_client_unit.py index c9cfb71..38c298f 100644 --- a/python/synapse_client/test/test_client_unit.py +++ b/python/synapse_client/test/test_client_unit.py @@ -310,6 +310,67 @@ def fake_post(url, headers, json, timeout): assert captured[0]["costUsdc"] == pytest.approx(0.10) +def test_invoke_llm_sends_max_cost_without_cost_usdc(monkeypatch): + captured = [] + + def fake_post(url, headers, json, timeout): + captured.append(json) + return DummyResponse( + json_data={ + "invocationId": "inv_llm", + "status": "SUCCEEDED", + "chargedUsdc": 0.000253, + "usage": {"inputTokens": 1200, "outputTokens": 300, "totalTokens": 1500}, + "synapse": { + "priceModel": "token_metered", + "holdUsdc": "0.001000", + "chargedUsdc": "0.000253", + "releasedUsdc": "0.000747", + "preAuthMode": "explicit", + }, + } + ) + + monkeypatch.setattr("synapse_client.client.requests.post", fake_post) + client = SynapseClient(api_key="agt_test") + result = client.invoke_llm( + "svc_deepseek_chat", + {"messages": [{"role": "user", "content": "hello"}], "max_tokens": 256}, + max_cost_usdc="0.010000", + idempotency_key="ik-llm", + ) + + assert "costUsdc" not in captured[0] + assert captured[0]["maxCostUsdc"] == "0.010000" + assert captured[0]["responseMode"] == "sync" + assert result.usage is not None + assert result.usage.input_tokens == 1200 + assert result.synapse is not None + assert result.synapse.released_usdc == "0.000747" + + +def test_invoke_requires_cost_usdc_for_fixed_price_services(monkeypatch): + monkeypatch.setattr( + "synapse_client.client.requests.post", + lambda *args, **kwargs: pytest.fail("should not call gateway"), + ) + client = SynapseClient(api_key="agt_test") + + with pytest.raises(ValueError, match="cost_usdc is required"): + client.invoke("svc_fixed", {"prompt": "hello"}) + + +def test_invoke_llm_rejects_stream_true(monkeypatch): + monkeypatch.setattr( + "synapse_client.client.requests.post", + lambda *args, **kwargs: pytest.fail("should not call gateway"), + ) + client = SynapseClient(api_key="agt_test") + + with pytest.raises(InvokeError, match="LLM_STREAMING_NOT_SUPPORTED"): + client.invoke_llm("svc_llm", {"stream": True}) + + def test_invoke_with_cost_usdc_raises_price_mismatch_error(monkeypatch): monkeypatch.setattr( "synapse_client.client.requests.post", diff --git a/python/synapse_client/test/test_provider_facade_unit.py b/python/synapse_client/test/test_provider_facade_unit.py new file mode 100644 index 0000000..8035aaa --- /dev/null +++ b/python/synapse_client/test/test_provider_facade_unit.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +from synapse_client import SynapseAuth, SynapseProvider + + +class DummyAuth: + def __init__(self): + self.calls = [] + + def issue_provider_secret(self, **options): + self.calls.append(("issue_provider_secret", options)) + return {"secret": {"id": "psk_1"}} + + def list_provider_secrets(self): + self.calls.append(("list_provider_secrets", None)) + return [] + + def delete_provider_secret(self, secret_id): + self.calls.append(("delete_provider_secret", secret_id)) + return {"status": "deleted"} + + def get_registration_guide(self): + self.calls.append(("get_registration_guide", None)) + return {"steps": []} + + def parse_curl_to_service_manifest(self, curl_command): + self.calls.append(("parse_curl_to_service_manifest", curl_command)) + return {"manifest": {}} + + def register_provider_service(self, **options): + self.calls.append(("register_provider_service", options)) + return {"serviceId": "svc_1"} + + def list_provider_services(self): + self.calls.append(("list_provider_services", None)) + return [] + + def get_provider_service(self, service_id): + self.calls.append(("get_provider_service", service_id)) + return {"serviceId": service_id} + + def get_provider_service_status(self, service_id): + self.calls.append(("get_provider_service_status", service_id)) + return {"serviceId": service_id} + + def update_provider_service(self, service_record_id, patch): + self.calls.append(("update_provider_service", service_record_id, patch)) + return {"status": "updated"} + + def delete_provider_service(self, service_record_id): + self.calls.append(("delete_provider_service", service_record_id)) + return {"status": "deleted"} + + def ping_provider_service(self, service_record_id): + self.calls.append(("ping_provider_service", service_record_id)) + return {"status": "ok"} + + def get_provider_service_health_history(self, service_record_id, *, limit): + self.calls.append(("get_provider_service_health_history", service_record_id, limit)) + return {"history": []} + + def get_provider_earnings_summary(self): + self.calls.append(("get_provider_earnings_summary", None)) + return {"total": "0"} + + def get_provider_withdrawal_capability(self): + self.calls.append(("get_provider_withdrawal_capability", None)) + return {"available": True} + + def create_provider_withdrawal_intent(self, amount_usdc, *, idempotency_key=None, destination_address=None): + self.calls.append(("create_provider_withdrawal_intent", amount_usdc, idempotency_key, destination_address)) + return {"intentId": "wd_1"} + + def list_provider_withdrawals(self, *, limit): + self.calls.append(("list_provider_withdrawals", limit)) + return {"withdrawals": []} + + +def test_provider_facade_delegates_to_owner_auth_methods(): + dummy = DummyAuth() + provider = SynapseProvider(dummy) + + provider.issue_secret(name="provider") + provider.list_secrets() + provider.delete_secret("psk_1") + provider.get_registration_guide() + provider.parse_curl_to_service_manifest("curl https://provider.example/health") + provider.register_service(service_name="Weather", endpoint_url="https://provider.example/invoke") + provider.list_services() + provider.get_service("svc_1") + provider.get_service_status("svc_1") + provider.update_service("rec_1", {"summary": "updated"}) + provider.delete_service("rec_1") + provider.ping_service("rec_1") + provider.get_service_health_history("rec_1", limit=3) + provider.get_earnings_summary() + provider.get_withdrawal_capability() + provider.create_withdrawal_intent(5, idempotency_key="fixed", destination_address="0xabc") + provider.list_withdrawals(limit=2) + + auth = SynapseAuth(wallet_address="0xabc", signer=lambda _: "0xsigned") + assert isinstance(auth.provider(), SynapseProvider) + assert dummy.calls == [ + ("issue_provider_secret", {"name": "provider"}), + ("list_provider_secrets", None), + ("delete_provider_secret", "psk_1"), + ("get_registration_guide", None), + ("parse_curl_to_service_manifest", "curl https://provider.example/health"), + ("register_provider_service", {"service_name": "Weather", "endpoint_url": "https://provider.example/invoke"}), + ("list_provider_services", None), + ("get_provider_service", "svc_1"), + ("get_provider_service_status", "svc_1"), + ("update_provider_service", "rec_1", {"summary": "updated"}), + ("delete_provider_service", "rec_1"), + ("ping_provider_service", "rec_1"), + ("get_provider_service_health_history", "rec_1", 3), + ("get_provider_earnings_summary", None), + ("get_provider_withdrawal_capability", None), + ("create_provider_withdrawal_intent", 5, "fixed", "0xabc"), + ("list_provider_withdrawals", 2), + ] diff --git a/python/synapse_client/wallet.py b/python/synapse_client/wallet.py new file mode 100644 index 0000000..236f6ae --- /dev/null +++ b/python/synapse_client/wallet.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import os +from typing import Any, Dict, Optional + +from .client import SynapseClient +from .exceptions import InsufficientFundsError +from .models import InvocationResponse + + +class AgentWallet(SynapseClient): + """Convenience wrapper: the 3-line DX entry point for agent developers.""" + + def __init__(self, budget: float = 5.0, **kwargs: Any): + super().__init__(**kwargs) + self._budget_usdc = float(budget) + self._spent_usdc: float = 0.0 + + @classmethod + def connect( + cls, + budget: float = 5.0, + api_key: Optional[str] = None, + gateway_url: Optional[str] = None, + environment: Optional[str] = None, + ) -> "AgentWallet": + api_key = api_key or os.getenv("SYNAPSE_API_KEY", "") + return cls(budget=budget, api_key=api_key, gateway_url=gateway_url, environment=environment) + + @property + def budget_usdc(self) -> float: + return self._budget_usdc + + @property + def spent_usdc(self) -> float: + return self._spent_usdc + + @property + def remaining_usdc(self) -> float: + return round(self._budget_usdc - self._spent_usdc, 6) + + def invoke( + self, + service_id: str, + *, + payload: Optional[Dict[str, Any]] = None, + cost_usdc: float = 0.0, + **kwargs: Any, + ) -> InvocationResponse: + cost = float(cost_usdc) + if self._spent_usdc + cost > self._budget_usdc: + raise InsufficientFundsError( + f"Budget exceeded: ${self._spent_usdc:.4f} spent + ${cost:.4f} cost > ${self._budget_usdc:.4f} budget" + ) + result = super().invoke(service_id, payload=payload, cost_usdc=cost_usdc, **kwargs) + self._spent_usdc = round(self._spent_usdc + float(result.charged_usdc), 6) + return result diff --git a/scripts/ci/python_checks.sh b/scripts/ci/python_checks.sh index 147df3d..c33b500 100755 --- a/scripts/ci/python_checks.sh +++ b/scripts/ci/python_checks.sh @@ -31,6 +31,7 @@ echo "[ci:python] running Python unit tests with 80% coverage gate" "$PYTHON_BIN" -m pytest -q \ python/synapse_client/test/test_auth_unit.py \ python/synapse_client/test/test_client_unit.py \ + python/synapse_client/test/test_provider_facade_unit.py \ --cov=synapse_client \ --cov-report=term-missing \ --cov-report=xml:python/coverage.xml \ diff --git a/scripts/ci/repo_hygiene_checks.sh b/scripts/ci/repo_hygiene_checks.sh index 398abf4..c79c22a 100755 --- a/scripts/ci/repo_hygiene_checks.sh +++ b/scripts/ci/repo_hygiene_checks.sh @@ -54,6 +54,8 @@ sensitive_files="$( | grep -Ei '(^|/)\.env($|\.|/)|private|secret|key|pem|wallet|mnemonic|credential' \ | grep -Ev '(^|/)\.env\.example$' \ | grep -Ev '^python/examples/consumer_wallet_to_invoke\.py$' \ + | grep -Ev '^python/synapse_client/_auth_credentials\.py$' \ + | grep -Ev '^typescript/src/auth_credentials\.ts$' \ || true )" if [[ -n "$sensitive_files" ]]; then diff --git a/scripts/ci/source_quality_checks.py b/scripts/ci/source_quality_checks.py index b856bbe..0dd3b80 100644 --- a/scripts/ci/source_quality_checks.py +++ b/scripts/ci/source_quality_checks.py @@ -2,6 +2,7 @@ from __future__ import annotations import ast +import re from pathlib import Path ROOT = Path(__file__).resolve().parents[2] @@ -11,7 +12,27 @@ MAX_SOURCE_LINES = 500 MAX_TEST_LINES = 700 MAX_PYTHON_FUNCTION_LINES = 40 -IGNORED_DIRS = {"__pycache__", ".pytest_cache", ".venv", "dist", "build", "coverage", "node_modules"} +IGNORED_DIRS = { + "__pycache__", + ".pytest_cache", + ".venv", + "dist", + "build", + "coverage", + "node_modules", +} +PYTHON_TYPED_RETURN_FILES = { + PYTHON_SOURCE / "auth.py", + PYTHON_SOURCE / "_auth_credentials.py", + PYTHON_SOURCE / "_auth_finance.py", + PYTHON_SOURCE / "_auth_provider_control.py", + PYTHON_SOURCE / "provider.py", +} +TYPESCRIPT_TYPED_RETURN_FILES = { + TYPESCRIPT_SOURCE / "auth.ts", + TYPESCRIPT_SOURCE / "auth_provider_control.ts", + TYPESCRIPT_SOURCE / "provider.ts", +} def iter_files(root: Path, suffix: str) -> list[Path]: @@ -39,7 +60,11 @@ def effective_lines(path: Path) -> int: def check_file_lengths() -> list[str]: failures: list[str] = [] for path in iter_files(PYTHON_SOURCE, ".py") + iter_files(TYPESCRIPT_SOURCE, ".ts"): - limit = MAX_TEST_LINES if "/test/" in f"/{relative(path)}" or "/tests/" in f"/{relative(path)}" else MAX_SOURCE_LINES + limit = ( + MAX_TEST_LINES + if "/test/" in f"/{relative(path)}" or "/tests/" in f"/{relative(path)}" + else MAX_SOURCE_LINES + ) total = len(path.read_text(encoding="utf-8").splitlines()) if total > limit: failures.append(f"{relative(path)} has {total} lines; limit is {limit}") @@ -53,7 +78,9 @@ def check_python_function_lengths() -> list[str]: continue tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) for node in ast.walk(tree): - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and hasattr(node, "end_lineno"): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and hasattr( + node, "end_lineno" + ): lines = effective_lines_for_node(path, node) if lines > MAX_PYTHON_FUNCTION_LINES: failures.append( @@ -63,16 +90,79 @@ def check_python_function_lengths() -> list[str]: return failures +def check_public_sdk_return_models() -> list[str]: + failures = check_python_public_return_models() + failures.extend(check_typescript_public_return_models()) + return failures + + +def check_python_public_return_models() -> list[str]: + failures: list[str] = [] + forbidden = {"Dict[str, Any]", "dict", "typing.Dict[str, Any]"} + for path in sorted(PYTHON_TYPED_RETURN_FILES): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + if node.name.startswith("_") or node.returns is None: + continue + annotation = ast.unparse(node.returns) + if annotation in forbidden: + failures.append( + f"{relative(path)}:{node.lineno} {node.name} returns raw {annotation}; use a SDK model" + ) + return failures + + +def check_typescript_public_return_models() -> list[str]: + failures: list[str] = [] + patterns = [ + re.compile( + r"^\s*export\s+async\s+function\s+([A-Za-z]\w*)\s*\([^{};]*?\)\s*:" + r"\s*Promise>", + re.MULTILINE | re.DOTALL, + ), + re.compile( + r"^\s*(?:async\s+)?([A-Za-z]\w*)\s*\([^{};]*?\)\s*:\s*Promise>", + re.MULTILINE | re.DOTALL, + ), + ] + for path in sorted(TYPESCRIPT_TYPED_RETURN_FILES): + text = path.read_text(encoding="utf-8") + for pattern in patterns: + for match in pattern.finditer(text): + lineno = text.count("\n", 0, match.start()) + 1 + name = match.group(1) + if name in {"constructor"}: + continue + failures.append( + f"{relative(path)}:{lineno} {name} returns raw Record; use a named SDK result type" + ) + return sorted(set(failures)) + + def effective_lines_for_node(path: Path, node: ast.AST) -> int: source = path.read_text(encoding="utf-8").splitlines() body = getattr(node, "body", []) - start = getattr(body[0], "lineno", getattr(node, "lineno", 1)) if body else getattr(node, "lineno", 1) + start = ( + getattr(body[0], "lineno", getattr(node, "lineno", 1)) + if body + else getattr(node, "lineno", 1) + ) end = getattr(node, "end_lineno", start) - return sum(1 for line in source[start - 1 : end] if line.strip() and not line.strip().startswith("#")) + return sum( + 1 + for line in source[start - 1 : end] + if line.strip() and not line.strip().startswith("#") + ) def main() -> int: - failures = check_file_lengths() + check_python_function_lengths() + failures = ( + check_file_lengths() + + check_python_function_lengths() + + check_public_sdk_return_models() + ) if failures: print("[ci:quality] source quality gate failed") for failure in failures: diff --git a/typescript/src/auth.ts b/typescript/src/auth.ts index bfdfd37..83ad7ed 100644 --- a/typescript/src/auth.ts +++ b/typescript/src/auth.ts @@ -1,24 +1,42 @@ -/** - * SynapseAuth — Wallet-based authentication + credential management. - * - * Requires `ethers` (v6) as a peer dependency for EIP-191 signing. - * Alternatively pass a custom `signer` function if you bring your own stack. - */ import { SynapseAuthOptions, + AuthLogoutResult, ChallengeResponse, TokenResponse, IssueCredentialOptions, IssueCredentialResult, AgentCredential, BalanceSummary, + CredentialAuditLogList, + CredentialDeleteResult, + CredentialQuotaUpdateResult, + CredentialRevokeResult, + CredentialRotateResult, + CredentialStatusResult, + DepositConfirmResult, DepositIntentResult, + FinanceAuditLogList, + OwnerProfile, + ProviderEarningsSummary, + ProviderRegistrationGuide, ProviderSecret, + ProviderSecretDeleteResult, IssueProviderSecretResult, RegisterProviderServiceOptions, RegisterProviderServiceResult, ProviderServiceRecord, + ProviderServiceDeleteResult, + ProviderServiceHealthHistory, + ProviderServicePingResult, ProviderServiceStatus, + ProviderServiceUpdateResult, + ProviderWithdrawalCapability, + ProviderWithdrawalIntentResult, + ProviderWithdrawalList, + RiskOverview, + ServiceManifestDraft, + UsageLogList, + VoucherRedeemResult, } from "./types"; import { AuthenticationError } from "./errors"; import { resolveGatewayUrl } from "./config"; @@ -128,9 +146,9 @@ export class SynapseAuth { } /** Clear the gateway session token and local cache. */ - async logout(): Promise> { + async logout(): Promise { const token = await this.getToken(); - const resp = await this._fetch>(`${this.gatewayUrl}/api/v1/auth/logout`, { + const resp = await this._fetch(`${this.gatewayUrl}/api/v1/auth/logout`, { method: "POST", headers: { Authorization: `Bearer ${token}` }, }); @@ -140,9 +158,9 @@ export class SynapseAuth { } /** Return the authenticated owner profile. */ - async getOwnerProfile(): Promise> { + async getOwnerProfile(): Promise { const token = await this.getToken(); - return this._fetch>(`${this.gatewayUrl}/api/v1/auth/me`, { + return this._fetch(`${this.gatewayUrl}/api/v1/auth/me`, { headers: { Authorization: `Bearer ${token}` }, }); } @@ -163,7 +181,7 @@ export class SynapseAuth { } /** Delete a provider control-plane secret. */ - async deleteProviderSecret(secretId: string): Promise> { + async deleteProviderSecret(secretId: string): Promise { return deleteProviderSecret(this.providerControlContext(), secretId); } @@ -178,45 +196,45 @@ export class SynapseAuth { } /** Check whether a credential is still valid and usable. */ - async checkCredentialStatus(credentialId: string): Promise> { + async checkCredentialStatus(credentialId: string): Promise { const token = await this.getToken(); const id = this.requireValue(credentialId, "credentialId"); - return this._fetch>( + return this._fetch( `${this.gatewayUrl}/api/v1/credentials/agent/${encodeURIComponent(id)}/status`, { headers: { Authorization: `Bearer ${token}` } } ); } /** Alias for checkCredentialStatus(). */ - async getCredentialStatus(credentialId: string): Promise> { + async getCredentialStatus(credentialId: string): Promise { return this.checkCredentialStatus(credentialId); } /** Revoke an agent credential without deleting its audit trail. */ - async revokeCredential(credentialId: string): Promise> { + async revokeCredential(credentialId: string): Promise { const token = await this.getToken(); const id = this.requireValue(credentialId, "credentialId"); - return this._fetch>( + return this._fetch( `${this.gatewayUrl}/api/v1/credentials/agent/${encodeURIComponent(id)}/revoke`, { method: "POST", headers: { Authorization: `Bearer ${token}` } } ); } /** Rotate an agent credential and return the gateway response containing the new token. */ - async rotateCredential(credentialId: string): Promise> { + async rotateCredential(credentialId: string): Promise { const token = await this.getToken(); const id = this.requireValue(credentialId, "credentialId"); - return this._fetch>( + return this._fetch( `${this.gatewayUrl}/api/v1/credentials/agent/${encodeURIComponent(id)}/rotate`, { method: "POST", headers: { Authorization: `Bearer ${token}` } } ); } /** Delete an agent credential. Use revokeCredential for emergency shutoff. */ - async deleteCredential(credentialId: string): Promise> { + async deleteCredential(credentialId: string): Promise { const token = await this.getToken(); const id = this.requireValue(credentialId, "credentialId"); - return this._fetch>( + return this._fetch( `${this.gatewayUrl}/api/v1/credentials/agent/${encodeURIComponent(id)}`, { method: "DELETE", headers: { Authorization: `Bearer ${token}` } } ); @@ -233,10 +251,10 @@ export class SynapseAuth { expiresAt?: string | number; expiration?: number; } = {} - ): Promise> { + ): Promise { const token = await this.getToken(); const id = this.requireValue(credentialId, "credentialId"); - return this._fetch>( + return this._fetch( `${this.gatewayUrl}/api/v1/credentials/agent/${encodeURIComponent(id)}/quota`, { method: "PATCH", @@ -247,12 +265,12 @@ export class SynapseAuth { } /** Fetch credential lifecycle audit logs for the authenticated owner. */ - async getCredentialAuditLogs(opts: { limit?: number } = {}): Promise> { + async getCredentialAuditLogs(opts: { limit?: number } = {}): Promise { const token = await this.getToken(); const url = this.withQuery(`${this.gatewayUrl}/api/v1/credentials/agent/audit-logs`, { limit: opts.limit ?? 100, }); - return this._fetch>(url, { headers: { Authorization: `Bearer ${token}` } }); + return this._fetch(url, { headers: { Authorization: `Bearer ${token}` } }); } /** Get balance summary for this wallet. */ @@ -287,9 +305,9 @@ export class SynapseAuth { } /** Confirm a previously registered deposit intent. */ - async confirmDeposit(intentId: string, eventKey: string): Promise<{ status: string }> { + async confirmDeposit(intentId: string, eventKey: string): Promise { const token = await this.getToken(); - return this._fetch<{ status: string }>(`${this.gatewayUrl}/api/v1/balance/deposit/intents/${intentId}/confirm`, { + return this._fetch(`${this.gatewayUrl}/api/v1/balance/deposit/intents/${intentId}/confirm`, { method: "POST", headers: { Authorization: `Bearer ${token}` }, body: JSON.stringify({ eventKey, confirmations: 1 }), @@ -312,10 +330,10 @@ export class SynapseAuth { } /** Redeem a voucher into the authenticated owner balance. */ - async redeemVoucher(voucherCode: string, idempotencyKey?: string): Promise> { + async redeemVoucher(voucherCode: string, idempotencyKey?: string): Promise { const token = await this.getToken(); const code = this.requireValue(voucherCode, "voucherCode"); - return this._fetch>(`${this.gatewayUrl}/api/v1/balance/vouchers/redeem`, { + return this._fetch(`${this.gatewayUrl}/api/v1/balance/vouchers/redeem`, { method: "POST", headers: { Authorization: `Bearer ${token}`, @@ -326,23 +344,23 @@ export class SynapseAuth { } /** Fetch owner usage logs for observability and billing review. */ - async getUsageLogs(opts: { limit?: number } = {}): Promise> { + async getUsageLogs(opts: { limit?: number } = {}): Promise { const token = await this.getToken(); const url = this.withQuery(`${this.gatewayUrl}/api/v1/usage/logs`, { limit: opts.limit ?? 100 }); - return this._fetch>(url, { headers: { Authorization: `Bearer ${token}` } }); + return this._fetch(url, { headers: { Authorization: `Bearer ${token}` } }); } /** Fetch finance audit logs. High-impact finance actions remain explicit. */ - async getFinanceAuditLogs(opts: { limit?: number } = {}): Promise> { + async getFinanceAuditLogs(opts: { limit?: number } = {}): Promise { const token = await this.getToken(); const url = this.withQuery(`${this.gatewayUrl}/api/v1/finance/audit-logs`, { limit: opts.limit ?? 100 }); - return this._fetch>(url, { headers: { Authorization: `Bearer ${token}` } }); + return this._fetch(url, { headers: { Authorization: `Bearer ${token}` } }); } /** Return the owner finance risk overview. */ - async getRiskOverview(): Promise> { + async getRiskOverview(): Promise { const token = await this.getToken(); - return this._fetch>(`${this.gatewayUrl}/api/v1/finance/risk-overview`, { + return this._fetch(`${this.gatewayUrl}/api/v1/finance/risk-overview`, { headers: { Authorization: `Bearer ${token}` }, }); } @@ -358,12 +376,12 @@ export class SynapseAuth { } /** Fetch the provider registration guide from the gateway control plane. */ - async getRegistrationGuide(): Promise> { + async getRegistrationGuide(): Promise { return getRegistrationGuide(this.providerControlContext()); } /** Convert a curl command into a provider service manifest draft. */ - async parseCurlToServiceManifest(curlCommand: string): Promise> { + async parseCurlToServiceManifest(curlCommand: string): Promise { return parseCurlToServiceManifest(this.providerControlContext(), curlCommand); } @@ -371,17 +389,17 @@ export class SynapseAuth { async updateProviderService( serviceRecordId: string, patch: Record - ): Promise> { + ): Promise { return updateProviderService(this.providerControlContext(), serviceRecordId, patch); } /** Delete a provider service registration by gateway record ID. */ - async deleteProviderService(serviceRecordId: string): Promise> { + async deleteProviderService(serviceRecordId: string): Promise { return deleteProviderService(this.providerControlContext(), serviceRecordId); } /** Force a provider service health ping. */ - async pingProviderService(serviceRecordId: string): Promise> { + async pingProviderService(serviceRecordId: string): Promise { return pingProviderService(this.providerControlContext(), serviceRecordId); } @@ -389,17 +407,17 @@ export class SynapseAuth { async getProviderServiceHealthHistory( serviceRecordId: string, opts: { limitPerTarget?: number } = {} - ): Promise> { + ): Promise { return getProviderServiceHealthHistory(this.providerControlContext(), serviceRecordId, opts); } /** Return provider earnings summary for the authenticated owner. */ - async getProviderEarningsSummary(): Promise> { + async getProviderEarningsSummary(): Promise { return getProviderEarningsSummary(this.providerControlContext()); } /** Return whether provider withdrawals are currently available. */ - async getProviderWithdrawalCapability(): Promise> { + async getProviderWithdrawalCapability(): Promise { return getProviderWithdrawalCapability(this.providerControlContext()); } @@ -407,12 +425,12 @@ export class SynapseAuth { async createProviderWithdrawalIntent( amountUsdc: number, opts: { idempotencyKey?: string; destinationAddress?: string } = {} - ): Promise> { + ): Promise { return createProviderWithdrawalIntent(this.providerControlContext(), amountUsdc, opts); } /** List provider withdrawal records. */ - async listProviderWithdrawals(opts: { limit?: number } = {}): Promise> { + async listProviderWithdrawals(opts: { limit?: number } = {}): Promise { return listProviderWithdrawals(this.providerControlContext(), opts); } diff --git a/typescript/src/auth_provider_control.ts b/typescript/src/auth_provider_control.ts index bb1d5e6..c2eba46 100644 --- a/typescript/src/auth_provider_control.ts +++ b/typescript/src/auth_provider_control.ts @@ -6,6 +6,17 @@ import { IssueCredentialOptions, IssueProviderSecretResult, ProviderSecret, + ProviderEarningsSummary, + ProviderRegistrationGuide, + ProviderSecretDeleteResult, + ProviderServiceDeleteResult, + ProviderServiceHealthHistory, + ProviderServicePingResult, + ProviderServiceUpdateResult, + ProviderWithdrawalCapability, + ProviderWithdrawalIntentResult, + ProviderWithdrawalList, + ServiceManifestDraft, } from "./types"; import { AuthenticationError } from "./errors"; @@ -62,13 +73,16 @@ export async function listProviderSecrets(ctx: AuthProviderControlContext): Prom export async function deleteProviderSecret( ctx: AuthProviderControlContext, secretId: string -): Promise> { +): Promise { const token = await ctx.getToken(); const id = ctx.requireValue(secretId, "secretId"); - return ctx.fetchJson>(`${ctx.gatewayUrl}/api/v1/secrets/provider/${encodeURIComponent(id)}`, { - method: "DELETE", - headers: { Authorization: `Bearer ${token}` }, - }); + return ctx.fetchJson( + `${ctx.gatewayUrl}/api/v1/secrets/provider/${encodeURIComponent(id)}`, + { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + } + ); } function defaultServiceId(serviceName: string): string { @@ -108,17 +122,18 @@ function providerServiceValues(opts: RegisterProviderServiceOptions) { function providerServiceBody(ctx: AuthProviderControlContext, opts: RegisterProviderServiceOptions) { const service = providerServiceValues(opts); + const serviceKind = opts.serviceKind ?? (opts.priceModel === "token_metered" ? "llm" : "api"); + const priceModel = opts.priceModel ?? (serviceKind === "llm" ? "token_metered" : "fixed"); return { serviceId: service.serviceId, agentToolName: service.serviceId, serviceName: service.serviceName, + serviceKind, + priceModel, role: "Provider", status: valueOr(opts.status, "active"), isActive: valueOr(opts.isActive, true), - pricing: { - amount: String(opts.basePriceUsdc), - currency: "USDC", - }, + pricing: providerPricing(opts, priceModel), summary: service.description, tags: valueOr(opts.tags, []), auth: { type: "gateway_signed" }, @@ -134,6 +149,28 @@ function providerServiceBody(ctx: AuthProviderControlContext, opts: RegisterProv }; } +function providerPricing(opts: RegisterProviderServiceOptions, priceModel: string): Record { + if (priceModel === "token_metered") { + if (opts.inputPricePer1MTokensUsdc == null) throw new Error("inputPricePer1MTokensUsdc is required"); + if (opts.outputPricePer1MTokensUsdc == null) throw new Error("outputPricePer1MTokensUsdc is required"); + const pricing: Record = { + priceModel: "token_metered", + inputPricePer1MTokensUsdc: String(opts.inputPricePer1MTokensUsdc), + outputPricePer1MTokensUsdc: String(opts.outputPricePer1MTokensUsdc), + currency: "USDC", + }; + if (opts.defaultMaxOutputTokens != null) pricing["defaultMaxOutputTokens"] = opts.defaultMaxOutputTokens; + if (opts.holdBufferMultiplier != null) pricing["holdBufferMultiplier"] = opts.holdBufferMultiplier; + if (opts.maxAutoHoldUsdc != null) pricing["maxAutoHoldUsdc"] = String(opts.maxAutoHoldUsdc); + return pricing; + } + if (opts.basePriceUsdc == null) throw new Error("basePriceUsdc is required"); + return { + amount: String(opts.basePriceUsdc), + currency: "USDC", + }; +} + function providerInvokeConfig(endpointUrl: string, opts: RegisterProviderServiceOptions) { return { method: valueOr(opts.endpointMethod, "POST"), @@ -201,9 +238,9 @@ export async function listProviderServices(ctx: AuthProviderControlContext): Pro return resp.services ?? []; } -export async function getRegistrationGuide(ctx: AuthProviderControlContext): Promise> { +export async function getRegistrationGuide(ctx: AuthProviderControlContext): Promise { const token = await ctx.getToken(); - return ctx.fetchJson>(`${ctx.gatewayUrl}/api/v1/services/registration-guide`, { + return ctx.fetchJson(`${ctx.gatewayUrl}/api/v1/services/registration-guide`, { headers: { Authorization: `Bearer ${token}` }, }); } @@ -211,10 +248,10 @@ export async function getRegistrationGuide(ctx: AuthProviderControlContext): Pro export async function parseCurlToServiceManifest( ctx: AuthProviderControlContext, curlCommand: string -): Promise> { +): Promise { const token = await ctx.getToken(); const command = ctx.requireValue(curlCommand, "curlCommand"); - return ctx.fetchJson>(`${ctx.gatewayUrl}/api/v1/services/parse-curl`, { + return ctx.fetchJson(`${ctx.gatewayUrl}/api/v1/services/parse-curl`, { method: "POST", headers: { Authorization: `Bearer ${token}` }, body: JSON.stringify({ curlCommand: command }), @@ -225,10 +262,10 @@ export async function updateProviderService( ctx: AuthProviderControlContext, serviceRecordId: string, patch: Record -): Promise> { +): Promise { const token = await ctx.getToken(); const id = ctx.requireValue(serviceRecordId, "serviceRecordId"); - return ctx.fetchJson>(`${ctx.gatewayUrl}/api/v1/services/${encodeURIComponent(id)}`, { + return ctx.fetchJson(`${ctx.gatewayUrl}/api/v1/services/${encodeURIComponent(id)}`, { method: "PUT", headers: { Authorization: `Bearer ${token}` }, body: JSON.stringify(patch ?? {}), @@ -238,10 +275,10 @@ export async function updateProviderService( export async function deleteProviderService( ctx: AuthProviderControlContext, serviceRecordId: string -): Promise> { +): Promise { const token = await ctx.getToken(); const id = ctx.requireValue(serviceRecordId, "serviceRecordId"); - return ctx.fetchJson>(`${ctx.gatewayUrl}/api/v1/services/${encodeURIComponent(id)}`, { + return ctx.fetchJson(`${ctx.gatewayUrl}/api/v1/services/${encodeURIComponent(id)}`, { method: "DELETE", headers: { Authorization: `Bearer ${token}` }, }); @@ -250,10 +287,10 @@ export async function deleteProviderService( export async function pingProviderService( ctx: AuthProviderControlContext, serviceRecordId: string -): Promise> { +): Promise { const token = await ctx.getToken(); const id = ctx.requireValue(serviceRecordId, "serviceRecordId"); - return ctx.fetchJson>(`${ctx.gatewayUrl}/api/v1/services/${encodeURIComponent(id)}/ping`, { + return ctx.fetchJson(`${ctx.gatewayUrl}/api/v1/services/${encodeURIComponent(id)}/ping`, { method: "POST", headers: { Authorization: `Bearer ${token}` }, }); @@ -263,27 +300,27 @@ export async function getProviderServiceHealthHistory( ctx: AuthProviderControlContext, serviceRecordId: string, opts: { limitPerTarget?: number } = {} -): Promise> { +): Promise { const token = await ctx.getToken(); const id = ctx.requireValue(serviceRecordId, "serviceRecordId"); const url = ctx.withQuery(`${ctx.gatewayUrl}/api/v1/services/${encodeURIComponent(id)}/health/history`, { limitPerTarget: opts.limitPerTarget ?? 100, }); - return ctx.fetchJson>(url, { headers: { Authorization: `Bearer ${token}` } }); + return ctx.fetchJson(url, { headers: { Authorization: `Bearer ${token}` } }); } -export async function getProviderEarningsSummary(ctx: AuthProviderControlContext): Promise> { +export async function getProviderEarningsSummary(ctx: AuthProviderControlContext): Promise { const token = await ctx.getToken(); - return ctx.fetchJson>(`${ctx.gatewayUrl}/api/v1/providers/earnings/summary`, { + return ctx.fetchJson(`${ctx.gatewayUrl}/api/v1/providers/earnings/summary`, { headers: { Authorization: `Bearer ${token}` }, }); } export async function getProviderWithdrawalCapability( ctx: AuthProviderControlContext -): Promise> { +): Promise { const token = await ctx.getToken(); - return ctx.fetchJson>(`${ctx.gatewayUrl}/api/v1/providers/withdrawals/capability`, { + return ctx.fetchJson(`${ctx.gatewayUrl}/api/v1/providers/withdrawals/capability`, { headers: { Authorization: `Bearer ${token}` }, }); } @@ -292,11 +329,11 @@ export async function createProviderWithdrawalIntent( ctx: AuthProviderControlContext, amountUsdc: number, opts: { idempotencyKey?: string; destinationAddress?: string } = {} -): Promise> { +): Promise { const token = await ctx.getToken(); const body: Record = { amountUsdc }; if (opts.destinationAddress) body["destinationAddress"] = opts.destinationAddress; - return ctx.fetchJson>(`${ctx.gatewayUrl}/api/v1/providers/withdrawals/intent`, { + return ctx.fetchJson(`${ctx.gatewayUrl}/api/v1/providers/withdrawals/intent`, { method: "POST", headers: { Authorization: `Bearer ${token}`, @@ -310,10 +347,10 @@ export async function createProviderWithdrawalIntent( export async function listProviderWithdrawals( ctx: AuthProviderControlContext, opts: { limit?: number } = {} -): Promise> { +): Promise { const token = await ctx.getToken(); const url = ctx.withQuery(`${ctx.gatewayUrl}/api/v1/providers/withdrawals`, { limit: opts.limit ?? 100 }); - return ctx.fetchJson>(url, { headers: { Authorization: `Bearer ${token}` } }); + return ctx.fetchJson(url, { headers: { Authorization: `Bearer ${token}` } }); } export async function getProviderService( diff --git a/typescript/src/client.ts b/typescript/src/client.ts index b6225ef..60b96cd 100644 --- a/typescript/src/client.ts +++ b/typescript/src/client.ts @@ -11,6 +11,7 @@ import { ServiceRecord, DiscoverOptions, InvokeOptions, + LlmInvokeOptions, InvocationResult, InvocationStatus, TERMINAL_STATUSES, @@ -121,6 +122,30 @@ export class SynapseClient { } } + /** + * Invoke an LLM service with token-metered billing. + * + * Do not pass costUsdc for LLM services. Gateway either uses maxCostUsdc as + * an explicit cap or computes an automatic pre-authorization hold, then + * captures only the final Provider-reported usage. + */ + async invokeLlm( + serviceId: string, + payload: Record = {}, + opts: LlmInvokeOptions = {} + ): Promise { + assertLlmSyncPayload(payload, opts); + try { + const resp = await this._fetch>( + `${this.gatewayUrl}/api/v1/agent/invoke`, + llmInvokeRequest(serviceId, payload, opts) + ); + return this.completeInvocation(resp, opts); + } catch (err) { + throw invokeError(err); + } + } + /** * Invoke once, then handle PRICE_MISMATCH by re-discovering and retrying once by default. */ @@ -219,7 +244,7 @@ export class SynapseClient { private completeInvocation( resp: Record, - opts: InvokeOptions + opts: { pollTimeoutMs?: number; pollIntervalMs?: number } ): Promise | InvocationResult { const result = this._parseInvocationResponse(resp); if (TERMINAL_STATUSES.has(result.status)) return result; @@ -259,19 +284,44 @@ function discoveryError(err: unknown): DiscoveryError { } function invokeRequest(serviceId: string, payload: Record, opts: InvokeOptions) { + const body: Record = { + serviceId, + idempotencyKey: opts.idempotencyKey ?? uuidv4(), + payload: { body: payload }, + responseMode: opts.responseMode ?? "sync", + }; + body["costUsdc"] = opts.costUsdc; return { method: "POST", extraHeaders: requestHeaders(opts.requestId), - body: JSON.stringify({ - serviceId, - idempotencyKey: opts.idempotencyKey ?? uuidv4(), - costUsdc: opts.costUsdc, - payload: { body: payload }, - responseMode: opts.responseMode ?? "sync", - }), + body: JSON.stringify(body), }; } +function llmInvokeRequest(serviceId: string, payload: Record, opts: LlmInvokeOptions) { + const body: Record = { + serviceId, + idempotencyKey: opts.idempotencyKey ?? uuidv4(), + payload: { body: payload }, + responseMode: "sync", + }; + if (opts.maxCostUsdc !== undefined) body["maxCostUsdc"] = opts.maxCostUsdc; + return { + method: "POST", + extraHeaders: requestHeaders(opts.requestId), + body: JSON.stringify(body), + }; +} + +function assertLlmSyncPayload(payload: Record, opts: LlmInvokeOptions): void { + if (opts.responseMode && opts.responseMode !== "sync") { + throw new InvokeError("LLM_STREAMING_NOT_SUPPORTED: LLM services only support sync responses in Synapse V1."); + } + if (payload["stream"] === true) { + throw new InvokeError("LLM_STREAMING_NOT_SUPPORTED: stream=true is not supported for token-metered LLM billing."); + } +} + function requestHeaders(requestId: string | undefined): Record { return requestId ? { "X-Request-Id": requestId } : {}; } diff --git a/typescript/src/provider.ts b/typescript/src/provider.ts index 1566287..46e69f6 100644 --- a/typescript/src/provider.ts +++ b/typescript/src/provider.ts @@ -2,11 +2,22 @@ import type { SynapseAuth } from "./auth"; import type { IssueCredentialOptions, IssueProviderSecretResult, + ProviderEarningsSummary, + ProviderRegistrationGuide, ProviderSecret, + ProviderSecretDeleteResult, RegisterProviderServiceOptions, RegisterProviderServiceResult, ProviderServiceRecord, + ProviderServiceDeleteResult, + ProviderServiceHealthHistory, + ProviderServicePingResult, ProviderServiceStatus, + ProviderServiceUpdateResult, + ProviderWithdrawalCapability, + ProviderWithdrawalIntentResult, + ProviderWithdrawalList, + ServiceManifestDraft, } from "./types"; /** @@ -27,15 +38,15 @@ export class SynapseProvider { return this.auth.listProviderSecrets(); } - deleteSecret(secretId: string): Promise> { + deleteSecret(secretId: string): Promise { return this.auth.deleteProviderSecret(secretId); } - getRegistrationGuide(): Promise> { + getRegistrationGuide(): Promise { return this.auth.getRegistrationGuide(); } - parseCurlToServiceManifest(curlCommand: string): Promise> { + parseCurlToServiceManifest(curlCommand: string): Promise { return this.auth.parseCurlToServiceManifest(curlCommand); } @@ -43,6 +54,16 @@ export class SynapseProvider { return this.auth.registerProviderService(opts); } + registerLlmService( + opts: Omit + ): Promise { + return this.auth.registerProviderService({ + ...opts, + serviceKind: "llm", + priceModel: "token_metered", + }); + } + listServices(): Promise { return this.auth.listProviderServices(); } @@ -55,41 +76,41 @@ export class SynapseProvider { return this.auth.getProviderServiceStatus(serviceId); } - updateService(serviceRecordId: string, patch: Record): Promise> { + updateService(serviceRecordId: string, patch: Record): Promise { return this.auth.updateProviderService(serviceRecordId, patch); } - deleteService(serviceRecordId: string): Promise> { + deleteService(serviceRecordId: string): Promise { return this.auth.deleteProviderService(serviceRecordId); } - pingService(serviceRecordId: string): Promise> { + pingService(serviceRecordId: string): Promise { return this.auth.pingProviderService(serviceRecordId); } getServiceHealthHistory( serviceRecordId: string, opts: { limitPerTarget?: number } = {} - ): Promise> { + ): Promise { return this.auth.getProviderServiceHealthHistory(serviceRecordId, opts); } - getEarningsSummary(): Promise> { + getEarningsSummary(): Promise { return this.auth.getProviderEarningsSummary(); } - getWithdrawalCapability(): Promise> { + getWithdrawalCapability(): Promise { return this.auth.getProviderWithdrawalCapability(); } createWithdrawalIntent( amountUsdc: number, opts: { idempotencyKey?: string; destinationAddress?: string } = {} - ): Promise> { + ): Promise { return this.auth.createProviderWithdrawalIntent(amountUsdc, opts); } - listWithdrawals(opts: { limit?: number } = {}): Promise> { + listWithdrawals(opts: { limit?: number } = {}): Promise { return this.auth.listProviderWithdrawals(opts); } } diff --git a/typescript/src/types.ts b/typescript/src/types.ts index fcd3b3c..4a5f6ad 100644 --- a/typescript/src/types.ts +++ b/typescript/src/types.ts @@ -24,6 +24,19 @@ export interface TokenResponse { expires_in: number; } +export interface AuthLogoutResult { + status?: string; + success?: boolean; + [key: string]: unknown; +} + +export interface OwnerProfile { + profile?: Record; + ownerAddress?: string; + walletAddress?: string; + [key: string]: unknown; +} + // ── Credentials ───────────────────────────────────────────────────────────── export interface IssueCredentialOptions { @@ -55,6 +68,53 @@ export interface IssueCredentialResult { token: string; } +export interface CredentialRevokeResult { + status?: string; + credentialId?: string; + credential?: AgentCredential; + [key: string]: unknown; +} + +export interface CredentialRotateResult { + status?: string; + credentialId?: string; + token?: string; + credential?: AgentCredential; + [key: string]: unknown; +} + +export interface CredentialDeleteResult { + status?: string; + credentialId?: string; + [key: string]: unknown; +} + +export interface CredentialQuotaUpdateResult { + status?: string; + credentialId?: string; + credential?: AgentCredential; + [key: string]: unknown; +} + +export interface CredentialStatusResult { + status?: string; + credentialId?: string; + valid?: boolean; + credentialStatus?: string; + isExpired?: boolean; + callsExhausted?: boolean; + expiresAt?: number; + callsUsed?: number; + maxCalls?: number; + creditLimit?: number; + [key: string]: unknown; +} + +export interface CredentialAuditLogList { + logs?: Array>; + [key: string]: unknown; +} + export interface ProviderSecret { id: string; name?: string; @@ -103,20 +163,88 @@ export interface DepositIntentResult { }; } +export interface DepositConfirmResult { + status: string; + intent?: { + id?: string; + intentId?: string; + depositIntentId?: string; + eventKey?: string; + event_key?: string; + txHash?: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +export interface VoucherRedeemResult { + status?: string; + voucherCode?: string; + [key: string]: unknown; +} + +export interface UsageLogList { + logs?: Array>; + [key: string]: unknown; +} + +export interface FinanceAuditLogList { + logs?: Array>; + [key: string]: unknown; +} + +export interface RiskOverview { + risk?: unknown; + [key: string]: unknown; +} + // ── Services ───────────────────────────────────────────────────────────────── +export type ServiceKind = "api" | "llm"; + +export type PriceModel = "fixed" | "per_call" | "per_success_call" | "token_metered"; + +export interface FixedPricing { + amount?: string; + currency?: string; +} + +export interface LlmPricing { + priceModel: "token_metered"; + inputPricePer1MTokensUsdc: string; + outputPricePer1MTokensUsdc: string; + defaultMaxOutputTokens?: number; + holdBufferMultiplier?: string | number; + maxAutoHoldUsdc?: string; +} + export interface ServiceRecord { serviceId?: string; id?: string; agentToolName?: string; serviceName?: string; status?: string; - pricing?: { amount?: string; currency?: string } | string; + serviceKind?: ServiceKind | string; + priceModel?: PriceModel | string; + pricing?: FixedPricing | LlmPricing | string; + inputPricePer1MTokensUsdc?: string; + outputPricePer1MTokensUsdc?: string; + defaultMaxOutputTokens?: number; + holdBufferMultiplier?: string | number; + maxAutoHoldUsdc?: string; summary?: string; tags?: string[]; [key: string]: unknown; } +export interface TokenMeteredServiceRecord extends ServiceRecord { + serviceKind: "llm"; + priceModel: "token_metered"; + pricing?: LlmPricing; + inputPricePer1MTokensUsdc: string; + outputPricePer1MTokensUsdc: string; +} + export interface ProviderProfileRecord { displayName: string; } @@ -160,8 +288,15 @@ export interface ProviderServiceRecord extends ServiceRecord { export interface RegisterProviderServiceOptions { serviceName: string; endpointUrl: string; - basePriceUsdc: string | number; + basePriceUsdc?: string | number; descriptionForModel: string; + serviceKind?: ServiceKind; + priceModel?: PriceModel; + inputPricePer1MTokensUsdc?: string | number; + outputPricePer1MTokensUsdc?: string | number; + defaultMaxOutputTokens?: number; + holdBufferMultiplier?: string | number; + maxAutoHoldUsdc?: string | number; serviceId?: string; providerDisplayName?: string; payoutAddress?: string; @@ -193,6 +328,70 @@ export interface ProviderServiceStatus { health: ServiceHealthRecord; } +export interface ProviderSecretDeleteResult { + status?: string; + secretId?: string; + [key: string]: unknown; +} + +export interface ProviderRegistrationGuide { + steps?: unknown[]; + requirements?: Record; + [key: string]: unknown; +} + +export interface ServiceManifestDraft { + data?: Record; + manifest?: Record; + [key: string]: unknown; +} + +export interface ProviderServiceUpdateResult { + status?: string; + service?: ProviderServiceRecord; + [key: string]: unknown; +} + +export interface ProviderServiceDeleteResult { + status?: string; + serviceId?: string; + [key: string]: unknown; +} + +export interface ProviderServicePingResult { + status?: string; + health?: Record; + [key: string]: unknown; +} + +export interface ProviderServiceHealthHistory { + history?: Array>; + [key: string]: unknown; +} + +export interface ProviderEarningsSummary { + total?: string | number; + [key: string]: unknown; +} + +export interface ProviderWithdrawalCapability { + available?: boolean; + [key: string]: unknown; +} + +export interface ProviderWithdrawalIntentResult { + status?: string; + intentId?: string; + amountUsdc?: string | number; + intent?: Record; + [key: string]: unknown; +} + +export interface ProviderWithdrawalList { + withdrawals?: Array>; + [key: string]: unknown; +} + export interface DiscoverOptions { limit?: number; offset?: number; @@ -225,11 +424,47 @@ export interface InvokeOptions { costUsdc: number; } +export interface LlmInvokeOptions { + idempotencyKey?: string; + responseMode?: "sync"; + requestId?: string; + pollTimeoutMs?: number; + pollIntervalMs?: number; + /** + * Optional caller-side maximum spend cap. If omitted, Gateway performs + * automatic pre-authorization based on prompt length and max_tokens. + */ + maxCostUsdc?: number | string; +} + +export interface LlmUsage { + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + [key: string]: unknown; +} + +export interface SynapseBillingMetadata { + priceModel?: "token_metered" | string; + holdUsdc?: string; + chargedUsdc?: string; + releasedUsdc?: string; + providerRevenueUsdc?: string; + platformFeeUsdc?: string; + preAuthMode?: "auto" | "explicit" | string; + [key: string]: unknown; +} + export interface InvocationResult { invocationId: string; status: InvocationStatus; chargedUsdc: number; result?: unknown; + usage?: LlmUsage; + synapse?: SynapseBillingMetadata; error?: { message?: string; code?: string } | null; receipt?: Record | null; quoteId?: string; diff --git a/typescript/tests/unit/auth.test.ts b/typescript/tests/unit/auth.test.ts index 993c849..95537e0 100644 --- a/typescript/tests/unit/auth.test.ts +++ b/typescript/tests/unit/auth.test.ts @@ -1,4 +1,30 @@ -import { SynapseAuth, SynapseClient, SynapseProvider, AuthenticationError, resolveGatewayUrl } from "../../src"; +import { + SynapseAuth, + SynapseClient, + SynapseProvider, + AuthenticationError, + resolveGatewayUrl, + type CredentialDeleteResult, + type CredentialQuotaUpdateResult, + type CredentialRevokeResult, + type CredentialRotateResult, + type FinanceAuditLogList, + type OwnerProfile, + type ProviderEarningsSummary, + type ProviderRegistrationGuide, + type ProviderSecretDeleteResult, + type ProviderServiceDeleteResult, + type ProviderServiceHealthHistory, + type ProviderServicePingResult, + type ProviderServiceUpdateResult, + type ProviderWithdrawalCapability, + type ProviderWithdrawalIntentResult, + type ProviderWithdrawalList, + type RiskOverview, + type ServiceManifestDraft, + type UsageLogList, + type VoucherRedeemResult, +} from "../../src"; type MockResponse = { status?: number; body: unknown }; @@ -298,6 +324,42 @@ test("registerProviderService preserves explicit provider manifest options", asy expect(body.invoke.response.body.properties.answer.type).toBe("string"); }); +test("registerProviderService builds token-metered LLM service manifest", async () => { + const calls = mockFetch([ + ...authHandshakeResponses(), + { body: { status: "created", serviceId: "svc_deepseek_chat", service: { id: "record_llm" } } }, + ]); + + await expect( + authForTests().registerProviderService({ + serviceId: "svc_deepseek_chat", + serviceName: "DeepSeek Chat", + endpointUrl: "https://provider.example/llm", + descriptionForModel: "OpenAI-compatible DeepSeek Chat endpoint.", + serviceKind: "llm", + priceModel: "token_metered", + inputPricePer1MTokensUsdc: "0.140000", + outputPricePer1MTokensUsdc: "0.280000", + defaultMaxOutputTokens: 2048, + maxAutoHoldUsdc: "0.050000", + requestTimeoutMs: 120_000, + }) + ).resolves.toMatchObject({ serviceId: "svc_deepseek_chat" }); + + const body = JSON.parse((calls[2].init?.body as string) ?? "{}"); + expect(body.serviceKind).toBe("llm"); + expect(body.priceModel).toBe("token_metered"); + expect(body.pricing).toMatchObject({ + priceModel: "token_metered", + inputPricePer1MTokensUsdc: "0.140000", + outputPricePer1MTokensUsdc: "0.280000", + defaultMaxOutputTokens: 2048, + maxAutoHoldUsdc: "0.050000", + }); + expect(body.pricing.pricePerToken).toBeUndefined(); + expect(body.invoke.timeoutMs).toBe(120_000); +}); + test("provider service lookup and status derive from listed services", async () => { mockFetch([ ...authHandshakeResponses(), @@ -353,15 +415,24 @@ test("credential lifecycle and owner observability helpers call current gateway ]); const auth = authForTests(); - await auth.revokeCredential("cred_1"); - await auth.rotateCredential("cred_1"); - await auth.updateCredentialQuota("cred_1", { creditLimit: 5, rpm: 60 }); - await auth.deleteCredential("cred_1"); + const revoked: CredentialRevokeResult = await auth.revokeCredential("cred_1"); + const rotated: CredentialRotateResult = await auth.rotateCredential("cred_1"); + const quota: CredentialQuotaUpdateResult = await auth.updateCredentialQuota("cred_1", { creditLimit: 5, rpm: 60 }); + const deleted: CredentialDeleteResult = await auth.deleteCredential("cred_1"); await auth.getCredentialAuditLogs({ limit: 25 }); - await auth.getOwnerProfile(); - await auth.getUsageLogs({ limit: 10 }); - await auth.getFinanceAuditLogs({ limit: 7 }); - await auth.getRiskOverview(); + const profile: OwnerProfile = await auth.getOwnerProfile(); + const usage: UsageLogList = await auth.getUsageLogs({ limit: 10 }); + const financeAudit: FinanceAuditLogList = await auth.getFinanceAuditLogs({ limit: 7 }); + const risk: RiskOverview = await auth.getRiskOverview(); + + expect(revoked.status).toBe("success"); + expect(rotated.status).toBe("success"); + expect(quota.status).toBe("success"); + expect(deleted.status).toBe("success"); + expect(profile.profile).toEqual({ ownerAddress: "0xabcdef" }); + expect(usage.logs).toEqual([]); + expect(financeAudit.logs).toEqual([]); + expect(risk.risk).toBe("low"); expect(calls[2].url).toContain("/api/v1/credentials/agent/cred_1/revoke"); expect(calls[3].url).toContain("/api/v1/credentials/agent/cred_1/rotate"); @@ -415,18 +486,37 @@ test("provider lifecycle and finance helpers call current gateway routes", async ]); const auth = authForTests(); - await auth.deleteProviderSecret("psk_1"); - await auth.getRegistrationGuide(); - await auth.parseCurlToServiceManifest("curl https://provider.example/health"); - await auth.updateProviderService("svc_rec_1", { summary: "updated" }); - await auth.deleteProviderService("svc_rec_1"); - await auth.pingProviderService("svc_rec_1"); - await auth.getProviderServiceHealthHistory("svc_rec_1", { limitPerTarget: 12 }); - await auth.getProviderEarningsSummary(); - await auth.getProviderWithdrawalCapability(); - await auth.createProviderWithdrawalIntent(10, { idempotencyKey: "provider-withdraw-fixed" }); - await auth.listProviderWithdrawals({ limit: 5 }); - await auth.redeemVoucher("ABC123DEF456", "voucher-fixed-1234"); + const secretDeleted: ProviderSecretDeleteResult = await auth.deleteProviderSecret("psk_1"); + const guide: ProviderRegistrationGuide = await auth.getRegistrationGuide(); + const manifest: ServiceManifestDraft = await auth.parseCurlToServiceManifest("curl https://provider.example/health"); + const serviceUpdated: ProviderServiceUpdateResult = await auth.updateProviderService("svc_rec_1", { + summary: "updated", + }); + const serviceDeleted: ProviderServiceDeleteResult = await auth.deleteProviderService("svc_rec_1"); + const ping: ProviderServicePingResult = await auth.pingProviderService("svc_rec_1"); + const health: ProviderServiceHealthHistory = await auth.getProviderServiceHealthHistory("svc_rec_1", { + limitPerTarget: 12, + }); + const earnings: ProviderEarningsSummary = await auth.getProviderEarningsSummary(); + const capability: ProviderWithdrawalCapability = await auth.getProviderWithdrawalCapability(); + const withdrawalIntent: ProviderWithdrawalIntentResult = await auth.createProviderWithdrawalIntent(10, { + idempotencyKey: "provider-withdraw-fixed", + }); + const withdrawals: ProviderWithdrawalList = await auth.listProviderWithdrawals({ limit: 5 }); + const voucher: VoucherRedeemResult = await auth.redeemVoucher("ABC123DEF456", "voucher-fixed-1234"); + + expect(secretDeleted.status).toBe("success"); + expect(guide.steps).toEqual([]); + expect(manifest.data).toEqual({ serviceId: "svc_1" }); + expect(serviceUpdated.status).toBe("success"); + expect(serviceDeleted.status).toBe("success"); + expect(ping.status).toBe("success"); + expect(health.history).toEqual([]); + expect(earnings.total).toBe("12.34"); + expect(capability.available).toBe(true); + expect(withdrawalIntent.intentId).toBe("wd_1"); + expect(withdrawals.withdrawals).toEqual([]); + expect(voucher.status).toBe("redeemed"); expect(calls[2].url).toContain("/api/v1/secrets/provider/psk_1"); expect(calls[3].url).toContain("/api/v1/services/registration-guide"); diff --git a/typescript/tests/unit/client.test.ts b/typescript/tests/unit/client.test.ts index 0594982..9600feb 100644 --- a/typescript/tests/unit/client.test.ts +++ b/typescript/tests/unit/client.test.ts @@ -163,6 +163,53 @@ test("invoke() sends correct body to /agent/invoke", async () => { expect((body["payload"] as Record)["body"]).toEqual({ text: "test" }); }); +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) => { + capturedBody = JSON.parse((init?.body as string) ?? "{}"); + return { + ok: true, + status: 200, + text: async () => + JSON.stringify({ + invocationId: "inv_llm", + status: "SUCCEEDED", + chargedUsdc: 0.000253, + usage: { inputTokens: 1200, outputTokens: 300, totalTokens: 1500 }, + synapse: { + priceModel: "token_metered", + holdUsdc: "0.001000", + chargedUsdc: "0.000253", + releasedUsdc: "0.000747", + preAuthMode: "explicit", + }, + }), + } as Response; + }); + + const client = new SynapseClient({ credential: "agt_test" }); + const result = await client.invokeLlm( + "svc_deepseek_chat", + { messages: [{ role: "user", content: "hello" }], max_tokens: 256 }, + { maxCostUsdc: "0.010000", idempotencyKey: "ik-llm" } + ); + + expect(capturedBody["serviceId"]).toBe("svc_deepseek_chat"); + expect(capturedBody["costUsdc"]).toBeUndefined(); + expect(capturedBody["maxCostUsdc"]).toBe("0.010000"); + expect(capturedBody["responseMode"]).toBe("sync"); + expect(result.usage?.inputTokens).toBe(1200); + expect(result.synapse?.releasedUsdc).toBe("0.000747"); +}); + +test("invokeLlm() rejects stream=true before calling the gateway", async () => { + (globalThis as unknown as Record).fetch = jest.fn(); + const client = new SynapseClient({ credential: "agt_test" }); + + await expect(client.invokeLlm("svc_llm", { stream: true })).rejects.toThrow("LLM_STREAMING_NOT_SUPPORTED"); + expect(globalThis.fetch as jest.Mock).not.toHaveBeenCalled(); +}); + // ── Error mapping ───────────────────────────────────────────────────────────── test("401 response from invoke throws AuthenticationError", async () => { From e680f973039df3aa2f5f78d18299db8f93da7024 Mon Sep 17 00:00:00 2001 From: cc-mac-mini Date: Wed, 29 Apr 2026 21:44:21 +0800 Subject: [PATCH 3/3] Allow SDK wallet module in hygiene gate --- scripts/ci/repo_hygiene_checks.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/ci/repo_hygiene_checks.sh b/scripts/ci/repo_hygiene_checks.sh index c79c22a..a2ddf3d 100755 --- a/scripts/ci/repo_hygiene_checks.sh +++ b/scripts/ci/repo_hygiene_checks.sh @@ -55,6 +55,7 @@ sensitive_files="$( | grep -Ev '(^|/)\.env\.example$' \ | grep -Ev '^python/examples/consumer_wallet_to_invoke\.py$' \ | grep -Ev '^python/synapse_client/_auth_credentials\.py$' \ + | grep -Ev '^python/synapse_client/wallet\.py$' \ | grep -Ev '^typescript/src/auth_credentials\.ts$' \ || true )"