-
- {tier.name}
-
- {tier.units.toLocaleString()} {tier.unit_type}
-
-
+ {bundles.map((bundle) => (
+
+ {bundle.name}
- {currency} {tier.price.toLocaleString()}
+ {currency} {bundle.amount.toLocaleString()}
))}
diff --git a/components/mcp/README.md b/components/mcp/README.md
deleted file mode 100644
index d0e373cb..00000000
--- a/components/mcp/README.md
+++ /dev/null
@@ -1,94 +0,0 @@
-# FastMCP Server with SyftBox Integration
-
-A comprehensive FastMCP server implementation featuring integrated OAuth 2.1 authentication and SyftBox API integration.
-
-## 🚀 Features
-
-- **Consolidated Architecture**: Single-server deployment combining MCP protocol and OAuth 2.1 authentication
-- **SyftBox Integration**: Built-in OTP authentication flow with automatic token management
-- **Secure Token Storage**: Automatic capture and storage of SyftBox access/refresh tokens
-- **Seamless API Access**: Existing tools automatically use stored tokens for SyftBox API calls
-- **Real-time Authentication**: Complete OAuth 2.1 with PKCE flow implementation
-
-## 📁 Project Structure
-
-```
-├── echo.py # Main FastMCP server with integrated OAuth & SyftBox
-├── syftbox_client.py # SyftBox API client for OTP authentication
-├── fastmcp.json # Server configuration
-├── pyproject.toml # Dependencies
-└── README_OAuth.md # Legacy OAuth documentation
-```
-
-## 🔧 Quick Start
-
-1. **Install Dependencies**:
- ```bash
- uv sync
- ```
-
-2. **Start Server**:
- ```bash
- uv run fastmcp run
- ```
-
-3. **Access Server**:
- - **MCP Endpoint**: `http://localhost:8004/mcp`
- - **OAuth Flow**: `http://localhost:8004/oauth/authorize`
- - **JWKS**: `http://localhost:8004/.well-known/jwks.json`
-
-## 🔐 Authentication Flow
-
-1. **OAuth 2.1 Authorization**: Client initiates OAuth flow with PKCE
-2. **SyftBox OTP**: User enters email and receives OTP from SyftBox
-3. **Token Capture**: Server automatically stores SyftBox access/refresh tokens
-4. **Seamless Integration**: Tools automatically use stored tokens for API calls
-
-## 🛠️ Available Tools
-
-### Core Tools
-- `echo_tool` - Echo input text
-- `list_data_sources` - List available data sources (includes SyftBox sources)
-- `build_context` - Build context from data sources with SyftBox integration
-
-### SyftBox Data Sources
-- `syftbox_profile` - Fetch user profile using stored tokens
-- `syftbox_api:/endpoint` - Access any SyftBox API endpoint
-
-## 📊 Usage Examples
-
-```bash
-# List all data sources (includes SyftBox integration)
-list_data_sources()
-
-# Fetch SyftBox user profile (uses stored tokens automatically)
-build_context(["syftbox_profile"])
-
-# Custom SyftBox API call (uses stored tokens automatically)
-build_context(["syftbox_api:/api/datasets"])
-```
-
-## 🔑 Environment Variables
-
-```bash
-OAUTH_ISSUER=http://localhost:8004
-OAUTH_AUDIENCE=fastmcp-api
-API_BASE_URL=http://localhost:8004
-```
-
-## 🏗️ Architecture
-
-- **FastMCP Framework**: Modern MCP server implementation
-- **OAuth 2.1 + PKCE**: Secure authorization with proof key
-- **JWT Tokens**: RS256 signed tokens with JWKS endpoint
-- **SyftBox Client**: OTP authentication integration
-- **Token Management**: Automatic storage and refresh handling
-
-## 📖 Learn More
-
-- [FastMCP Documentation](https://gofastmcp.com/)
-- [MCP Protocol](https://modelcontextprotocol.io/)
-- [OAuth 2.1 Specification](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1)
-
----
-*Enhanced FastMCP server with integrated SyftBox authentication and API access.*
diff --git a/components/mcp/pyproject.toml b/components/mcp/pyproject.toml
index 0bb49d91..eb9c3676 100644
--- a/components/mcp/pyproject.toml
+++ b/components/mcp/pyproject.toml
@@ -17,7 +17,7 @@ dependencies = [
# Web framework dependencies with security fixes
"starlette>=0.49.1", # CVE-2025-62727: O(n^2) DoS via Range header
"werkzeug>=3.1.6", # CVE-2025-66221, CVE-2026-21860, CVE-2026-27199: Windows device names
- "authlib>=1.6.9", # CVE-2025-68158, CVE-2026-28802, CVE-2026-27962, CVE-2026-28490, CVE-2026-28498
+ "authlib>=1.6.11", # CVE-2025-68158, CVE-2026-28802, CVE-2026-27962, CVE-2026-28490, CVE-2026-28498
# Pydantic (compatible with fastmcp>=2.14.3)
"pydantic>=2.11.7",
# Authentication and crypto
@@ -25,7 +25,7 @@ dependencies = [
"pyjwt>=2.12.0", # CVE-2026-32597: accepts unknown crit header extensions
# Utilities
"python-dotenv==1.1.0",
- "python-multipart>=0.0.5",
+ "python-multipart>=0.0.26",
"email-validator>=2.0.0",
# SyftHub integration
"syft-accounting-sdk @ git+https://git@github.com/OpenMined/accounting-sdk.git",
@@ -48,7 +48,7 @@ override-dependencies = [
"urllib3>=2.6.3", # CVE-2025-66471, CVE-2026-21441
"starlette>=0.49.1", # CVE-2025-62727
"werkzeug>=3.1.6", # CVE-2025-66221, CVE-2026-21860, CVE-2026-27199
- "authlib>=1.6.9", # CVE-2025-68158, CVE-2026-27962, CVE-2026-28490, CVE-2026-28498, CVE-2026-28802
+ "authlib>=1.6.11", # CVE-2025-68158, CVE-2026-27962, CVE-2026-28490, CVE-2026-28498, CVE-2026-28802
"aiohttp>=3.13.3", # CVE-2025-69223
"mcp>=1.23.0", # CVE-2025-66416
"pydantic>=2.11.7", # syft-accounting-sdk pins ==2.11.4; override for fastmcp compat
diff --git a/components/mcp/uv.lock b/components/mcp/uv.lock
index 938522a9..24eccceb 100644
--- a/components/mcp/uv.lock
+++ b/components/mcp/uv.lock
@@ -5,7 +5,7 @@ requires-python = ">=3.12"
[manifest]
overrides = [
{ name = "aiohttp", specifier = ">=3.13.3" },
- { name = "authlib", specifier = ">=1.6.9" },
+ { name = "authlib", specifier = ">=1.6.11" },
{ name = "mcp", specifier = ">=1.23.0" },
{ name = "pydantic", specifier = ">=2.11.7" },
{ name = "requests", specifier = ">=2.32.4" },
@@ -167,14 +167,14 @@ wheels = [
[[package]]
name = "authlib"
-version = "1.6.9"
+version = "1.6.11"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/af/98/00d3dd826d46959ad8e32af2dbb2398868fd9fd0683c26e56d0789bd0e68/authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04", size = 165134, upload-time = "2026-03-02T07:44:01.998Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/28/10/b325d58ffe86815b399334a101e63bc6fa4e1953921cb23703b48a0a0220/authlib-1.6.11.tar.gz", hash = "sha256:64db35b9b01aeccb4715a6c9a6613a06f2bd7be2ab9d2eb89edd1dfc7580a38f", size = 165359, upload-time = "2026-04-16T07:22:50.279Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" },
+ { url = "https://files.pythonhosted.org/packages/57/2f/55fca558f925a51db046e5b929deb317ddb05afed74b22d89f4eca578980/authlib-1.6.11-py2.py3-none-any.whl", hash = "sha256:c8687a9a26451c51a34a06fa17bb97cb15bba46a6a626755e2d7f50da8bff3e3", size = 244469, upload-time = "2026-04-16T07:22:48.413Z" },
]
[[package]]
@@ -1308,11 +1308,11 @@ wheels = [
[[package]]
name = "python-multipart"
-version = "0.0.22"
+version = "0.0.26"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17", size = 43501, upload-time = "2026-04-10T14:09:59.473Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" },
]
[[package]]
@@ -1608,7 +1608,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "aiohttp", specifier = ">=3.13.3" },
- { name = "authlib", specifier = ">=1.6.9" },
+ { name = "authlib", specifier = ">=1.6.11" },
{ name = "cryptography", specifier = ">=41.0.0" },
{ name = "email-validator", specifier = ">=2.0.0" },
{ name = "fastmcp", specifier = ">=2.14.3" },
@@ -1617,7 +1617,7 @@ requires-dist = [
{ name = "pydantic", specifier = ">=2.11.7" },
{ name = "pyjwt", specifier = ">=2.12.0" },
{ name = "python-dotenv", specifier = "==1.1.0" },
- { name = "python-multipart", specifier = ">=0.0.5" },
+ { name = "python-multipart", specifier = ">=0.0.26" },
{ name = "requests", specifier = ">=2.32.4" },
{ name = "starlette", specifier = ">=0.49.1" },
{ name = "syft-accounting-sdk", git = "https://github.com/OpenMined/accounting-sdk.git" },
diff --git a/docs/agent-endpoint-workflow-design.md b/docs/agent-endpoint-workflow-design.md
deleted file mode 100644
index 680afc6b..00000000
--- a/docs/agent-endpoint-workflow-design.md
+++ /dev/null
@@ -1,1728 +0,0 @@
-# Agent Endpoint Workflow — Architecture Design
-
-> Design for a new `agent` endpoint type that enables bidirectional, session-based communication between users and remote agents running on desktop/CLI nodes — similar to Claude Code's interactive workflow, but in SyftHub's distributed format.
-
----
-
-## Table of Contents
-
-1. [Motivation & Problem Statement](#1-motivation--problem-statement)
-2. [Design Principles](#2-design-principles)
-3. [Architecture Overview](#3-architecture-overview)
-4. [Session Lifecycle](#4-session-lifecycle)
-5. [Complete Sequence Diagram](#5-complete-sequence-diagram)
-6. [WebSocket Protocol (Frontend ↔ Aggregator)](#6-websocket-protocol-frontend--aggregator)
-7. [NATS Session Protocol (Aggregator ↔ Space)](#7-nats-session-protocol-aggregator--space)
-8. [HTTP Direct Transport (Non-Tunnel Spaces)](#8-http-direct-transport-non-tunnel-spaces)
-9. [Aggregator Session Manager](#9-aggregator-session-manager)
-10. [Go SDK Server — Agent Handler Framework](#10-go-sdk-server--agent-handler-framework)
-11. [Go SDK Client — Agent Resource](#11-go-sdk-client--agent-resource)
-12. [TypeScript SDK — Agent Client](#12-typescript-sdk--agent-client)
-13. [Frontend — Agent UI](#13-frontend--agent-ui)
-14. [Authentication & Token Strategy](#14-authentication--token-strategy)
-15. [Comparison: Chat vs Agent Workflow](#15-comparison-chat-vs-agent-workflow)
-16. [Error Handling & Edge Cases](#16-error-handling--edge-cases)
-17. [Backward Compatibility Checklist](#17-backward-compatibility-checklist)
-18. [Implementation Phases](#18-implementation-phases)
-
----
-
-## 1. Motivation & Problem Statement
-
-### Current Limitation
-
-The existing SyftHub workflow supports only **request → response** interactions:
-
-```
-User sends query → Aggregator orchestrates RAG → Model generates response → User receives answer
-```
-
-This works for stateless Q&A but cannot support:
-
-- **Multi-step reasoning** where an agent iterates through tool calls
-- **Interactive workflows** where the agent pauses for user confirmation
-- **Dynamic input** where the user provides additional context mid-execution
-- **Long-running tasks** where the agent reports progress over minutes
-
-### The Agent Paradigm
-
-An agent endpoint operates as a **session-based, bidirectional conversation** where both sides can push messages at any time:
-
-```
-User sends prompt → Agent starts working
- ← Agent: "Reading file auth.py..."
- ← Agent: tool_call(read_file, {path: "auth.py"})
- ← Agent: tool_result(success, contents)
- ← Agent: "I found a bug. I want to fix it."
- ← Agent: tool_call(write_file, {path: "auth.py"}, requires_confirmation=true)
-User: confirm →
- ← Agent: tool_result(success)
-User: "Also check tests" →
- ← Agent: "Checking test files..."
- ← Agent: tool_call(read_file, {path: "test_auth.py"})
- ...
- ← Agent: session.completed("Fixed 3 bugs across 2 files")
-```
-
-This is the Claude Code interaction model, generalized for SyftHub's distributed architecture.
-
----
-
-## 2. Design Principles
-
-1. **Fully additive** — Zero modifications to existing chat/RAG workflow
-2. **Transport-agnostic agent handlers** — Same `AgentSession` API whether transport is NATS or HTTP
-3. **Leverage existing infrastructure** — Reuse NATS tunneling, satellite tokens, X25519 encryption
-4. **Session as the primitive** — All communication scoped to a session with clear lifecycle
-5. **Agent-defined interaction** — The agent (not the platform) decides when to ask for input, which tools need confirmation, and how to stream output
-
----
-
-## 3. Architecture Overview
-
-### Existing vs Agent Data Path
-
-```mermaid
-graph TB
- subgraph "Existing Chat Path (unchanged)"
- FE_CHAT[Frontend] -->|"SSE POST"| AG_CHAT[Aggregator
/chat/stream]
- AG_CHAT -->|"RAG Pipeline"| AG_ORCH[Orchestrator]
- AG_ORCH -->|"HTTP or NATS
req/resp"| SPACE_CHAT[Space Handler]
- end
-
- subgraph "New Agent Path (additive)"
- FE_AGENT[Frontend] <-->|"WebSocket"| AG_AGENT[Aggregator
/agent/session]
- AG_AGENT <-->|"Session Manager"| SM[SessionTransport]
- SM <-->|"NATS session"| SPACE_AGENT[Space
AgentHandler]
- SM <-->|"HTTP SSE+POST"| SPACE_HTTP[Space
AgentHandler]
- end
-
- style FE_CHAT fill:#4A90D9,color:#fff
- style FE_AGENT fill:#E74C3C,color:#fff
- style AG_CHAT fill:#7B68EE,color:#fff
- style AG_AGENT fill:#E74C3C,color:#fff
-```
-
-### Full System View
-
-```mermaid
-graph TB
- subgraph "Browser"
- UI[Agent UI
useAgentWorkflow]
- end
-
- subgraph "SyftHub Cloud"
- NG[Nginx
WebSocket proxy]
- BE[Backend Hub
Tokens + Auth]
- AG[Aggregator
Session Manager]
- NATS[NATS Broker]
- end
-
- subgraph "User's Machine"
- SPACE[Desktop/CLI
NATS Client]
- AGENT[Agent Handler
Go/Python]
- end
-
- UI <-->|"WebSocket"| NG
- NG <-->|"WebSocket"| AG
- UI -->|"Token requests"| NG
- NG -->|"Token requests"| BE
-
- AG <-->|"Encrypted pub/sub
session protocol"| NATS
- NATS <-->|"Encrypted pub/sub"| SPACE
- SPACE --> AGENT
-
- style UI fill:#4A90D9,color:#fff
- style AG fill:#7B68EE,color:#fff
- style NATS fill:#27AE60,color:#fff
- style SPACE fill:#E74C3C,color:#fff
-```
-
-### Why WebSocket (not SSE)?
-
-| Criterion | SSE (current chat) | WebSocket (agent) |
-|-----------|--------------------|--------------------|
-| Direction | Server → Client only | Bidirectional |
-| User input mid-stream | Requires separate POST endpoint | Native — same connection |
-| Session identity | Implicit (one SSE per request) | Explicit session_id |
-| Reconnection | Reconnect = new request | Reconnect = resume session (v2) |
-| Fit for agents | Poor — user can't inject input | Natural |
-
----
-
-## 4. Session Lifecycle
-
-```mermaid
-stateDiagram-v2
- [*] --> Connecting: Frontend opens WebSocket
- Connecting --> Initializing: session.start sent
- Initializing --> Running: session.created received
-
- Running --> Running: agent sends events
(thinking, tool_call, token, status)
- Running --> AwaitingInput: agent.request_input
- AwaitingInput --> Running: user.message / user.confirm
- Running --> Running: user.message (async input)
-
- Running --> Completed: session.completed
- Running --> Failed: session.failed
- Running --> Cancelled: user.cancel / user sends session.close
- AwaitingInput --> Cancelled: user.cancel
-
- Completed --> [*]
- Failed --> [*]
- Cancelled --> [*]
-
- note right of Running
- Agent alternates between
- autonomous work and
- user interaction
- end note
-
- note right of AwaitingInput
- Agent explicitly pauses
- and requests user input
- (tool confirmation, question, etc.)
- end note
-```
-
-### Session State Transitions
-
-| Current State | Event | Next State | Actor |
-|---------------|-------|------------|-------|
-| — | WebSocket opened | `connecting` | Frontend |
-| `connecting` | `session.start` sent | `initializing` | Frontend |
-| `initializing` | `session.created` received | `running` | Aggregator |
-| `running` | `agent.request_input` | `awaiting_input` | Agent |
-| `awaiting_input` | `user.message` / `user.confirm` | `running` | User |
-| `running` | `session.completed` | `completed` | Agent |
-| `running` | `session.failed` | `failed` | Agent |
-| `running` / `awaiting_input` | `user.cancel` | `cancelled` | User |
-| `running` / `awaiting_input` | `session.close` | `closed` | User |
-| any | WebSocket disconnect | `disconnected` | Network |
-| any | Inactivity timeout | `timed_out` | Aggregator |
-
----
-
-## 5. Complete Sequence Diagram
-
-### Happy Path: Agent with Tool Confirmation
-
-```mermaid
-sequenceDiagram
- actor User
- participant FE as Frontend
(React)
- participant BE as Backend
(Hub API)
- participant AG as Aggregator
(Session Mgr)
- participant NATS as NATS Broker
- participant Space as Desktop/CLI
- participant Agent as Agent Handler
-
- rect rgb(255, 243, 224)
- Note over FE,BE: Token Acquisition
- FE->>BE: GET /api/v1/token?aud=alice
- BE-->>FE: satellite_token (60s)
- FE->>BE: POST /api/v1/peer-token
- BE-->>FE: peer_token + peer_channel
- end
-
- rect rgb(224, 247, 250)
- Note over FE,AG: WebSocket Connection
- FE->>AG: WS /aggregator/api/v1/agent/session
- FE->>AG: session.start {prompt, endpoint, tokens}
- end
-
- rect rgb(232, 245, 233)
- Note over AG,Agent: Session Establishment
- AG->>AG: Create session state
- AG->>AG: Encrypt payload (X25519 + AES-256-GCM)
- AG->>NATS: PUB syfthub.spaces.alice
type: agent_session_start
- AG->>NATS: SUB syfthub.peer.{channel}
- NATS->>Space: Deliver agent_session_start
- Space->>Space: Decrypt, verify token
- Space->>Space: Create AgentSession
- Space->>Agent: Launch handler goroutine
- AG-->>FE: session.created {session_id}
- end
-
- rect rgb(243, 229, 245)
- Note over Agent,FE: Agent Execution (bidirectional)
-
- Agent->>Space: session.SendStatus("Reading file...")
- Space->>NATS: PUB syfthub.peer.{channel}
type: agent_event
- NATS-->>AG: agent_event {status}
- AG-->>FE: agent.status {status: "Reading file..."}
-
- Agent->>Space: session.SendToolCall(read_file)
- Space->>NATS: agent_event {tool_call}
- NATS-->>AG: Relay
- AG-->>FE: agent.tool_call {tool: "read_file", requires_confirmation: false}
-
- Agent->>Agent: Execute tool internally
- Agent->>Space: session.SendToolResult(success)
- Space->>NATS: agent_event {tool_result}
- NATS-->>AG: Relay
- AG-->>FE: agent.tool_result {status: "success"}
-
- Agent->>Space: session.SendThinking("Found a bug...")
- Space->>NATS: agent_event {thinking}
- NATS-->>AG: Relay
- AG-->>FE: agent.thinking {content: "Found a bug..."}
-
- Agent->>Space: session.SendToolCall(write_file, requires_confirm=true)
- Space->>NATS: agent_event {tool_call, requires_confirmation: true}
- NATS-->>AG: Relay
- AG-->>FE: agent.tool_call {tool: "write_file", requires_confirmation: true}
- Note over FE: Show Confirm/Deny buttons
- end
-
- rect rgb(255, 235, 238)
- Note over User,Agent: User Confirmation
- User->>FE: Click "Confirm"
- FE->>BE: GET /api/v1/token?aud=alice (refresh)
- BE-->>FE: fresh satellite_token
- FE->>AG: user.confirm {tool_call_id, satellite_token}
- AG->>AG: Encrypt
- AG->>NATS: PUB syfthub.spaces.alice
type: agent_user_message
- NATS->>Space: Deliver user confirmation
- Space->>Space: Decrypt, push to session.recvCh
- Agent->>Agent: session.RequestConfirmation() returns true
- Agent->>Agent: Execute write_file
- Agent->>Space: session.SendToolResult(success)
- Space->>NATS: agent_event {tool_result}
- NATS-->>AG: Relay
- AG-->>FE: agent.tool_result {status: "success"}
- end
-
- rect rgb(232, 245, 233)
- Note over User,Agent: Dynamic User Input
- User->>FE: "Also check test files"
- FE->>AG: user.message {content: "Also check test files"}
- AG->>NATS: agent_user_message
- NATS->>Space: Deliver
- Space->>Agent: Push to session.recvCh
- Agent->>Agent: session.Receive() returns user message
- Note over Agent: Agent continues with new context
- end
-
- rect rgb(224, 247, 250)
- Note over Agent,FE: Session Completion
- Agent->>Space: Handler returns nil
- Space->>NATS: agent_event {session.completed}
- NATS-->>AG: Relay
- AG-->>FE: session.completed {summary, usage, duration_ms}
- AG->>AG: Cleanup session, unsubscribe NATS
- FE->>FE: Close WebSocket
- end
-```
-
----
-
-## 6. WebSocket Protocol (Frontend ↔ Aggregator)
-
-### Message Envelope
-
-Every WebSocket message is a JSON object with a common envelope:
-
-```json
-{
- "type": "
",
- "session_id": "",
- "sequence": 42,
- "timestamp": "2026-03-19T10:30:00.123Z",
- "payload": { }
-}
-```
-
-- `session_id` is absent in `session.start` (assigned by aggregator)
-- `sequence` is monotonically increasing per direction (client sequences and server sequences are independent)
-- All payloads are JSON objects
-
-### Client → Server Messages
-
-```mermaid
-classDiagram
- class SessionStart {
- +type: "session.start"
- +payload.prompt: string
- +payload.endpoint: EndpointRef
- +payload.satellite_token: string
- +payload.transaction_token: string?
- +payload.peer_token: string?
- +payload.peer_channel: string?
- +payload.config: AgentConfig?
- +payload.messages: Message[]?
- }
-
- class UserMessage {
- +type: "user.message"
- +payload.content: string
- +payload.satellite_token: string?
- }
-
- class UserConfirm {
- +type: "user.confirm"
- +payload.tool_call_id: string
- +payload.modifications: string?
- }
-
- class UserDeny {
- +type: "user.deny"
- +payload.tool_call_id: string
- +payload.reason: string?
- }
-
- class UserCancel {
- +type: "user.cancel"
- }
-
- class SessionClose {
- +type: "session.close"
- }
-
- class Ping {
- +type: "ping"
- }
-```
-
-#### `session.start` — Initialize Agent Session
-
-```json
-{
- "type": "session.start",
- "sequence": 1,
- "timestamp": "2026-03-19T10:30:00Z",
- "payload": {
- "prompt": "Find and fix the bug in auth.py",
- "endpoint": {
- "owner": "alice",
- "slug": "code-assistant"
- },
- "satellite_token": "eyJ...",
- "transaction_token": "tx_...",
- "peer_token": "peer_...",
- "peer_channel": "a1b2c3d4-...",
- "config": {
- "max_tokens": 4096,
- "temperature": 0.7,
- "system_prompt": "You are a coding assistant.",
- "metadata": { "project": "syfthub" }
- },
- "messages": [
- { "role": "user", "content": "Previous context..." },
- { "role": "assistant", "content": "Previous response..." }
- ]
- }
-}
-```
-
-#### `user.message` — Send Input Mid-Session
-
-```json
-{
- "type": "user.message",
- "session_id": "sess_abc123",
- "sequence": 2,
- "timestamp": "2026-03-19T10:31:15Z",
- "payload": {
- "content": "Also check the test files for similar issues",
- "satellite_token": "eyJ...(fresh token)..."
- }
-}
-```
-
-#### `user.confirm` / `user.deny` — Respond to Tool Call
-
-```json
-{
- "type": "user.confirm",
- "session_id": "sess_abc123",
- "sequence": 3,
- "payload": {
- "tool_call_id": "tc_xyz789"
- }
-}
-```
-
-```json
-{
- "type": "user.deny",
- "session_id": "sess_abc123",
- "sequence": 3,
- "payload": {
- "tool_call_id": "tc_xyz789",
- "reason": "Don't modify that file, it's managed by another team"
- }
-}
-```
-
-### Server → Client Messages
-
-```mermaid
-classDiagram
- class SessionCreated {
- +type: "session.created"
- +payload.session_id: string
- +payload.endpoint: EndpointInfo
- }
-
- class AgentThinking {
- +type: "agent.thinking"
- +payload.content: string
- +payload.is_streaming: boolean
- }
-
- class AgentToolCall {
- +type: "agent.tool_call"
- +payload.tool_call_id: string
- +payload.tool_name: string
- +payload.arguments: object
- +payload.requires_confirmation: boolean
- +payload.description: string?
- }
-
- class AgentToolResult {
- +type: "agent.tool_result"
- +payload.tool_call_id: string
- +payload.status: "success" | "error"
- +payload.result: any?
- +payload.error: string?
- +payload.duration_ms: int
- }
-
- class AgentMessage {
- +type: "agent.message"
- +payload.content: string
- +payload.is_complete: boolean
- }
-
- class AgentToken {
- +type: "agent.token"
- +payload.content: string
- }
-
- class AgentStatus {
- +type: "agent.status"
- +payload.status: string
- +payload.detail: string?
- +payload.progress: Progress?
- }
-
- class AgentRequestInput {
- +type: "agent.request_input"
- +payload.prompt: string
- +payload.input_type: "text"|"confirmation"|"choice"
- +payload.choices: string[]?
- +payload.default: string?
- }
-
- class AgentError {
- +type: "agent.error"
- +payload.code: string
- +payload.message: string
- +payload.recoverable: boolean
- }
-
- class SessionCompleted {
- +type: "session.completed"
- +payload.summary: string?
- +payload.usage: TokenUsage?
- +payload.duration_ms: int
- }
-
- class SessionFailed {
- +type: "session.failed"
- +payload.code: string
- +payload.message: string
- }
-```
-
-#### Event Type Reference
-
-| Event | Direction | Purpose | Phase |
-|-------|-----------|---------|-------|
-| `session.start` | Client→Server | Initialize session | Setup |
-| `session.created` | Server→Client | Session confirmed | Setup |
-| `agent.thinking` | Server→Client | Agent reasoning (transparency) | Running |
-| `agent.tool_call` | Server→Client | Agent wants to use a tool | Running |
-| `agent.tool_result` | Server→Client | Tool execution result | Running |
-| `agent.message` | Server→Client | Agent text response | Running |
-| `agent.token` | Server→Client | Streamed response chunk | Running |
-| `agent.status` | Server→Client | Progress update | Running |
-| `agent.request_input` | Server→Client | Agent pauses for input | Running→Awaiting |
-| `agent.error` | Server→Client | Error (may be recoverable) | Any |
-| `user.message` | Client→Server | User sends input | Running/Awaiting |
-| `user.confirm` | Client→Server | Confirm tool call | Awaiting |
-| `user.deny` | Client→Server | Deny tool call | Awaiting |
-| `user.cancel` | Client→Server | Cancel current run | Running/Awaiting |
-| `session.close` | Client→Server | Terminate session | Any |
-| `session.completed` | Server→Client | Agent finished | Terminal |
-| `session.failed` | Server→Client | Unrecoverable error | Terminal |
-| `ping`/`pong` | Both | Keepalive | Any |
-
----
-
-## 7. NATS Session Protocol (Aggregator ↔ Space)
-
-### Extension of Existing Tunnel Protocol
-
-The agent session protocol extends `syfthub-tunnel/v1` with new message types. Existing types (`endpoint_request`, `endpoint_response`) are unchanged.
-
-```mermaid
-graph LR
- subgraph "Existing Types (unchanged)"
- ER["endpoint_request"]
- ERESP["endpoint_response"]
- end
-
- subgraph "New Agent Types"
- ASS["agent_session_start"]
- AUM["agent_user_message"]
- ASC["agent_session_cancel"]
- AE["agent_event"]
- end
-
- subgraph "NATS Subjects"
- S1["syfthub.spaces.{username}"]
- S2["syfthub.peer.{peer_channel}"]
- end
-
- ER -->|"Aggregator publishes"| S1
- ASS -->|"Aggregator publishes"| S1
- AUM -->|"Aggregator publishes"| S1
- ASC -->|"Aggregator publishes"| S1
-
- ERESP -->|"Space publishes"| S2
- AE -->|"Space publishes"| S2
-
- style ASS fill:#E74C3C,color:#fff
- style AUM fill:#E74C3C,color:#fff
- style ASC fill:#E74C3C,color:#fff
- style AE fill:#E74C3C,color:#fff
-```
-
-### Aggregator → Space Messages
-
-All published to `syfthub.spaces.{username}`:
-
-#### `agent_session_start`
-
-```json
-{
- "protocol": "syfthub-tunnel/v1",
- "type": "agent_session_start",
- "correlation_id": "corr-uuid-1",
- "session_id": "sess-uuid-1",
- "reply_to": "peer-channel-uuid",
- "endpoint": { "slug": "code-assistant", "type": "agent" },
- "satellite_token": "eyJ...",
- "timeout_ms": 0,
- "encryption_info": {
- "algorithm": "X25519-ECDH-AES-256-GCM",
- "ephemeral_public_key": "base64url...",
- "nonce": "base64url..."
- },
- "encrypted_payload": "base64url(encrypted JSON)"
-}
-```
-
-Decrypted payload:
-```json
-{
- "prompt": "Find and fix the bug in auth.py",
- "config": {
- "max_tokens": 4096,
- "temperature": 0.7,
- "system_prompt": "You are a coding assistant."
- },
- "messages": [],
- "transaction_token": "tx_..."
-}
-```
-
-#### `agent_user_message`
-
-```json
-{
- "protocol": "syfthub-tunnel/v1",
- "type": "agent_user_message",
- "correlation_id": "corr-uuid-2",
- "session_id": "sess-uuid-1",
- "reply_to": "peer-channel-uuid",
- "satellite_token": "eyJ...(fresh)...",
- "encryption_info": { ... },
- "encrypted_payload": "base64url(encrypted JSON)"
-}
-```
-
-Decrypted payload:
-```json
-{
- "message_type": "user_message",
- "content": "Also check test files"
-}
-```
-
-Or for confirmations:
-```json
-{
- "message_type": "user_confirm",
- "tool_call_id": "tc_xyz789"
-}
-```
-
-#### `agent_session_cancel`
-
-```json
-{
- "protocol": "syfthub-tunnel/v1",
- "type": "agent_session_cancel",
- "correlation_id": "corr-uuid-3",
- "session_id": "sess-uuid-1",
- "reply_to": "peer-channel-uuid",
- "encryption_info": { ... },
- "encrypted_payload": "base64url(encrypted {})"
-}
-```
-
-### Space → Aggregator Messages
-
-All published to `syfthub.peer.{peer_channel}`:
-
-#### `agent_event`
-
-```json
-{
- "protocol": "syfthub-tunnel/v1",
- "type": "agent_event",
- "correlation_id": "corr-uuid-4",
- "session_id": "sess-uuid-1",
- "endpoint_slug": "code-assistant",
- "encryption_info": { ... },
- "encrypted_payload": "base64url(encrypted JSON)",
- "timing": {
- "received_at": "2026-03-19T10:30:00Z",
- "processed_at": "2026-03-19T10:30:00.050Z",
- "duration_ms": 50
- }
-}
-```
-
-Decrypted payload (the actual agent event):
-```json
-{
- "event_type": "tool_call",
- "sequence": 5,
- "data": {
- "tool_call_id": "tc_xyz789",
- "tool_name": "write_file",
- "arguments": { "path": "auth.py", "content": "..." },
- "requires_confirmation": true,
- "description": "Fix authentication bug in auth.py"
- }
-}
-```
-
-### NATS Subject Reuse
-
-```mermaid
-sequenceDiagram
- participant AG as Aggregator
- participant NATS as NATS Broker
- participant SP as Space
-
- Note over SP: Already subscribed to
syfthub.spaces.alice
(handles BOTH endpoint_request
AND agent_session_start)
-
- AG->>NATS: PUB syfthub.spaces.alice
{type: "agent_session_start", session_id: "S1"}
- NATS->>SP: Deliver
-
- Note over SP: Dispatch by type field:
endpoint_request → existing handler
agent_session_start → new session handler
-
- SP->>NATS: PUB syfthub.peer.{channel}
{type: "agent_event", session_id: "S1"}
- NATS-->>AG: Deliver
-
- AG->>NATS: PUB syfthub.spaces.alice
{type: "agent_user_message", session_id: "S1"}
- NATS->>SP: Deliver
-
- Note over SP: Route by session_id to
correct AgentSession's recvCh
-```
-
-### Encryption Per Message
-
-Each NATS message is independently encrypted — exactly the same as existing tunnel protocol:
-
-- Aggregator generates a **new ephemeral keypair per message**
-- Space generates a **new ephemeral keypair per event**
-- AAD = `correlation_id` (unique per message, NOT session_id)
-- Same HKDF info labels: `syfthub-tunnel-request-v1` / `syfthub-tunnel-response-v1`
-
-This means each message has forward secrecy independent of all other messages.
-
----
-
-## 8. HTTP Direct Transport (Non-Tunnel Spaces)
-
-For spaces with a public URL (not tunneling), the aggregator uses HTTP:
-
-```mermaid
-sequenceDiagram
- participant AG as Aggregator
- participant SP as Space (HTTP)
-
- AG->>SP: POST /api/v1/agent/session/start
{prompt, config, satellite_token}
- Note over SP: Returns SSE stream (keep-alive)
-
- SP-->>AG: SSE: agent.status {reading file}
- SP-->>AG: SSE: agent.tool_call {read_file}
- SP-->>AG: SSE: agent.tool_result {success}
- SP-->>AG: SSE: agent.thinking {found bug}
- SP-->>AG: SSE: agent.tool_call {write_file, requires_confirmation}
-
- AG->>SP: POST /api/v1/agent/session/{id}/message
{type: "user_confirm", tool_call_id: "..."}
-
- SP-->>AG: SSE: agent.tool_result {success}
- SP-->>AG: SSE: session.completed
-```
-
-### Space HTTP Endpoints (new)
-
-| Method | Path | Purpose |
-|--------|------|---------|
-| `POST` | `/api/v1/agent/session/start` | Start session, returns SSE stream |
-| `POST` | `/api/v1/agent/session/{id}/message` | Send user message/confirm/deny |
-| `POST` | `/api/v1/agent/session/{id}/cancel` | Cancel session |
-
-### Transport Abstraction
-
-```mermaid
-classDiagram
- class SessionTransport {
- <>
- +send_to_space(message) async
- +receive_from_space() AsyncGenerator~AgentEvent~
- +close() async
- }
-
- class NATSSessionTransport {
- -nats_conn: Connection
- -peer_channel: str
- -session_id: str
- -space_pubkey: bytes
- +send_to_space(message) async
- +receive_from_space() AsyncGenerator~AgentEvent~
- +close() async
- }
-
- class HTTPSessionTransport {
- -sse_connection: httpx.Response
- -space_url: str
- -session_id: str
- +send_to_space(message) async
- +receive_from_space() AsyncGenerator~AgentEvent~
- +close() async
- }
-
- SessionTransport <|.. NATSSessionTransport
- SessionTransport <|.. HTTPSessionTransport
-```
-
----
-
-## 9. Aggregator Session Manager
-
-### Architecture
-
-```mermaid
-graph TB
- WS1[WebSocket 1] --> SM[Session Manager]
- WS2[WebSocket 2] --> SM
- WS3[WebSocket 3] --> SM
-
- SM --> S1[Session 1
NATS Transport]
- SM --> S2[Session 2
NATS Transport]
- SM --> S3[Session 3
HTTP Transport]
-
- S1 --> NATS[NATS Broker]
- S2 --> NATS
- S3 --> HTTP[HTTP Space]
-
- style SM fill:#7B68EE,color:#fff
-```
-
-### Session State
-
-```python
-@dataclass
-class AgentSession:
- session_id: str
- websocket: WebSocket
- transport: SessionTransport
- endpoint_ref: ResolvedEndpoint
- owner_username: str
- state: Literal["initializing", "running", "awaiting_input",
- "completed", "failed", "cancelled"]
- created_at: datetime
- last_activity: datetime
- sequence_counter: int = 0
- config: dict = field(default_factory=dict)
-```
-
-### WebSocket Handler (FastAPI)
-
-```python
-@router.websocket("/agent/session")
-async def agent_session(websocket: WebSocket):
- await websocket.accept()
- session: AgentSession | None = None
-
- try:
- # 1. Wait for session.start message
- start_msg = await asyncio.wait_for(
- websocket.receive_json(), timeout=30.0
- )
- validate_session_start(start_msg)
-
- # 2. Resolve endpoint, create transport
- endpoint = resolve_agent_endpoint(start_msg)
- transport = create_transport(endpoint, start_msg)
-
- # 3. Create session
- session = AgentSession(
- session_id=str(uuid.uuid4()),
- websocket=websocket,
- transport=transport,
- endpoint_ref=endpoint,
- ...
- )
-
- # 4. Send session.created to frontend
- await websocket.send_json({
- "type": "session.created",
- "session_id": session.session_id,
- ...
- })
-
- # 5. Start bidirectional relay
- await asyncio.gather(
- relay_space_to_frontend(session), # Transport → WebSocket
- relay_frontend_to_space(session), # WebSocket → Transport
- )
-
- except WebSocketDisconnect:
- if session:
- await session.transport.send_to_space(cancel_message())
- finally:
- if session:
- await session.transport.close()
-```
-
-### Relay Coroutines
-
-```python
-async def relay_space_to_frontend(session: AgentSession):
- """Forward agent events from space to frontend WebSocket."""
- async for event in session.transport.receive_from_space():
- session.last_activity = datetime.utcnow()
- session.sequence_counter += 1
-
- ws_message = {
- "type": event.event_type, # agent.thinking, agent.tool_call, etc.
- "session_id": session.session_id,
- "sequence": session.sequence_counter,
- "timestamp": datetime.utcnow().isoformat(),
- "payload": event.data,
- }
-
- # Update session state based on event
- if event.event_type == "agent.request_input":
- session.state = "awaiting_input"
- elif event.event_type == "session.completed":
- session.state = "completed"
- elif event.event_type == "session.failed":
- session.state = "failed"
- else:
- session.state = "running"
-
- await session.websocket.send_json(ws_message)
-
- if session.state in ("completed", "failed"):
- break
-
-
-async def relay_frontend_to_space(session: AgentSession):
- """Forward user messages from frontend WebSocket to space."""
- while session.state not in ("completed", "failed", "cancelled"):
- try:
- msg = await asyncio.wait_for(
- session.websocket.receive_json(),
- timeout=1800.0 # 30-minute inactivity timeout
- )
- except asyncio.TimeoutError:
- session.state = "timed_out"
- await session.transport.send_to_space(cancel_message())
- break
-
- session.last_activity = datetime.utcnow()
-
- if msg["type"] == "user.cancel":
- session.state = "cancelled"
- await session.transport.send_to_space(cancel_message())
- break
- elif msg["type"] == "session.close":
- session.state = "cancelled"
- await session.transport.send_to_space(cancel_message())
- break
- elif msg["type"] in ("user.message", "user.confirm", "user.deny"):
- await session.transport.send_to_space(msg)
- elif msg["type"] == "ping":
- await session.websocket.send_json({"type": "pong"})
-```
-
----
-
-## 10. Go SDK Server — Agent Handler Framework
-
-### Handler Interface
-
-```mermaid
-classDiagram
- class AgentSession {
- +ID: string
- +InitialPrompt: string
- +Messages: []Message
- +Config: AgentConfig
- +User: UserContext
- +Context(): context.Context
- +Send(event AgentEvent): error
- +SendThinking(text string): error
- +SendToolCall(call ToolCall): error
- +SendToolResult(result ToolResult): error
- +SendMessage(content string): error
- +SendToken(token string): error
- +SendStatus(status string, detail string): error
- +RequestInput(prompt string): UserMessage, error
- +RequestConfirmation(action string): bool, error
- +Receive(): UserMessage, error
- +AddMessage(role string, content string)
- }
-
- class AgentHandler {
- <>
- +func(ctx, *AgentSession) error
- }
-
- class ToolCall {
- +ID: string
- +Name: string
- +Arguments: map[string]any
- +RequiresConfirmation: bool
- +Description: string
- }
-
- class ToolResult {
- +ToolCallID: string
- +Status: string
- +Result: any
- +Error: string
- +DurationMs: int
- }
-
- class UserMessage {
- +Type: string
- +Content: string
- +ToolCallID: string
- +Reason: string
- }
-
- AgentSession --> ToolCall
- AgentSession --> ToolResult
- AgentSession --> UserMessage
- AgentHandler --> AgentSession
-```
-
-### Registration API
-
-```go
-// Endpoint registration (alongside existing DataSource/Model)
-api.Agent("code-assistant").
- Name("Code Assistant").
- Description("An AI agent that can read, write, and debug code").
- Version("1.0.0").
- Tools([]syfthubapi.ToolDef{
- {Name: "read_file", Description: "Read a file", RequiresConfirmation: false},
- {Name: "write_file", Description: "Write a file", RequiresConfirmation: true},
- {Name: "run_command", Description: "Execute a command", RequiresConfirmation: true},
- {Name: "search_code", Description: "Search codebase", RequiresConfirmation: false},
- }).
- Handler(myAgentHandler)
-```
-
-### Example Agent Handler
-
-```go
-func myAgentHandler(ctx context.Context, session *syfthubapi.AgentSession) error {
- prompt := session.InitialPrompt
-
- for {
- // 1. Think about the prompt
- session.SendThinking("Analyzing the request: " + prompt)
-
- // 2. Decide on action
- session.SendStatus("searching", "Looking for relevant files...")
-
- // 3. Use a tool (no confirmation needed)
- session.SendToolCall(syfthubapi.ToolCall{
- ID: uuid.New().String(),
- Name: "read_file",
- Arguments: map[string]any{"path": "auth.py"},
- RequiresConfirmation: false,
- Description: "Reading auth.py to understand the code",
- })
-
- content, err := readFile("auth.py")
- if err != nil {
- return err
- }
-
- session.SendToolResult(syfthubapi.ToolResult{
- ToolCallID: toolCallID,
- Status: "success",
- Result: content,
- })
-
- // 4. Use a tool that requires confirmation
- confirmed, err := session.RequestConfirmation(
- "I want to modify auth.py to fix the token validation bug",
- )
- if err != nil {
- return err // Session cancelled or disconnected
- }
-
- if confirmed {
- // Apply fix
- session.SendStatus("writing", "Applying fix to auth.py")
- // ... write file ...
- session.SendMessage("Fixed the bug in auth.py!")
- } else {
- session.SendMessage("OK, I won't modify auth.py.")
- }
-
- // 5. Check for additional user input
- session.SendMessage("Is there anything else you'd like me to do?")
- msg, err := session.Receive()
- if err != nil {
- return err // Session ended
- }
-
- if msg.Content == "" || strings.ToLower(msg.Content) == "no" {
- break
- }
- prompt = msg.Content // Continue with new prompt
- }
-
- return nil // Session completes successfully
-}
-```
-
-### Internal Session Management (Space Side)
-
-```mermaid
-flowchart TD
- NATS_MSG[NATS Message Received] --> TYPE_CHECK{Message type?}
-
- TYPE_CHECK -->|endpoint_request| EXISTING[Existing handler
(unchanged)]
- TYPE_CHECK -->|agent_session_start| NEW_SESSION
- TYPE_CHECK -->|agent_user_message| ROUTE_SESSION
- TYPE_CHECK -->|agent_session_cancel| CANCEL_SESSION
-
- NEW_SESSION[Create AgentSession
with channels] --> DECRYPT[Decrypt payload]
- DECRYPT --> VERIFY[Verify satellite token]
- VERIFY --> LOOKUP[Lookup agent endpoint]
- LOOKUP --> SPAWN["go handler(ctx, session)"]
- SPAWN --> RELAY_LOOP["Relay goroutine:
session.sendCh → NATS
(encrypt & publish to peer_channel)"]
-
- ROUTE_SESSION[Find session by session_id] --> DECRYPT2[Decrypt payload]
- DECRYPT2 --> PUSH["Push to session.recvCh"]
-
- CANCEL_SESSION[Find session by session_id] --> CTX_CANCEL["Cancel session context
Handler goroutine returns"]
-
- style NEW_SESSION fill:#E74C3C,color:#fff
- style ROUTE_SESSION fill:#E8A838,color:#fff
- style CANCEL_SESSION fill:#95A5A6,color:#fff
-```
-
----
-
-## 11. Go SDK Client — Agent Resource
-
-```mermaid
-classDiagram
- class Client {
- +Agent(): *AgentResource
- }
-
- class AgentResource {
- +StartSession(ctx, req): *AgentSessionClient, error
- }
-
- class AgentSessionRequest {
- +Prompt: string
- +Endpoint: string
- +Config: *AgentConfig
- +Messages: []Message
- }
-
- class AgentSessionClient {
- +SessionID: string
- +SendMessage(ctx, content): error
- +Confirm(ctx, toolCallID): error
- +Deny(ctx, toolCallID, reason): error
- +Cancel(ctx): error
- +Close(): error
- +Events(): chan AgentEvent
- +Errors(): chan error
- }
-
- class AgentEvent {
- <>
- +EventType(): string
- +Sequence(): int
- }
-
- Client --> AgentResource
- AgentResource --> AgentSessionClient
- AgentSessionClient --> AgentEvent
-```
-
-Usage:
-```go
-client, _ := syfthub.NewClient(syfthub.WithAPIToken("syft_pat_..."))
-session, _ := client.Agent().StartSession(ctx, &syfthub.AgentSessionRequest{
- Prompt: "Fix the auth bug",
- Endpoint: "alice/code-assistant",
-})
-defer session.Close()
-
-for event := range session.Events() {
- switch e := event.(type) {
- case *syfthub.ToolCallEvent:
- if e.RequiresConfirmation {
- session.Confirm(ctx, e.ToolCallID)
- }
- case *syfthub.TokenEvent:
- fmt.Print(e.Content)
- case *syfthub.RequestInputEvent:
- session.SendMessage(ctx, getUserInput())
- case *syfthub.SessionCompletedEvent:
- fmt.Println("\nDone:", e.Summary)
- return
- }
-}
-```
-
----
-
-## 12. TypeScript SDK — Agent Client
-
-```typescript
-// In @syfthub/sdk
-class AgentResource {
- async startSession(options: AgentSessionOptions): Promise {
- // 1. Resolve endpoint
- // 2. Fetch tokens (satellite, transaction, peer)
- // 3. Open WebSocket to aggregator
- // 4. Send session.start
- // 5. Wait for session.created
- // 6. Return AgentSessionClient
- }
-}
-
-interface AgentSessionClient {
- readonly sessionId: string;
- readonly state: AgentSessionState;
-
- // Send messages
- sendMessage(content: string): Promise;
- confirm(toolCallId: string): Promise;
- deny(toolCallId: string, reason?: string): Promise;
- cancel(): Promise;
- close(): void;
-
- // Receive events (async iterable)
- events(): AsyncIterable;
-
- // Event listeners (alternative API)
- on(event: 'thinking', handler: (e: ThinkingEvent) => void): void;
- on(event: 'tool_call', handler: (e: ToolCallEvent) => void): void;
- on(event: 'tool_result', handler: (e: ToolResultEvent) => void): void;
- on(event: 'message', handler: (e: MessageEvent) => void): void;
- on(event: 'token', handler: (e: TokenEvent) => void): void;
- on(event: 'status', handler: (e: StatusEvent) => void): void;
- on(event: 'request_input', handler: (e: RequestInputEvent) => void): void;
- on(event: 'completed', handler: (e: CompletedEvent) => void): void;
- on(event: 'failed', handler: (e: FailedEvent) => void): void;
- on(event: 'error', handler: (e: ErrorEvent) => void): void;
-}
-
-// Usage
-const client = new SyftHubClient({ apiToken: '...' });
-const session = await client.agent.startSession({
- prompt: 'Fix the auth bug',
- endpoint: 'alice/code-assistant',
-});
-
-for await (const event of session.events()) {
- if (event.type === 'agent.token') {
- process.stdout.write(event.payload.content);
- } else if (event.type === 'agent.tool_call' && event.payload.requires_confirmation) {
- await session.confirm(event.payload.tool_call_id);
- } else if (event.type === 'agent.request_input') {
- const input = await getUserInput(event.payload.prompt);
- await session.sendMessage(input);
- }
-}
-```
-
----
-
-## 13. Frontend — Agent UI
-
-### Component Hierarchy
-
-```mermaid
-graph TD
- AP[AgentPage
/agent/:owner/:slug] --> AV[AgentView]
- AV --> AH[AgentHeader
endpoint info + session status]
- AV --> AEL[AgentEventList
scrollable event feed]
- AV --> AI[AgentInput
adaptive input field]
-
- AEL --> EC_TH[ThinkingBlock
collapsible reasoning]
- AEL --> EC_TC[ToolCallCard
tool name + args + confirm/deny]
- AEL --> EC_TR[ToolResultCard
collapsible result]
- AEL --> EC_MSG[MessageBubble
markdown message]
- AEL --> EC_TOK[StreamingMessage
accumulates tokens]
- AEL --> EC_ST[StatusBadge
progress indicator]
- AEL --> EC_RI[InputPrompt
highlighted request for input]
- AEL --> EC_UM[UserMessage
user's messages]
-
- AV -->|"uses"| UAW[useAgentWorkflow]
- UAW -->|"uses"| SDK[SyftHubClient.agent]
-
- style AV fill:#4A90D9,color:#fff
- style UAW fill:#E8A838,color:#fff
- style SDK fill:#7B68EE,color:#fff
-```
-
-### useAgentWorkflow Hook
-
-```mermaid
-stateDiagram-v2
- [*] --> idle
-
- idle --> connecting: startSession()
- connecting --> running: session.created
- connecting --> error: connection failed
-
- running --> running: agent events
- running --> awaiting_input: agent.request_input
- awaiting_input --> running: sendMessage() / confirm()
- running --> completed: session.completed
- running --> failed: session.failed
- running --> cancelled: cancel()
-
- completed --> idle: new session
- failed --> idle: new session
- cancelled --> idle: new session
- error --> idle: retry
-```
-
-### UI Mockup Flow
-
-```
-┌─────────────────────────────────────────────┐
-│ Code Assistant (alice/code-assistant) [●] │ ← AgentHeader
-├─────────────────────────────────────────────┤
-│ │
-│ You: Find and fix the bug in auth.py │ ← UserMessage
-│ │
-│ ┌─ Thinking ─────────────────────────────┐ │ ← ThinkingBlock
-│ │ Analyzing the request. I'll start by │ │ (collapsible)
-│ │ reading auth.py to understand the code.│ │
-│ └────────────────────────────────────────┘ │
-│ │
-│ ◎ Reading file... │ ← StatusBadge
-│ │
-│ ┌─ Tool: read_file ─────────────────────┐ │ ← ToolCallCard
-│ │ path: "auth.py" │ │
-│ │ ┌─ Result (success) ───────────────┐ │ │ ← ToolResultCard
-│ │ │ def validate_token(token): │ │ │ (collapsible)
-│ │ │ ...124 lines... │ │ │
-│ │ └─────────────────────────────────┘ │ │
-│ └────────────────────────────────────────┘ │
-│ │
-│ ┌─ Tool: write_file ────────────────────┐ │ ← ToolCallCard
-│ │ path: "auth.py" │ │ (confirmation)
-│ │ Fix authentication bug in auth.py │ │
-│ │ │ │
-│ │ [✓ Confirm] [✗ Deny] │ │ ← Action buttons
-│ └────────────────────────────────────────┘ │
-│ │
-├─────────────────────────────────────────────┤
-│ ⏳ Waiting for your confirmation... │ ← AgentInput
-│ ┌─────────────────────────────────── [Send]│ │ (shows prompt)
-│ └───────────────────────────────────────────│
-└─────────────────────────────────────────────┘
-```
-
----
-
-## 14. Authentication & Token Strategy
-
-### Token Lifecycle for Agent Sessions
-
-```mermaid
-sequenceDiagram
- participant FE as Frontend
- participant BE as Backend
- participant AG as Aggregator
- participant SP as Space
-
- Note over FE: Session start
- FE->>BE: GET /api/v1/token?aud=alice (60s)
- BE-->>FE: satellite_token_1
- FE->>AG: session.start {satellite_token_1}
- AG->>SP: agent_session_start {satellite_token_1}
- SP->>SP: Verify token → establish session
- Note over SP: Session verified.
Trust NATS channel for duration.
-
- Note over FE: 2 minutes later, user sends message
- FE->>BE: GET /api/v1/token?aud=alice (fresh 60s)
- BE-->>FE: satellite_token_2
- FE->>AG: user.message {satellite_token_2}
- AG->>SP: agent_user_message {satellite_token_2}
- SP->>SP: Optional re-verification
(validates fresh token)
-
- Note over SP: Agent events don't need
user tokens — they originate
FROM the space.
- SP->>AG: agent_event (no token needed)
- AG->>FE: agent.tool_call (no token needed)
-```
-
-### Token Strategy Summary
-
-| Message Direction | Token Required | When Verified |
-|-------------------|----------------|---------------|
-| `session.start` (FE→Space) | Satellite token (mandatory) | At session creation |
-| `user.message` (FE→Space) | Satellite token (optional, recommended) | If present, re-verified |
-| `user.confirm/deny` (FE→Space) | None | Trusted within session |
-| `agent_event` (Space→FE) | None | N/A (originates from space) |
-| `session.cancel` (FE→Space) | None | Trusted within session |
-
-### Peer Token for Agent Sessions
-
-The peer token (for NATS tunneling) may need a longer TTL for agent sessions:
-
-- Current default: short-lived (minutes)
-- Agent sessions: may last 30+ minutes
-- Solution: Backend accepts an optional `session_type: "agent"` parameter in `POST /api/v1/peer-token` that issues a longer-lived peer token (e.g., 2 hours)
-- Alternative: Frontend refreshes peer token periodically and aggregator handles re-subscription
-
----
-
-## 15. Comparison: Chat vs Agent Workflow
-
-```mermaid
-graph TB
- subgraph "Chat Workflow (existing)"
- C1[User sends query] --> C2[Fetch tokens]
- C2 --> C3[POST /chat/stream]
- C3 --> C4[Aggregator RAG pipeline]
- C4 --> C5[Retrieve → Rerank → Generate]
- C5 --> C6[SSE stream response]
- C6 --> C7[Display with citations]
- Note1["Single request/response
Aggregator orchestrates
SSE unidirectional
Seconds duration"]
- end
-
- subgraph "Agent Workflow (new)"
- A1[User sends prompt] --> A2[Fetch tokens]
- A2 --> A3[WS /agent/session]
- A3 --> A4[Aggregator relay]
- A4 --> A5[Space runs agent handler]
- A5 --> A6[Bidirectional events]
- A6 --> A7{Agent needs input?}
- A7 -->|Yes| A8[User provides input]
- A8 --> A5
- A7 -->|No| A9[Agent continues]
- A9 --> A5
- A5 --> A10[session.completed]
- Note2["Multi-turn session
Aggregator relays (no RAG)
WebSocket bidirectional
Minutes-hours duration"]
- end
-
- style C4 fill:#7B68EE,color:#fff
- style A4 fill:#E74C3C,color:#fff
-```
-
-### Feature Comparison
-
-| Feature | Chat (model/data_source) | Agent |
-|---------|-------------------------|-------|
-| **Transport (FE↔AG)** | SSE (POST) | WebSocket |
-| **Transport (AG↔Space)** | NATS req/resp or HTTP | NATS session or HTTP SSE+POST |
-| **Direction** | Unidirectional (server→client) | Bidirectional |
-| **Aggregator role** | RAG orchestrator | Message relay |
-| **Session duration** | Seconds (single request) | Minutes to hours |
-| **User input** | Single prompt | Multiple inputs during session |
-| **Agent autonomy** | None (dumb model) | Full (tool calls, reasoning) |
-| **Tool calls** | N/A | First-class with confirmation |
-| **Streaming** | Token events via SSE | Token events via WebSocket |
-| **Endpoint type** | `model`, `data_source` | `agent` |
-| **Handler signature** | `(query) → response` | `(session) → runs until done` |
-
----
-
-## 16. Error Handling & Edge Cases
-
-### Connection Failures
-
-```mermaid
-flowchart TD
- subgraph "WebSocket Disconnect (Frontend)"
- WS_DC[Browser disconnect] --> AG_DETECT[Aggregator detects
WebSocket close]
- AG_DETECT --> AG_CANCEL["Send agent_session_cancel
to space via NATS"]
- AG_CANCEL --> SP_CANCEL[Space cancels
handler context]
- SP_CANCEL --> CLEANUP[Session cleanup]
- end
-
- subgraph "Space Goes Offline"
- SP_OFF[Desktop/CLI offline] --> NATS_LOST[NATS messages
undelivered]
- NATS_LOST --> AG_TIMEOUT[Aggregator message
timeout (30s)]
- AG_TIMEOUT --> AG_ERR["Send agent.error to FE
{recoverable: false}"]
- AG_ERR --> FE_ERR[Frontend shows
connection lost]
- end
-
- subgraph "Agent Handler Crash"
- PANIC[Handler panics] --> RECOVER[Recovery middleware
catches panic]
- RECOVER --> SP_FAIL["Space sends session.failed
via NATS"]
- SP_FAIL --> AG_RELAY[Aggregator relays
session.failed to FE]
- AG_RELAY --> FE_FAIL[Frontend shows
agent error]
- end
-
- subgraph "Inactivity Timeout"
- NO_ACTIVITY[30min no messages] --> AG_TIMEOUT2[Aggregator timeout]
- AG_TIMEOUT2 --> AG_CANCEL2["Send cancel to space
+ close WebSocket"]
- end
-
- style WS_DC fill:#E8A838,color:#fff
- style SP_OFF fill:#E74C3C,color:#fff
- style PANIC fill:#E74C3C,color:#fff
- style NO_ACTIVITY fill:#95A5A6,color:#fff
-```
-
-### Error Codes (Agent-Specific)
-
-| Code | Meaning | Recoverable |
-|------|---------|-------------|
-| `SESSION_INIT_FAILED` | Couldn't establish session with space | No |
-| `AGENT_NOT_FOUND` | Agent endpoint slug not in registry | No |
-| `AGENT_DISABLED` | Agent endpoint exists but disabled | No |
-| `HANDLER_CRASHED` | Agent handler panicked/errored | No |
-| `HANDLER_TIMEOUT` | Handler exceeded max session duration | No |
-| `SPACE_DISCONNECTED` | Space went offline mid-session | No |
-| `TOKEN_EXPIRED` | Satellite token expired, refresh failed | Yes (send fresh token) |
-| `TOOL_EXECUTION_ERROR` | Tool call failed | Yes (agent can retry) |
-| `MESSAGE_TOO_LARGE` | Message exceeds size limit | Yes (send smaller) |
-| `SESSION_TIMEOUT` | Inactivity timeout exceeded | No |
-
-### Concurrent User Messages
-
-```mermaid
-sequenceDiagram
- participant User
- participant Agent
-
- Note over Agent: Agent is working on task 1
-
- User->>Agent: "Also do task 2"
- User->>Agent: "And task 3"
-
- Note over Agent: Messages buffered in recvCh
-
- Agent->>Agent: session.Receive() → "Also do task 2"
- Agent->>Agent: session.Receive() → "And task 3"
- Note over Agent: Agent processes in order
-```
-
-The agent's `recvCh` is a buffered channel. Messages are queued and the agent processes them when ready via `session.Receive()`.
-
----
-
-## 17. Backward Compatibility Checklist
-
-Every existing component remains unchanged:
-
-| Component | Change Required | Impact on Existing |
-|-----------|----------------|-------------------|
-| **Backend: Auth endpoints** | None | Zero — satellite/peer tokens work as-is |
-| **Backend: Chat endpoints** | None | Zero |
-| **Backend: DB models** | Add `agent` to EndpointType enum | Additive enum value |
-| **Backend: Endpoint sync** | Accept `type: "agent"` | Already handles unknown types |
-| **Aggregator: /chat/stream** | None | Zero |
-| **Aggregator: /chat** | None | Zero |
-| **Aggregator: Orchestrator** | None | Zero |
-| **Aggregator: NATS transport** | Handle new message types | Existing types unchanged |
-| **Go SDK (syfthubapi): processor** | Add `agent` case | Existing cases unchanged |
-| **Go SDK (syfthubapi): NATS transport** | Handle new message types | Existing handler unchanged |
-| **Go SDK (syfthub): Chat resource** | None | Zero |
-| **TS SDK: Chat resource** | None | Zero |
-| **Frontend: Chat components** | None | Zero |
-| **Frontend: Routes** | Add `/agent/:owner/:slug` | Existing routes unchanged |
-| **Nginx** | Add WebSocket proxy rule | Existing rules unchanged |
-| **NATS subjects** | Reuse existing subjects | No new subjects needed |
-| **Encryption** | Same protocol | Same X25519+AES-256-GCM |
-
----
-
-## 18. Implementation Phases
-
-### Phase 1: Foundation (Core Protocol)
-
-```mermaid
-gantt
- title Phase 1 — Foundation
- dateFormat X
- axisFormat %s
-
- section Backend
- Add agent to EndpointType enum :a1, 0, 1
- Accept agent in endpoint sync :a2, 0, 1
-
- section Aggregator
- WebSocket endpoint stub :b1, 0, 2
- Session Manager :b2, 1, 3
- NATS Session Transport :b3, 2, 4
-
- section Go SDK (syfthubapi)
- AgentSession struct + channels :c1, 0, 2
- AgentHandler type + registration :c2, 1, 3
- NATS agent message handling :c3, 2, 4
- Session goroutine management :c4, 3, 5
-
- section Integration
- End-to-end test (WS→NATS→Space) :d1, 4, 6
-```
-
-**Deliverables:**
-- Agent endpoint type in database
-- Working WebSocket → NATS → Space → Agent handler pipeline
-- Basic `AgentSession` with `Send()`, `Receive()`, `RequestConfirmation()`
-
-### Phase 2: Client & UI
-
-**Deliverables:**
-- Go SDK client `Agent()` resource with `StartSession()`
-- TypeScript SDK `agent.startSession()` with WebSocket client
-- Frontend `useAgentWorkflow` hook
-- Frontend `AgentView` with event rendering
-- Tool confirmation UI (confirm/deny buttons)
-
-### Phase 3: Polish & HTTP Transport
-
-**Deliverables:**
-- HTTP direct transport for non-tunneled agent endpoints
-- Token refresh during long sessions
-- Session timeout/cleanup
-- Ping/pong keepalive
-- Agent status progress bars
-- Thinking block UI (collapsible)
-
-### Phase 4: Advanced Features
-
-**Deliverables:**
-- Session persistence and resumption after disconnect
-- Session history in backend database
-- Agent-side data source access (agent calls other SyftHub endpoints)
-- File/image attachments in user messages
-- Shared tool registry
-- Multi-agent orchestration (agent spawns sub-agents)
-
----
-
-## Appendix A: Full Message Type Catalog
-
-### Client → Server
-
-| Type | Payload Fields | When Sent |
-|------|---------------|-----------|
-| `session.start` | `prompt`, `endpoint`, `satellite_token`, `peer_token?`, `peer_channel?`, `transaction_token?`, `config?`, `messages?` | Once, to initialize |
-| `user.message` | `content`, `satellite_token?` | Any time during session |
-| `user.confirm` | `tool_call_id`, `modifications?` | After `agent.tool_call` with `requires_confirmation` |
-| `user.deny` | `tool_call_id`, `reason?` | After `agent.tool_call` with `requires_confirmation` |
-| `user.cancel` | — | Any time to stop agent |
-| `session.close` | — | To terminate session |
-| `ping` | — | Keepalive |
-
-### Server → Client
-
-| Type | Payload Fields | When Sent |
-|------|---------------|-----------|
-| `session.created` | `session_id`, `endpoint` | After successful initialization |
-| `agent.thinking` | `content`, `is_streaming` | Agent reasoning |
-| `agent.tool_call` | `tool_call_id`, `tool_name`, `arguments`, `requires_confirmation`, `description?` | Agent wants to use tool |
-| `agent.tool_result` | `tool_call_id`, `status`, `result?`, `error?`, `duration_ms` | After tool execution |
-| `agent.message` | `content`, `is_complete` | Agent text output |
-| `agent.token` | `content` | Streamed response chunk |
-| `agent.status` | `status`, `detail?`, `progress?` | Progress update |
-| `agent.request_input` | `prompt`, `input_type`, `choices?`, `default?` | Agent pauses for input |
-| `agent.error` | `code`, `message`, `recoverable` | Error occurred |
-| `session.completed` | `summary?`, `usage?`, `duration_ms` | Agent finished |
-| `session.failed` | `code`, `message` | Unrecoverable error |
-| `pong` | — | Keepalive response |
-
-## Appendix B: NATS Message Type Catalog
-
-### Aggregator → Space (on `syfthub.spaces.{username}`)
-
-| Type | Purpose | Encryption |
-|------|---------|-----------|
-| `endpoint_request` | Existing: single query | Per-message ephemeral |
-| `agent_session_start` | New: initialize agent session | Per-message ephemeral |
-| `agent_user_message` | New: relay user input to agent | Per-message ephemeral |
-| `agent_session_cancel` | New: cancel agent session | Per-message ephemeral |
-
-### Space → Aggregator (on `syfthub.peer.{peer_channel}`)
-
-| Type | Purpose | Encryption |
-|------|---------|-----------|
-| `endpoint_response` | Existing: single response | Per-message ephemeral |
-| `agent_event` | New: agent event (wraps all event types) | Per-message ephemeral |
-
-## Appendix C: Configuration Reference
-
-| Component | Setting | Default | Purpose |
-|-----------|---------|---------|---------|
-| Aggregator | `AGENT_SESSION_TIMEOUT_SECONDS` | 1800 (30min) | Inactivity timeout |
-| Aggregator | `AGENT_MAX_SESSIONS` | 100 | Max concurrent sessions |
-| Aggregator | `AGENT_MAX_MESSAGE_SIZE_BYTES` | 524288 (512KB) | Max per-message size |
-| Space | `AGENT_HANDLER_TIMEOUT_SECONDS` | 3600 (1hr) | Max session duration |
-| Space | `AGENT_RECV_BUFFER_SIZE` | 100 | Buffered channel size |
-| Backend | `AGENT_PEER_TOKEN_TTL_SECONDS` | 7200 (2hr) | Peer token TTL for agent sessions |
-
-## Appendix D: File Locations (Proposed)
-
-| Component | New Files | Purpose |
-|-----------|-----------|---------|
-| **Aggregator** | `api/endpoints/agent.py` | WebSocket endpoint |
-| | `services/session_manager.py` | Session lifecycle |
-| | `services/session_transport.py` | NATS + HTTP transport abstraction |
-| | `schemas/agent.py` | Agent message schemas |
-| **Go SDK (syfthubapi)** | `agent.go` | AgentSession, AgentHandler |
-| | `agent_builder.go` | Agent endpoint builder |
-| | `session_manager.go` | Space-side session tracking |
-| **Go SDK (syfthub)** | `agent.go` | Agent client resource |
-| | `agent_session.go` | AgentSessionClient |
-| **TS SDK** | `resources/agent.ts` | Agent client resource |
-| | `types/agent.ts` | Agent event types |
-| **Frontend** | `pages/agent.tsx` | Agent page |
-| | `components/agent/agent-view.tsx` | Main agent UI |
-| | `components/agent/agent-event-list.tsx` | Event feed |
-| | `components/agent/event-cards/*.tsx` | Per-event-type cards |
-| | `hooks/use-agent-workflow.ts` | Agent workflow hook |
-| **Backend** | Migration: add `agent` to endpoint type enum | DB schema |
diff --git a/docs/llm-chat-workflow.md b/docs/llm-chat-workflow.md
deleted file mode 100644
index d6673763..00000000
--- a/docs/llm-chat-workflow.md
+++ /dev/null
@@ -1,1237 +0,0 @@
-# SyftHub LLM Chat Workflow — Complete Architecture
-
-> End-to-end trace of a chat request from the React frontend through the backend, aggregator, NATS tunnel, desktop/CLI node, and Go SDK endpoint handler.
-
----
-
-## Table of Contents
-
-1. [System Overview](#1-system-overview)
-2. [Component Relationship Map](#2-component-relationship-map)
-3. [Complete Chat Sequence (High Level)](#3-complete-chat-sequence-high-level)
-4. [Phase 1: Frontend — User Input to API Call](#4-phase-1-frontend--user-input-to-api-call)
-5. [Phase 2: Token Acquisition](#5-phase-2-token-acquisition)
-6. [Phase 3: Aggregator — RAG Orchestration](#6-phase-3-aggregator--rag-orchestration)
-7. [Phase 4: Transport Decision — HTTP vs NATS](#7-phase-4-transport-decision--http-vs-nats)
-8. [Phase 5: NATS Tunnel Protocol](#8-phase-5-nats-tunnel-protocol)
-9. [Phase 6: Desktop/CLI — Endpoint Execution](#9-phase-6-desktopcli--endpoint-execution)
-10. [Phase 7: Response Assembly & Streaming](#10-phase-7-response-assembly--streaming)
-11. [SSE Event Lifecycle](#11-sse-event-lifecycle)
-12. [Authentication & Token Architecture](#12-authentication--token-architecture)
-13. [NATS Encryption Protocol](#13-nats-encryption-protocol)
-14. [Branch Logic: Streaming vs Non-Streaming](#14-branch-logic-streaming-vs-non-streaming)
-15. [Branch Logic: Authenticated vs Guest](#15-branch-logic-authenticated-vs-guest)
-16. [Citation & Attribution Pipeline](#16-citation--attribution-pipeline)
-17. [Error Handling Across Layers](#17-error-handling-across-layers)
-18. [Data Models Reference](#18-data-models-reference)
-
----
-
-## 1. System Overview
-
-```mermaid
-graph TB
- subgraph "User's Browser"
- FE[React Frontend
port 3000]
- end
-
- subgraph "SyftHub Cloud"
- NG[Nginx Reverse Proxy
port 8080]
- BE[Backend Hub API
FastAPI · port 8000]
- AG[Aggregator Service
FastAPI · port 8001]
- NATS[NATS Broker
port 4222 / WS]
- DB[(PostgreSQL)]
- RD[(Redis)]
- end
-
- subgraph "User's Machine"
- DA[Desktop App / CLI
Go · NATS client]
- EP[Local Endpoints
Python handlers]
- end
-
- FE -->|"all requests"| NG
- NG -->|"/api/v1/*"| BE
- NG -->|"/aggregator/api/v1/*"| AG
-
- BE --> DB
- BE --> RD
-
- AG -->|"HTTP direct"| EP2[Remote Endpoints]
- AG -->|"NATS tunnel"| NATS
- NATS -->|"encrypted"| DA
- DA -->|"subprocess"| EP
-
- BE -.->|"token endpoints"| FE
- AG -.->|"SSE stream"| FE
-
- style FE fill:#4A90D9,color:#fff
- style BE fill:#E8A838,color:#fff
- style AG fill:#7B68EE,color:#fff
- style NATS fill:#27AE60,color:#fff
- style DA fill:#E74C3C,color:#fff
- style NG fill:#95A5A6,color:#fff
-```
-
-**Key insight**: The backend is NOT in the chat request path. Chat requests flow directly from frontend → aggregator. The backend only provides authentication tokens.
-
----
-
-## 2. Component Relationship Map
-
-```mermaid
-graph LR
- subgraph "Frontend Layer"
- CV[ChatView] --> UCW[useChatWorkflow]
- SI[SearchInput] --> CV
- UCW --> SDK_TS["@syfthub/sdk
TypeScript"]
- end
-
- subgraph "Token Layer (Backend)"
- SDK_TS -->|"GET /api/v1/token"| SAT[Satellite Token EP]
- SDK_TS -->|"POST /api/v1/accounting/transaction-tokens"| TXN[Transaction Token EP]
- SDK_TS -->|"POST /api/v1/peer-token"| PEER[Peer Token EP]
- end
-
- subgraph "Aggregator Layer"
- SDK_TS -->|"POST /aggregator/api/v1/chat/stream"| CHAT_EP[Chat Stream EP]
- CHAT_EP --> ORCH[Orchestrator]
- ORCH --> RET[Retrieval Service]
- ORCH --> RERANK[Reranker
ONNX]
- ORCH --> PB[Prompt Builder]
- ORCH --> GEN[Generation Service]
- end
-
- subgraph "Transport Layer"
- RET -->|"HTTP"| HTTP_T[HTTP Client]
- RET -->|"NATS"| NATS_T[NATS Transport]
- GEN -->|"HTTP"| HTTP_T
- GEN -->|"NATS"| NATS_T
- end
-
- subgraph "Endpoint Layer (Desktop/CLI)"
- NATS_T -->|"encrypted pub/sub"| NATS_H[NATS Handler]
- HTTP_T -->|"direct POST"| HTTP_H[HTTP Handler]
- NATS_H --> PROC[RequestProcessor]
- HTTP_H --> PROC
- PROC --> REG[Endpoint Registry]
- REG --> EXEC[SubprocessExecutor
Python handler]
- end
-
- style CV fill:#4A90D9,color:#fff
- style ORCH fill:#7B68EE,color:#fff
- style NATS_T fill:#27AE60,color:#fff
- style PROC fill:#E74C3C,color:#fff
-```
-
----
-
-## 3. Complete Chat Sequence (High Level)
-
-```mermaid
-sequenceDiagram
- actor User
- participant FE as Frontend
(React)
- participant BE as Backend
(FastAPI)
- participant AG as Aggregator
(FastAPI)
- participant NATS as NATS Broker
- participant Space as Desktop/CLI
(Go)
- participant EP as Endpoint
(Python)
-
- User->>FE: Type query, select model & sources
- FE->>FE: Validate input, resolve source IDs
-
- rect rgb(255, 243, 224)
- Note over FE,BE: Phase 1 — Token Acquisition
- par Satellite Tokens
- FE->>BE: GET /api/v1/token?aud={owner}
- BE-->>FE: RS256 JWT (60s TTL)
- and Transaction Tokens
- FE->>BE: POST /api/v1/accounting/transaction-tokens
- BE-->>FE: Billing tokens per owner
- and Peer Token (if tunneling)
- FE->>BE: POST /api/v1/peer-token
- BE-->>FE: peer_token + peer_channel + nats_url
- end
- end
-
- rect rgb(224, 247, 250)
- Note over FE,AG: Phase 2 — Chat Stream
- FE->>AG: POST /aggregator/api/v1/chat/stream
(prompt, model, sources, all tokens)
- AG-->>FE: SSE: retrieval_start
-
- rect rgb(232, 245, 233)
- Note over AG,EP: Phase 3 — Retrieval (parallel per source)
- par For each data source
- alt HTTP endpoint
- AG->>EP: POST {url}/query
Authorization: Bearer {satellite_token}
- EP-->>AG: documents[]
- else NATS tunnel endpoint
- AG->>AG: Encrypt payload (X25519 + AES-256-GCM)
- AG->>NATS: PUB syfthub.spaces.{owner}
- NATS->>Space: Encrypted request
- Space->>Space: Decrypt, verify token
- Space->>EP: Invoke handler
- EP-->>Space: documents[]
- Space->>Space: Encrypt response
- Space->>NATS: PUB syfthub.peer.{channel}
- NATS-->>AG: Encrypted response
- AG->>AG: Decrypt response
- end
- end
- end
-
- AG-->>FE: SSE: source_complete (per source)
- AG-->>FE: SSE: retrieval_complete
-
- AG->>AG: Rerank documents (ONNX)
- AG-->>FE: SSE: reranking_start / reranking_complete
-
- AG->>AG: Build augmented prompt
- AG-->>FE: SSE: generation_start
-
- rect rgb(243, 229, 245)
- Note over AG,EP: Phase 4 — LLM Generation
- alt HTTP model
- AG->>EP: POST {url}/query
Authorization: Bearer {satellite_token}
- EP-->>AG: LLM response
- else NATS tunnel model
- AG->>NATS: PUB syfthub.spaces.{owner}
- NATS->>Space: Encrypted request
- Space->>EP: Invoke model handler
- EP-->>Space: LLM response
- Space->>NATS: PUB syfthub.peer.{channel}
- NATS-->>AG: Encrypted response
- end
- end
-
- AG-->>FE: SSE: token (repeated, chunked)
- AG-->>FE: SSE: generation_heartbeat (periodic)
- AG->>AG: Annotate citations, compute attribution
- AG-->>FE: SSE: done (response + sources + metadata)
- end
-
- FE->>FE: Parse citations, update UI
- FE->>User: Display formatted response with sources
-```
-
----
-
-## 4. Phase 1: Frontend — User Input to API Call
-
-### Component Hierarchy
-
-```mermaid
-graph TD
- CP[ChatPage] -->|"navigation state"| CV[ChatView]
- CV -->|"renders"| SI[SearchInput]
- CV -->|"renders"| ML[MessageList]
- CV -->|"renders"| STAT[StatusIndicator]
- CV -->|"uses"| UCW[useChatWorkflow hook]
- SI -->|"@mention"| AC[Autocomplete]
- SI -->|"model picker"| MP[ModelSelector]
- ML -->|"per message"| MM[MarkdownMessage]
- MM -->|"citations"| CIT[CitationHighlight]
-
- UCW -->|"dispatches"| RED[workflowReducer]
- UCW -->|"calls"| SDK[SyftHubClient]
- RED -->|"state updates"| CV
-
- style UCW fill:#E8A838,color:#fff
- style SDK fill:#4A90D9,color:#fff
-```
-
-### useChatWorkflow State Machine
-
-```mermaid
-statediagram-v2
- [*] --> idle
- idle --> preparing: submitQuery()
- preparing --> streaming: executeWithSources()
- streaming --> streaming: SSE events
- streaming --> complete: done event
- streaming --> error: error event / abort
- preparing --> error: validation failure
- complete --> idle: new query
- error --> idle: new query
-```
-
-### Frontend Request Flow
-
-```mermaid
-flowchart TD
- A[User clicks Send] --> B{Input valid?}
- B -->|No| ERR1[Show validation error]
- B -->|Yes| C[Resolve source IDs → full paths]
- C --> D[Set phase = preparing]
-
- D --> E[Collect unique endpoint owners]
- E --> F{User authenticated?}
-
- F -->|Yes| G1[GET /api/v1/token?aud=owner
for each unique owner]
- F -->|No| G2[GET /api/v1/token/guest?aud=owner
for each unique owner]
-
- G1 --> H{Any tunneling endpoints?}
- G2 --> H
-
- H -->|Yes| I[POST /api/v1/peer-token
with target_usernames]
- H -->|No| J[Skip peer token]
-
- I --> K[Build ChatRequest body]
- J --> K
-
- F -->|Yes| L[POST /api/v1/accounting/transaction-tokens]
- L --> K
-
- K --> M[POST /aggregator/api/v1/chat/stream]
- M --> N[Set phase = streaming]
- N --> O[Process SSE events via AsyncIterable]
-
- style A fill:#4A90D9,color:#fff
- style M fill:#7B68EE,color:#fff
- style O fill:#27AE60,color:#fff
-```
-
-### ChatRequest Body (sent to aggregator)
-
-```json
-{
- "prompt": "What are the key features?",
- "model": {
- "url": "https://space.example.com",
- "slug": "gpt-model",
- "name": "GPT Model",
- "owner_username": "alice"
- },
- "data_sources": [
- {
- "url": "tunneling:bob",
- "slug": "docs-dataset",
- "name": "Docs",
- "owner_username": "bob"
- }
- ],
- "endpoint_tokens": {
- "alice": "eyJ...(satellite JWT)...",
- "bob": "eyJ...(satellite JWT)..."
- },
- "transaction_tokens": {
- "alice": "tx_token_alice",
- "bob": "tx_token_bob"
- },
- "peer_token": "peer_jwt_for_nats",
- "peer_channel": "a1b2c3d4-uuid",
- "top_k": 5,
- "max_tokens": 1024,
- "temperature": 0.7,
- "similarity_threshold": 0.5,
- "stream": true,
- "messages": [
- {"role": "user", "content": "Previous question"},
- {"role": "assistant", "content": "Previous answer"}
- ]
-}
-```
-
----
-
-## 5. Phase 2: Token Acquisition
-
-```mermaid
-sequenceDiagram
- participant SDK as TS SDK
- participant BE as Backend Hub
-
- Note over SDK: Collect unique owners from model + data_sources
-
- par Satellite Tokens (one per owner)
- SDK->>BE: GET /api/v1/token?aud=alice
- BE->>BE: Validate user active
Sign RS256 JWT (sub=user, aud=alice, 60s)
- BE-->>SDK: {target_token, expires_in: 60}
-
- SDK->>BE: GET /api/v1/token?aud=bob
- BE-->>SDK: {target_token, expires_in: 60}
- and Transaction Tokens (batch)
- SDK->>BE: POST /api/v1/accounting/transaction-tokens
{owner_usernames: ["alice", "bob"]}
- BE->>BE: For each owner:
1. Look up owner email
2. POST to accounting service
- BE-->>SDK: {tokens: {"alice": "tx1", "bob": "tx2"}, errors: {}}
- and Peer Token (if tunneling detected)
- SDK->>BE: POST /api/v1/peer-token
{target_usernames: ["bob"]}
- BE->>BE: Generate peer channel UUID
Store in Redis with TTL
- BE-->>SDK: {peer_token, peer_channel, expires_in, nats_url}
- end
-
- Note over SDK: All tokens collected → build ChatRequest
-```
-
-### Token Types Comparison
-
-| Token | Endpoint | Signing | TTL | Purpose | Auth Required |
-|-------|----------|---------|-----|---------|---------------|
-| **Hub Access** | Login | HS256 | 30min | Authenticate with backend | N/A (login) |
-| **Satellite** | `GET /api/v1/token` | RS256 | 60s | Authorize endpoint access | Yes (or guest variant) |
-| **Transaction** | `POST /api/v1/accounting/transaction-tokens` | External | Varies | Billing authorization | Yes |
-| **Peer** | `POST /api/v1/peer-token` | Internal | Short | NATS P2P communication | Yes (or guest variant) |
-
----
-
-## 6. Phase 3: Aggregator — RAG Orchestration
-
-### Orchestrator Pipeline
-
-```mermaid
-flowchart TD
- REQ[ChatRequest received] --> RESOLVE[Resolve EndpointRefs
→ ResolvedEndpoints]
- RESOLVE --> CHECK_TUNNEL{Any tunneling
endpoints?}
-
- CHECK_TUNNEL -->|Yes, no peer_token| GEN_PEER[Generate ephemeral
peer_channel UUID]
- CHECK_TUNNEL -->|Yes, has peer_token| RETRIEVE
- CHECK_TUNNEL -->|No tunneling| RETRIEVE
- GEN_PEER --> RETRIEVE
-
- RETRIEVE[Parallel Retrieval
asyncio.gather per source] --> |SSE: retrieval_start| R_START
- R_START --> R_EACH
-
- subgraph "Per Data Source (parallel)"
- R_EACH[Query data source] --> R_TYPE{Transport?}
- R_TYPE -->|HTTP| R_HTTP[POST url/query
Bearer satellite_token]
- R_TYPE -->|NATS| R_NATS[Encrypt & publish
to syfthub.spaces.owner]
- R_HTTP --> R_DONE[RetrievalResult]
- R_NATS --> R_DONE
- end
-
- R_DONE --> |SSE: source_complete| S_COMPLETE
- S_COMPLETE --> ALL_DONE{All sources
complete?}
- ALL_DONE -->|No| R_EACH
- ALL_DONE -->|Yes| |SSE: retrieval_complete| RERANK_CHECK
-
- RERANK_CHECK{Documents > 0?}
- RERANK_CHECK -->|No| BUILD_PROMPT
- RERANK_CHECK -->|Yes| RERANK
-
- RERANK[Rerank via ONNX
CENTRAL_REEMBEDDING] --> |SSE: reranking_start/complete| BUILD_PROMPT
-
- BUILD_PROMPT[PromptBuilder.build
system + context + history + query] --> GEN
-
- GEN[Generation Service] --> |SSE: generation_start| GEN_TYPE{Transport?}
- GEN_TYPE -->|HTTP| GEN_HTTP[POST model_url/query
Bearer satellite_token]
- GEN_TYPE -->|NATS| GEN_NATS[Encrypt & publish
to syfthub.spaces.owner]
-
- GEN_HTTP --> STREAM_CHECK{Streaming enabled?}
- GEN_NATS --> STREAM_CHECK
-
- STREAM_CHECK -->|Yes| TOKENS[Yield token events
SSE: token]
- STREAM_CHECK -->|No| HEARTBEAT[Periodic heartbeat
SSE: generation_heartbeat]
-
- TOKENS --> ANNOTATE
- HEARTBEAT --> ANNOTATE
-
- ANNOTATE[Annotate citations
cite:N → cite:N-start:end] --> ATTRIB[Compute profit_share
per source]
- ATTRIB --> |SSE: done| DONE[Final response + metadata]
-
- style REQ fill:#7B68EE,color:#fff
- style RETRIEVE fill:#27AE60,color:#fff
- style RERANK fill:#E8A838,color:#fff
- style GEN fill:#E74C3C,color:#fff
- style DONE fill:#4A90D9,color:#fff
-```
-
-### Retrieval Service Detail
-
-```mermaid
-flowchart LR
- subgraph "retrieve() — parallel mode"
- Q[query + data_sources] --> TASKS["asyncio.gather(*tasks)"]
- TASKS --> DS1[Source 1: POST url/query]
- TASKS --> DS2[Source 2: POST url/query]
- TASKS --> DS3[Source 3: NATS tunnel]
- DS1 --> MERGE[Merge all RetrievalResults]
- DS2 --> MERGE
- DS3 --> MERGE
- end
-
- subgraph "retrieve_streaming() — first-completed mode"
- Q2[query + data_sources] --> TASKS2["asyncio.wait(FIRST_COMPLETED)"]
- TASKS2 --> |"yields as each completes"| YIELD[AsyncGenerator yields
RetrievalResult per source]
- end
-```
-
-### Prompt Builder — Context Assembly
-
-```mermaid
-flowchart TD
- PB[PromptBuilder.build] --> HAS_CTX{Has retrieved
documents?}
-
- HAS_CTX -->|No| NO_CTX[System prompt:
"You are a helpful assistant"]
- HAS_CTX -->|Yes| HAS_DICT{context_dict
provided?}
-
- HAS_DICT -->|Yes| CITE_PATH["Citation path:
System prompt includes numbered docs
[1] Title: content...
Instruct model to use [cite:N]"]
- HAS_DICT -->|No| XML_PATH["XML path:
System prompt wraps docs in XML
<context><document>...</document></context>"]
-
- NO_CTX --> ADD_HIST
- CITE_PATH --> ADD_HIST
- XML_PATH --> ADD_HIST
-
- ADD_HIST{Chat history?}
- ADD_HIST -->|Yes| HIST[Prepend history messages
user/assistant alternating]
- ADD_HIST -->|No| FINAL
-
- HIST --> FINAL[Final messages array:
system + history + user query]
-```
-
----
-
-## 7. Phase 4: Transport Decision — HTTP vs NATS
-
-```mermaid
-flowchart TD
- EP_URL[endpoint.url] --> CHECK{URL starts with
'tunneling:' ?}
-
- CHECK -->|No → HTTP| HTTP_PATH
- CHECK -->|Yes → NATS| NATS_PATH
-
- subgraph "HTTP Direct Path"
- HTTP_PATH[Build target URL] --> HTTP_REQ["POST {url}/api/v1/endpoints/{slug}/query"]
- HTTP_REQ --> HTTP_HEADERS["Headers:
Authorization: Bearer {satellite_token}
Content-Type: application/json
X-Transaction-Token: {txn_token}"]
- HTTP_HEADERS --> HTTP_RESP[Parse JSON response]
- HTTP_RESP --> HTTP_RETRY{Status 5xx?}
- HTTP_RETRY -->|Yes, attempts < 2| HTTP_REQ
- HTTP_RETRY -->|No| HTTP_RESULT[Return result]
- end
-
- subgraph "NATS Tunnel Path"
- NATS_PATH[Extract username from URL
tunneling:alice → alice] --> FETCH_KEY["Fetch space's X25519 public key
GET /api/v1/nats/encryption-key/{username}
(cached 300s)"]
- FETCH_KEY --> ENCRYPT[Generate ephemeral keypair
ECDH + HKDF → AES key
AES-256-GCM encrypt payload]
- ENCRYPT --> PUB["Publish to NATS
subject: syfthub.spaces.{username}"]
- PUB --> SUB["Subscribe to reply
subject: syfthub.peer.{peer_channel}"]
- SUB --> WAIT[Wait for response
timeout: 30s data / 120s model]
- WAIT --> DECRYPT[Decrypt response
ECDH with retained ephemeral key]
- DECRYPT --> NATS_RESULT[Return result]
- end
-
- style CHECK fill:#E8A838,color:#fff
- style HTTP_PATH fill:#4A90D9,color:#fff
- style NATS_PATH fill:#27AE60,color:#fff
-```
-
----
-
-## 8. Phase 5: NATS Tunnel Protocol
-
-### Message Flow
-
-```mermaid
-sequenceDiagram
- participant AG as Aggregator
- participant HUB as Hub Backend
- participant NATS as NATS Broker
- participant SP as Space (Desktop/CLI)
-
- Note over AG: Need to call endpoint owned by "alice"
URL = "tunneling:alice"
-
- AG->>HUB: GET /api/v1/nats/encryption-key/alice
- HUB-->>AG: {encryption_public_key: "base64url..."}
- Note over AG: Cache key for 300s
-
- AG->>AG: Generate ephemeral X25519 keypair
(eph_priv, eph_pub)
- AG->>AG: shared = ECDH(eph_priv, alice_pub)
- AG->>AG: aes_key = HKDF(shared, info="syfthub-tunnel-request-v1")
- AG->>AG: ciphertext = AES-256-GCM(aes_key, nonce, payload, AAD=correlation_id)
-
- AG->>NATS: PUB syfthub.spaces.alice
{protocol, correlation_id, reply_to,
encryption_info, encrypted_payload}
-
- NATS->>SP: Deliver message
-
- SP->>SP: shared = ECDH(alice_priv, eph_pub)
- SP->>SP: aes_key = HKDF(shared, info="syfthub-tunnel-request-v1")
- SP->>SP: payload = AES-256-GCM.Open(aes_key, nonce, ciphertext, AAD=correlation_id)
-
- SP->>SP: Verify satellite_token
Look up endpoint by slug
Invoke handler
-
- SP->>SP: Generate fresh ephemeral keypair
(resp_eph_priv, resp_eph_pub)
- SP->>SP: shared = ECDH(resp_eph_priv, req_eph_pub)
- SP->>SP: aes_key = HKDF(shared, info="syfthub-tunnel-response-v1")
- SP->>SP: ciphertext = AES-256-GCM(aes_key, nonce, response, AAD=correlation_id)
-
- SP->>NATS: PUB syfthub.peer.{peer_channel}
{protocol, correlation_id, status,
encryption_info, encrypted_payload, timing}
-
- NATS-->>AG: Deliver response
-
- AG->>AG: shared = ECDH(eph_priv, resp_eph_pub)
- AG->>AG: aes_key = HKDF(shared, info="syfthub-tunnel-response-v1")
- AG->>AG: response = AES-256-GCM.Open(...)
-```
-
-### NATS Subject Naming
-
-```mermaid
-graph LR
- subgraph "Subject Namespace"
- S1["syfthub.spaces.{username}"]
- S2["syfthub.peer.{peer_channel}"]
- end
-
- AG[Aggregator] -->|"publishes request"| S1
- SP[Space] -->|"subscribes"| S1
- SP -->|"publishes response"| S2
- AG -->|"subscribes"| S2
-
- style S1 fill:#27AE60,color:#fff
- style S2 fill:#E8A838,color:#fff
-```
-
-### Wire Message Format
-
-```mermaid
-classDiagram
- class TunnelRequest {
- +protocol: "syfthub-tunnel/v1"
- +type: "endpoint_request"
- +correlation_id: UUID
- +reply_to: peer_channel
- +endpoint: EndpointInfo
- +satellite_token: string
- +timeout_ms: int
- +encryption_info: EncryptionInfo
- +encrypted_payload: base64url
- }
-
- class TunnelResponse {
- +protocol: "syfthub-tunnel/v1"
- +type: "endpoint_response"
- +correlation_id: UUID
- +status: "success" | "error"
- +endpoint_slug: string
- +encryption_info: EncryptionInfo
- +encrypted_payload: base64url
- +error: ErrorInfo?
- +timing: TimingInfo
- }
-
- class EncryptionInfo {
- +algorithm: "X25519-ECDH-AES-256-GCM"
- +ephemeral_public_key: base64url
- +nonce: base64url (12 bytes)
- }
-
- class EndpointInfo {
- +slug: string
- +type: "model" | "data_source"
- }
-
- class TimingInfo {
- +received_at: ISO8601
- +processed_at: ISO8601
- +duration_ms: int
- }
-
- TunnelRequest --> EncryptionInfo
- TunnelRequest --> EndpointInfo
- TunnelResponse --> EncryptionInfo
- TunnelResponse --> TimingInfo
-```
-
----
-
-## 9. Phase 6: Desktop/CLI — Endpoint Execution
-
-### Space Startup Flow
-
-```mermaid
-sequenceDiagram
- participant App as Desktop App
- participant FS as Filesystem
- participant HUB as Hub Backend
- participant NATS as NATS Broker
-
- App->>FS: Load settings.json
(~/.config/syfthub/)
- App->>HUB: Authenticate with API key
- HUB-->>App: username, user info
-
- App->>App: Set SPACE_URL = tunneling:{username}
-
- alt Key file exists
- App->>FS: Load X25519 keypair
(~/.config/syfthub/tunnel_key)
- else First run
- App->>App: Generate X25519 keypair
- App->>FS: Save key atomically
(O_CREATE|O_EXCL, mode 0600)
- end
-
- App->>HUB: PUT /api/v1/nats/encryption-key
{encryption_public_key: base64url}
-
- App->>FS: Scan endpoints directory
(README.md frontmatter + runner.py)
- App->>App: Build endpoint registry
-
- App->>HUB: POST /api/v1/endpoints/sync
(register all endpoints with hub)
-
- App->>HUB: GET /api/v1/nats/credentials
- HUB-->>App: {nats_url, auth_token}
-
- App->>NATS: Connect(url, token)
Subscribe("syfthub.spaces.{username}")
- Note over App,NATS: Ready to receive requests
-```
-
-### Request Processing Pipeline
-
-```mermaid
-flowchart TD
- MSG[NATS Message Received] --> PARSE[Parse JSON → TunnelRequest]
- PARSE --> ENC_CHECK{encryption_info &
encrypted_payload present?}
-
- ENC_CHECK -->|No| REJECT[Reject — no plaintext allowed]
- ENC_CHECK -->|Yes| DECRYPT[Decrypt payload
X25519 ECDH + AES-256-GCM]
-
- DECRYPT --> VERIFY[Verify satellite_token
POST /api/v1/verify]
- VERIFY --> LOOKUP[Registry.Get(slug)]
- LOOKUP --> ENABLED{Endpoint enabled?}
-
- ENABLED -->|No| ERR_DISABLED[Error: ENDPOINT_DISABLED]
- ENABLED -->|Yes| TYPE_CHECK{Endpoint type?}
-
- TYPE_CHECK -->|data_source| DS_PARSE[Parse DataSourceQueryRequest
Extract query from messages]
- TYPE_CHECK -->|model| M_PARSE[Parse ModelQueryRequest
Extract messages array]
-
- DS_PARSE --> INVOKE
- M_PARSE --> INVOKE
-
- INVOKE{File-based endpoint?}
- INVOKE -->|Yes| SUBPROCESS[SubprocessExecutor.Execute
Python handler via stdin/stdout]
- INVOKE -->|No| IN_MEMORY[Call registered Go handler]
-
- SUBPROCESS --> RESPONSE[Build TunnelResponse]
- IN_MEMORY --> RESPONSE
-
- RESPONSE --> ENC_RESP[Encrypt response
Fresh ephemeral keypair]
- ENC_RESP --> PUBLISH["Publish to NATS
syfthub.peer.{peer_channel}"]
-
- style MSG fill:#27AE60,color:#fff
- style DECRYPT fill:#E8A838,color:#fff
- style VERIFY fill:#E74C3C,color:#fff
- style PUBLISH fill:#4A90D9,color:#fff
-```
-
----
-
-## 10. Phase 7: Response Assembly & Streaming
-
-```mermaid
-flowchart TD
- subgraph "Aggregator Response Assembly"
- GEN_RESULT[Generation result
(raw text with cite:N tags)] --> ANNOTATE["Annotate citations
[cite:N] → [cite:N-start:end]"]
- ANNOTATE --> ATTRIB[Compute profit_share
per source using attribution lib]
- ATTRIB --> BUILD_RESP[Build final response
+ sources + metadata + usage]
- BUILD_RESP --> SSE_DONE["Emit SSE: done"]
- end
-
- subgraph "Frontend Response Processing"
- SSE_DONE --> PARSE_EVT[Parse done event]
- PARSE_EVT --> UPDATE_STATE[Dispatch SET_COMPLETE
phase = complete]
- UPDATE_STATE --> ON_COMPLETE[onComplete callback]
- ON_COMPLETE --> ADD_MSG[Add assistant message
to message history]
- ADD_MSG --> PARSE_CIT[parseCitations
extract cite:N-start:end markers]
- PARSE_CIT --> BUILD_MD[buildCitedMarkdown
inject HTML mark + sup badges]
- BUILD_MD --> RENDER[Render MarkdownMessage
with highlighted citations]
- end
-
- style GEN_RESULT fill:#7B68EE,color:#fff
- style RENDER fill:#4A90D9,color:#fff
-```
-
----
-
-## 11. SSE Event Lifecycle
-
-```mermaid
-sequenceDiagram
- participant AG as Aggregator
- participant FE as Frontend
-
- Note over AG,FE: SSE Stream (text/event-stream)
-
- AG->>FE: event: retrieval_start
data: {"sources": 3}
- Note over FE: Initialize progress bar
-
- AG->>FE: event: source_complete
data: {"path": "alice/docs", "status": "success", "documents": 12}
- AG->>FE: event: source_complete
data: {"path": "bob/wiki", "status": "success", "documents": 8}
- AG->>FE: event: source_complete
data: {"path": "carol/faq", "status": "error", "documents": 0}
- Note over FE: Update per-source status
-
- AG->>FE: event: retrieval_complete
data: {"total_documents": 20, "time_ms": 1523}
- Note over FE: Mark retrieval phase done
-
- AG->>FE: event: reranking_start
data: {"documents": 20}
- AG->>FE: event: reranking_complete
data: {"documents": 5, "time_ms": 342}
- Note over FE: Show reranked count
-
- AG->>FE: event: generation_start
data: {}
- Note over FE: Show "Generating..."
-
- loop Every token chunk
- AG->>FE: event: token
data: {"content": "The key "}
- AG->>FE: event: token
data: {"content": "features are "}
- AG->>FE: event: token
data: {"content": "[cite:1] ..."}
- end
-
- loop Every ~500ms (if non-streaming model)
- AG->>FE: event: generation_heartbeat
data: {"elapsed_ms": 2500}
- end
-
- AG->>FE: event: done
data: {"response": "...", "sources": {...},
"metadata": {...}, "usage": {...}, "profit_share": {...}}
- Note over FE: Display final response with citations
-```
-
-### SSE Event Types Reference
-
-| Event | Payload | Phase | Purpose |
-|-------|---------|-------|---------|
-| `retrieval_start` | `{sources: N}` | Retrieval | N data sources will be queried |
-| `source_complete` | `{path, status, documents}` | Retrieval | One source finished |
-| `retrieval_complete` | `{total_documents, time_ms}` | Retrieval | All sources done |
-| `reranking_start` | `{documents: N}` | Reranking | Starting to rerank N docs |
-| `reranking_complete` | `{documents: N, time_ms}` | Reranking | Top N selected after rerank |
-| `generation_start` | `{}` | Generation | LLM generation beginning |
-| `generation_heartbeat` | `{elapsed_ms}` | Generation | Periodic liveness signal |
-| `token` | `{content: "..."}` | Generation | Streamed response chunk |
-| `done` | `{response, sources, metadata, usage, profit_share}` | Complete | Final result |
-| `error` | `{message: "..."}` | Error | Pipeline failure |
-
----
-
-## 12. Authentication & Token Architecture
-
-```mermaid
-graph TB
- subgraph "Token Hierarchy"
- HUB_TOKEN["Hub Access Token
HS256 · 30min
Authenticates user with backend"]
- SAT_TOKEN["Satellite Token
RS256 · 60s
Authorizes endpoint access"]
- TXN_TOKEN["Transaction Token
External · varies
Billing authorization"]
- PEER_TOKEN["Peer Token
Internal · short
NATS P2P auth"]
- GUEST_SAT["Guest Satellite Token
RS256 · 60s
sub=guest, no auth needed"]
- end
-
- USER[User Login] -->|"POST /api/v1/auth/login"| HUB_TOKEN
- HUB_TOKEN -->|"GET /api/v1/token?aud=X"| SAT_TOKEN
- HUB_TOKEN -->|"POST /api/v1/accounting/transaction-tokens"| TXN_TOKEN
- HUB_TOKEN -->|"POST /api/v1/peer-token"| PEER_TOKEN
-
- ANON[Anonymous User] -->|"GET /api/v1/token/guest?aud=X"| GUEST_SAT
- ANON -->|"POST /api/v1/nats/guest-peer-token"| PEER_TOKEN
-
- SAT_TOKEN -->|"in ChatRequest.endpoint_tokens"| AG[Aggregator]
- TXN_TOKEN -->|"in ChatRequest.transaction_tokens"| AG
- PEER_TOKEN -->|"in ChatRequest.peer_token"| AG
- GUEST_SAT -->|"in ChatRequest.endpoint_tokens"| AG
-
- AG -->|"Authorization: Bearer {sat_token}"| EP[Endpoint]
- AG -->|"X-Transaction-Token: {txn}"| EP
-
- style HUB_TOKEN fill:#E8A838,color:#fff
- style SAT_TOKEN fill:#4A90D9,color:#fff
- style TXN_TOKEN fill:#E74C3C,color:#fff
- style PEER_TOKEN fill:#27AE60,color:#fff
-```
-
-### Satellite Token Claims
-
-```mermaid
-classDiagram
- class SatelliteToken {
- +sub: user_id (or "guest")
- +aud: target_owner_username
- +iss: hub_url
- +exp: now + 60s
- +role: "admin" | "user" | "guest"
- +iat: now
- +kid: key_id
- ---
- Signing: RS256
- Verification: JWKS at /.well-known/jwks.json
- }
-```
-
----
-
-## 13. NATS Encryption Protocol
-
-```mermaid
-graph TB
- subgraph "Request Encryption (Aggregator → Space)"
- A1[Generate ephemeral keypair
eph_priv, eph_pub] --> A2["ECDH(eph_priv, space_longterm_pub)
→ shared_secret"]
- A2 --> A3["HKDF-SHA256(shared_secret)
info='syfthub-tunnel-request-v1'
→ 32-byte AES key"]
- A3 --> A4["AES-256-GCM.Seal(key, nonce, payload)
AAD = correlation_id"]
- A4 --> A5["Send: eph_pub + nonce + ciphertext"]
- end
-
- subgraph "Request Decryption (Space)"
- B1["Receive: eph_pub + nonce + ciphertext"] --> B2["ECDH(space_longterm_priv, eph_pub)
→ same shared_secret"]
- B2 --> B3["HKDF-SHA256(shared_secret)
info='syfthub-tunnel-request-v1'
→ same AES key"]
- B3 --> B4["AES-256-GCM.Open(key, nonce, ciphertext)
AAD = correlation_id"]
- end
-
- subgraph "Response Encryption (Space → Aggregator)"
- C1["Generate fresh ephemeral keypair
resp_eph_priv, resp_eph_pub"] --> C2["ECDH(resp_eph_priv, request_eph_pub)
→ response_shared_secret"]
- C2 --> C3["HKDF-SHA256(response_shared_secret)
info='syfthub-tunnel-response-v1'
← different info!"]
- C3 --> C4["AES-256-GCM.Seal(key, nonce, response)
AAD = correlation_id"]
- C4 --> C5["Send: resp_eph_pub + nonce + ciphertext"]
- end
-
- subgraph "Response Decryption (Aggregator)"
- D1["Receive: resp_eph_pub + nonce + ciphertext"] --> D2["ECDH(request_eph_priv, resp_eph_pub)
→ same response_shared_secret"]
- D2 --> D3["HKDF-SHA256(response_shared_secret)
info='syfthub-tunnel-response-v1'
→ same AES key"]
- D3 --> D4["AES-256-GCM.Open(key, nonce, ciphertext)
AAD = correlation_id"]
- end
-
- A5 -.->|"NATS"| B1
- C5 -.->|"NATS"| D1
-
- style A4 fill:#E74C3C,color:#fff
- style B4 fill:#27AE60,color:#fff
- style C4 fill:#E74C3C,color:#fff
- style D4 fill:#27AE60,color:#fff
-```
-
-### Key Properties
-
-| Property | Value |
-|----------|-------|
-| **Key Agreement** | X25519 ECDH |
-| **KDF** | HKDF-SHA256, no salt |
-| **Symmetric Cipher** | AES-256-GCM (12-byte nonce) |
-| **AAD** | correlation_id (UUID) |
-| **Domain Separation** | Request: `syfthub-tunnel-request-v1`, Response: `syfthub-tunnel-response-v1` |
-| **Forward Secrecy** | Yes — ephemeral keys per request and per response |
-| **Key Persistence** | Space long-term key on disk (mode 0600), aggregator ephemeral per-request |
-
----
-
-## 14. Branch Logic: Streaming vs Non-Streaming
-
-```mermaid
-flowchart TD
- REQ[Chat Request] --> STREAM{request.stream?}
-
- STREAM -->|true| STREAM_PATH
- STREAM -->|false| SYNC_PATH
-
- subgraph "Streaming Path (POST /chat/stream)"
- STREAM_PATH[StreamingResponse
media_type=text/event-stream] --> S_RET[retrieve_streaming
asyncio.wait FIRST_COMPLETED]
- S_RET --> S_YIELD[Yield source_complete
as each source finishes]
- S_YIELD --> S_RERANK[Rerank if documents > 0]
- S_RERANK --> S_GEN_CHECK{model_streaming_enabled?}
-
- S_GEN_CHECK -->|true| S_GEN_STREAM["generate_stream()
yield token events"]
- S_GEN_CHECK -->|false| S_GEN_SYNC["generate() as asyncio.Task
yield heartbeat every 500ms
until task completes"]
-
- S_GEN_STREAM --> S_DONE[Yield done event]
- S_GEN_SYNC --> S_DONE
- end
-
- subgraph "Non-Streaming Path (POST /chat)"
- SYNC_PATH[JSON Response] --> NS_RET["retrieve()
asyncio.gather all sources"]
- NS_RET --> NS_RERANK[Rerank]
- NS_RERANK --> NS_GEN["generate()
single call, await result"]
- NS_GEN --> NS_RESP[Return ChatResponse JSON]
- end
-
- style STREAM fill:#E8A838,color:#fff
- style S_GEN_STREAM fill:#27AE60,color:#fff
- style S_GEN_SYNC fill:#7B68EE,color:#fff
-```
-
----
-
-## 15. Branch Logic: Authenticated vs Guest
-
-```mermaid
-flowchart TD
- USER_CHECK{User authenticated?}
-
- USER_CHECK -->|Yes| AUTH_PATH
- USER_CHECK -->|No| GUEST_PATH
-
- subgraph "Authenticated Path"
- AUTH_PATH[Has hub access token] --> AUTH_SAT["GET /api/v1/token?aud={owner}
per unique owner"]
- AUTH_SAT --> AUTH_TXN["POST /api/v1/accounting/transaction-tokens
{owner_usernames: [...]}"]
- AUTH_TXN --> AUTH_PEER{Tunneling endpoints?}
- AUTH_PEER -->|Yes| AUTH_PEER_TOK["POST /api/v1/peer-token
{target_usernames: [...]}"]
- AUTH_PEER -->|No| AUTH_BUILD[Build request]
- AUTH_PEER_TOK --> AUTH_BUILD
- end
-
- subgraph "Guest Path"
- GUEST_PATH[No authentication] --> GUEST_SAT["GET /api/v1/token/guest?aud={owner}
(IP rate-limited)"]
- GUEST_SAT --> GUEST_TXN[No transaction tokens
guests cannot be billed]
- GUEST_TXN --> GUEST_PEER{Tunneling endpoints?}
- GUEST_PEER -->|Yes| GUEST_PEER_TOK["POST /api/v1/nats/guest-peer-token
(IP rate-limited)"]
- GUEST_PEER -->|No| GUEST_BUILD[Build request]
- GUEST_PEER_TOK --> GUEST_BUILD
- end
-
- AUTH_BUILD --> SEND[Send to Aggregator]
- GUEST_BUILD --> SEND
-
- subgraph "Endpoint-Side Verification"
- SEND --> EP_VERIFY{Space verifies token}
- EP_VERIFY --> ROLE_CHECK{token.role?}
- ROLE_CHECK -->|"user/admin"| FULL_ACCESS[Full access
policies may apply]
- ROLE_CHECK -->|"guest"| GUEST_CHECK{Endpoint allows
guest access?}
- GUEST_CHECK -->|Yes| LIMITED[Limited access
no billing]
- GUEST_CHECK -->|No| DENIED[403 POLICY_DENIED]
- end
-
- style AUTH_PATH fill:#4A90D9,color:#fff
- style GUEST_PATH fill:#95A5A6,color:#fff
- style DENIED fill:#E74C3C,color:#fff
-```
-
----
-
-## 16. Citation & Attribution Pipeline
-
-```mermaid
-flowchart TD
- subgraph "1. Prompt Construction"
- DOCS[Retrieved documents] --> NUMBER["Number documents:
[1] Title: content...
[2] Title: content..."]
- NUMBER --> SYSTEM["System prompt instructs:
'Use [cite:N] to reference sources'"]
- SYSTEM --> LLM[Send to LLM]
- end
-
- subgraph "2. LLM Generation"
- LLM --> RAW["Raw response:
'The key feature [cite:1] is
performance [cite:2]...'"]
- end
-
- subgraph "3. Aggregator Annotation"
- RAW --> ANNOTATE["_annotate_cite_positions():
[cite:1] → [cite:1-0:15]
[cite:2] → [cite:2-20:42]
(adds character positions)"]
- ANNOTATE --> ATTRIB["_compute_attribution():
Count cite references per source
→ profit_share: {owner/slug: 0.6, ...}"]
- end
-
- subgraph "4. Frontend Rendering"
- ATTRIB --> FE_PARSE["parseCitations():
Extract [cite:N-start:end] markers"]
- FE_PARSE --> FE_BUILD["buildCitedMarkdown():
Inject HTML highlights
<mark> + <sup> badges"]
- FE_BUILD --> RENDER["Render with click-to-source
highlight + source panel"]
- end
-
- style LLM fill:#7B68EE,color:#fff
- style ANNOTATE fill:#E8A838,color:#fff
- style RENDER fill:#4A90D9,color:#fff
-```
-
----
-
-## 17. Error Handling Across Layers
-
-```mermaid
-flowchart TD
- subgraph "Frontend Errors"
- FE1[Validation Error] --> FE_SHOW[Show inline error]
- FE2[AuthenticationError] --> FE_REAUTH[Prompt re-login]
- FE3[AggregatorError] --> FE_MSG[Show error message]
- FE4[AbortError] --> FE_CANCEL[Silently cancel]
- FE5[Network Error] --> FE_RETRY[Show connection error]
- end
-
- subgraph "Aggregator Errors"
- AG1[Retrieval timeout] --> AG_PARTIAL["Per-source error
SSE: source_complete status=timeout
Continue with other sources"]
- AG2[Retrieval error] --> AG_PARTIAL
- AG3[Reranking failure] --> AG_FALLBACK["Silent fallback
Use raw score sort"]
- AG4[Generation 5xx] --> AG_RETRY["Retry up to 2x"]
- AG5[Generation 403] --> AG_FAIL["SSE: error event
{message: 'Access denied'}"]
- AG6[NATS timeout] --> AG_NATS_ERR["NATSTransportError
→ SSE: error event"]
- end
-
- subgraph "Space Errors"
- SP1[Decryption failure] --> SP_ERR1["Error: DECRYPTION_FAILED
HTTP 400"]
- SP2[Token invalid] --> SP_ERR2["Error: AUTH_FAILED
HTTP 401"]
- SP3[Endpoint not found] --> SP_ERR3["Error: ENDPOINT_NOT_FOUND
HTTP 404"]
- SP4[Policy denied] --> SP_ERR4["Error: POLICY_DENIED
HTTP 403"]
- SP5[Handler crash] --> SP_ERR5["Error: EXECUTION_FAILED
HTTP 500"]
- SP6[Timeout] --> SP_ERR6["Error: TIMEOUT
HTTP 504"]
- end
-
- AG_PARTIAL -.-> FE3
- AG_FAIL -.-> FE3
- AG_NATS_ERR -.-> FE3
- SP_ERR1 -.-> AG6
- SP_ERR2 -.-> AG5
-```
-
-### Error Code Reference (Space → Aggregator)
-
-| Code | HTTP Status | Meaning |
-|------|------------|---------|
-| `AUTH_FAILED` | 401 | Satellite token invalid/expired |
-| `ENDPOINT_NOT_FOUND` | 404 | Slug not in registry |
-| `POLICY_DENIED` | 403 | Endpoint policy rejected request |
-| `EXECUTION_FAILED` | 500 | Handler threw an error |
-| `TIMEOUT` | 504 | Handler exceeded timeout |
-| `INVALID_REQUEST` | 400 | Malformed request payload |
-| `ENDPOINT_DISABLED` | 503 | Endpoint exists but disabled |
-| `RATE_LIMIT_EXCEEDED` | 429 | Too many requests |
-| `DECRYPTION_FAILED` | 400 | NATS payload decrypt error |
-| `INTERNAL_ERROR` | 500 | Unexpected server error |
-
----
-
-## 18. Data Models Reference
-
-### Request/Response Flow
-
-```mermaid
-classDiagram
- class ChatRequest {
- +prompt: string
- +model: EndpointRef
- +data_sources: EndpointRef[]
- +endpoint_tokens: map~string,string~
- +transaction_tokens: map~string,string~
- +peer_token: string?
- +peer_channel: string?
- +top_k: int = 5
- +max_tokens: int = 1024
- +temperature: float = 0.7
- +similarity_threshold: float = 0.5
- +stream: bool
- +messages: Message[]
- +custom_system_prompt: string?
- +retrieval_only: bool = false
- }
-
- class EndpointRef {
- +url: string
- +slug: string
- +name: string
- +tenant_name: string?
- +owner_username: string?
- +query_override: string?
- }
-
- class Message {
- +role: "system"|"user"|"assistant"
- +content: string
- }
-
- class ChatResponse {
- +response: string
- +sources: map~string,DocumentSource~
- +retrieval_info: SourceInfo[]
- +metadata: ResponseMetadata
- +usage: TokenUsage?
- +profit_share: map~string,float~?
- }
-
- class DocumentSource {
- +slug: string
- +content: string
- }
-
- class ResponseMetadata {
- +retrieval_time_ms: int
- +generation_time_ms: int
- +total_time_ms: int
- }
-
- class TokenUsage {
- +prompt_tokens: int
- +completion_tokens: int
- +total_tokens: int
- }
-
- ChatRequest --> EndpointRef
- ChatRequest --> Message
- ChatResponse --> DocumentSource
- ChatResponse --> ResponseMetadata
- ChatResponse --> TokenUsage
-```
-
-### Retrieval Data Flow
-
-```mermaid
-classDiagram
- class RetrievalResult {
- +source_path: string
- +documents: Document[]
- +status: "success"|"error"|"timeout"
- +error_message: string?
- +latency_ms: int
- }
-
- class Document {
- +content: string
- +metadata: map
- +score: float
- +title: string?
- }
-
- class GenerationResult {
- +response: string
- +latency_ms: int
- +usage: TokenUsage?
- }
-
- class ResolvedEndpoint {
- +path: string
- +url: string
- +slug: string
- +name: string
- +owner_username: string
- +endpoint_type: "model"|"data_source"
- +tenant_name: string?
- }
-
- RetrievalResult --> Document
-```
-
----
-
-## Appendix A: File Reference
-
-| Layer | Key File | Purpose |
-|-------|----------|---------|
-| **Frontend** | `components/frontend/src/hooks/use-chat-workflow.ts` | Chat workflow state machine |
-| | `components/frontend/src/components/chat/chat-view.tsx` | Chat UI container |
-| | `components/frontend/src/components/chat/search-input.tsx` | Query input with model/source selection |
-| | `components/frontend/src/lib/citation-utils.ts` | Citation parsing & rendering |
-| **TS SDK** | `sdk/typescript/src/resources/chat.ts` | Chat API client, SSE parsing |
-| | `sdk/typescript/src/resources/auth.ts` | Token acquisition (satellite, transaction, peer) |
-| **Backend** | `components/backend/src/syfthub/api/endpoints/token.py` | Satellite token generation |
-| | `components/backend/src/syfthub/api/endpoints/accounting.py` | Transaction token generation |
-| | `components/backend/src/syfthub/api/endpoints/peer.py` | Peer token generation |
-| **Aggregator** | `components/aggregator/src/aggregator/api/endpoints/chat.py` | Chat endpoint handlers |
-| | `components/aggregator/src/aggregator/services/orchestrator.py` | RAG pipeline orchestration |
-| | `components/aggregator/src/aggregator/services/retrieval.py` | Data source retrieval |
-| | `components/aggregator/src/aggregator/services/model.py` | Model client (HTTP) |
-| | `components/aggregator/src/aggregator/services/prompt_builder.py` | Prompt construction |
-| | `components/aggregator/src/aggregator/clients/nats_transport.py` | NATS client (aggregator side) |
-| **Go SDK** | `sdk/golang/syfthub/chat.go` | Hub client chat/stream |
-| | `sdk/golang/syfthub/auth.go` | Token acquisition (Go client) |
-| | `sdk/golang/syfthubapi/processor.go` | Request processing pipeline |
-| | `sdk/golang/syfthubapi/transport/nats.go` | NATS transport (space side) |
-| | `sdk/golang/syfthubapi/transport/crypto.go` | X25519 + AES-256-GCM encryption |
-| | `sdk/golang/syfthubapi/transport/http.go` | HTTP transport (space side) |
-
-## Appendix B: Environment Variables
-
-| Component | Variable | Default | Purpose |
-|-----------|----------|---------|---------|
-| Backend | `SATELLITE_TOKEN_EXPIRE_SECONDS` | 60 | Satellite token TTL |
-| Backend | `NATS_AUTH_TOKEN` | — | Required for peer token endpoints |
-| Backend | `NATS_WS_PUBLIC_URL` | — | WebSocket URL in peer token response |
-| Aggregator | `AGGREGATOR_MODEL_STREAMING_ENABLED` | false | Enable token-by-token streaming from model |
-| Aggregator | `AGGREGATOR_SYFTHUB_URL` | — | Hub URL for endpoint resolution |
-| Space | `SYFTHUB_URL` | — | Hub backend URL |
-| Space | `SYFTHUB_API_KEY` | — | PAT for authentication |
-| Space | `SPACE_URL` | — | Public URL or `tunneling:{username}` |
-| Space | `SERVER_PORT` | 8000 | HTTP listen port |
-| Space | `HEARTBEAT_TTL_SECONDS` | 300 | Health ping interval base |
-
-## Appendix C: Timeout Reference
-
-| Timeout | Value | Context |
-|---------|-------|---------|
-| Satellite token TTL | 60s | Must re-fetch frequently |
-| Data source query | 30s | HTTP proxy timeout |
-| Model query | 120s | HTTP proxy timeout |
-| NATS request timeout | `timeout_ms` in request or 120s default | Per-request configurable |
-| Hub API call | 30s | Default httpx timeout |
-| Aggregator API call | 120s | Default for generation |
-| Heartbeat interval | TTL × 0.8 (default 240s) | Periodic health ping |
-| Encryption key cache | 300s | Aggregator caches space public keys |
diff --git a/sdk/README.md b/sdk/README.md
deleted file mode 100644
index bd8feb0e..00000000
--- a/sdk/README.md
+++ /dev/null
@@ -1,123 +0,0 @@
-# SyftHub SDKs
-
-Official SDKs for interacting with the SyftHub API.
-
-## Available SDKs
-
-| SDK | Language | Directory | Status |
-|-----|----------|-----------|--------|
-| [Python SDK](./python/) | Python 3.10+ | `sdk/python/` | Stable |
-| [TypeScript SDK](./typescript/) | TypeScript/Node.js 18+ | `sdk/typescript/` | Stable |
-
-## Quick Comparison
-
-### Installation
-
-**Python:**
-```bash
-pip install syfthub-sdk
-# or
-uv add syfthub-sdk
-```
-
-**TypeScript:**
-```bash
-npm install @syfthub/sdk
-# or
-yarn add @syfthub/sdk
-```
-
-### Basic Usage
-
-**Python:**
-```python
-from syfthub_sdk import SyftHubClient
-
-client = SyftHubClient(base_url="https://hub.syft.com")
-user = await client.auth.login("alice", "password")
-
-for endpoint in client.hub.browse():
- print(endpoint.name)
-```
-
-**TypeScript:**
-```typescript
-import { SyftHubClient } from '@syfthub/sdk';
-
-const client = new SyftHubClient({ baseUrl: 'https://hub.syft.com' });
-const user = await client.auth.login('alice', 'password');
-
-for await (const endpoint of client.hub.browse()) {
- console.log(endpoint.name);
-}
-```
-
-## API Parity
-
-Both SDKs provide the same functionality with identical APIs (adjusted for language conventions):
-
-| Feature | Python | TypeScript |
-|---------|--------|------------|
-| Auth | `client.auth.*` | `client.auth.*` |
-| My Endpoints | `client.my_endpoints.*` | `client.myEndpoints.*` |
-| Hub | `client.hub.*` | `client.hub.*` |
-| Users | `client.users.*` | `client.users.*` |
-| Accounting | `client.accounting.*` | `client.accounting.*` |
-
-### Naming Conventions
-
-| Python (snake_case) | TypeScript (camelCase) |
-|---------------------|------------------------|
-| `my_endpoints` | `myEndpoints` |
-| `full_name` | `fullName` |
-| `get_tokens()` | `getTokens()` |
-| `is_authenticated` | `isAuthenticated` |
-
-### Iteration
-
-**Python:**
-```python
-for endpoint in client.hub.browse():
- print(endpoint.name)
-```
-
-**TypeScript:**
-```typescript
-for await (const endpoint of client.hub.browse()) {
- console.log(endpoint.name);
-}
-```
-
-## Environment Variables
-
-Both SDKs support the same environment variables:
-
-| Variable | Description |
-|----------|-------------|
-| `SYFTHUB_URL` | SyftHub API base URL |
-| `SYFTHUB_ACCOUNTING_URL` | Accounting service URL (optional) |
-| `SYFTHUB_ACCOUNTING_EMAIL` | Accounting auth email (optional) |
-| `SYFTHUB_ACCOUNTING_PASSWORD` | Accounting auth password (optional) |
-
-## Development
-
-### Python SDK
-
-```bash
-cd sdk/python
-uv sync
-uv run pytest
-```
-
-### TypeScript SDK
-
-```bash
-cd sdk/typescript
-npm install
-npm run build
-npm test
-```
-
-## License
-
-MIT
diff --git a/sdk/golang/README.md b/sdk/golang/README.md
deleted file mode 100644
index bf288303..00000000
--- a/sdk/golang/README.md
+++ /dev/null
@@ -1,456 +0,0 @@
-# SyftHub Go SDK
-
-Official Go SDK for SyftHub - a platform for RAG-powered AI endpoints.
-
-## Installation
-
-```bash
-go get github.com/openmined/syfthub/sdk/golang
-```
-
-## Quick Start
-
-```go
-package main
-
-import (
- "context"
- "fmt"
- "log"
-
- "github.com/openmined/syfthub/sdk/golang/syfthub"
-)
-
-func main() {
- // Create client (reads SYFTHUB_URL from environment)
- client, err := syfthub.NewClient()
- if err != nil {
- log.Fatal(err)
- }
- defer client.Close()
-
- ctx := context.Background()
-
- // Login
- user, err := client.Auth.Login(ctx, "username", "password")
- if err != nil {
- log.Fatal(err)
- }
- fmt.Printf("Logged in as: %s\n", user.Username)
-
- // RAG Chat Query
- chat := client.Chat()
- response, err := chat.Complete(ctx, &syfthub.ChatRequest{
- Prompt: "What is machine learning?",
- Model: "alice/gpt-model",
- DataSources: []string{"bob/ml-docs", "carol/tutorials"},
- })
- if err != nil {
- log.Fatal(err)
- }
- fmt.Println(response.Response)
-}
-```
-
-## Configuration
-
-### Environment Variables
-
-| Variable | Description | Default |
-|----------|-------------|---------|
-| `SYFTHUB_URL` | SyftHub API URL | `https://hub.syft.com` |
-| `SYFTHUB_AGGREGATOR_URL` | Aggregator service URL | Auto-discovered |
-| `SYFTHUB_API_TOKEN` | API token for authentication | - |
-
-### Client Options
-
-```go
-client, err := syfthub.NewClient(
- syfthub.WithBaseURL("https://hub.syft.com"),
- syfthub.WithTimeout(30 * time.Second),
- syfthub.WithAggregatorURL("https://aggregator.syft.com"),
- syfthub.WithAPIToken("your-api-token"),
-)
-```
-
-## Authentication
-
-### Username/Password Login
-
-```go
-user, err := client.Auth.Login(ctx, "username", "password")
-```
-
-### API Token Authentication
-
-```go
-// Via environment variable
-os.Setenv("SYFTHUB_API_TOKEN", "your-api-token")
-client, _ := syfthub.NewClient()
-
-// Or via option
-client, _ := syfthub.NewClient(syfthub.WithAPIToken("your-api-token"))
-```
-
-### Register New User
-
-```go
-user, err := client.Auth.Register(ctx, &syfthub.RegisterRequest{
- Username: "newuser",
- Email: "user@example.com",
- Password: "securepassword",
- FullName: "New User",
-})
-```
-
-## Chat (RAG Queries)
-
-### Complete (Non-Streaming)
-
-```go
-chat := client.Chat()
-
-response, err := chat.Complete(ctx, &syfthub.ChatRequest{
- Prompt: "Explain neural networks",
- Model: "owner/model-slug",
- DataSources: []string{"owner1/docs", "owner2/kb"},
- TopK: 5,
- MaxTokens: 1024,
- Temperature: 0.7,
-})
-
-fmt.Println(response.Response)
-fmt.Printf("Retrieval: %dms, Generation: %dms\n",
- response.Metadata.RetrievalTimeMs,
- response.Metadata.GenerationTimeMs)
-```
-
-### Streaming
-
-```go
-events, errChan := chat.Stream(ctx, &syfthub.ChatRequest{
- Prompt: "What is Python?",
- Model: "owner/model",
-})
-
-for event := range events {
- switch e := event.(type) {
- case *syfthub.TokenEvent:
- fmt.Print(e.Content)
- case *syfthub.RetrievalCompleteEvent:
- fmt.Printf("[Retrieved %d docs]\n", e.TotalDocuments)
- case *syfthub.DoneEvent:
- fmt.Println("\nComplete!")
- case *syfthub.ErrorEvent:
- fmt.Printf("Error: %s\n", e.Message)
- }
-}
-
-if err := <-errChan; err != nil {
- log.Fatal(err)
-}
-```
-
-### Available Models and Data Sources
-
-```go
-// Get available models
-models, err := chat.GetAvailableModels(ctx)
-for _, m := range models {
- fmt.Printf("%s/%s: %s\n", m.OwnerUsername, m.Slug, m.Name)
-}
-
-// Get available data sources
-sources, err := chat.GetAvailableDataSources(ctx)
-```
-
-## Hub Discovery
-
-### Browse Public Endpoints
-
-```go
-iter := client.Hub.Browse(ctx, syfthub.WithPageSize(20))
-for iter.Next(ctx) {
- ep := iter.Value()
- fmt.Printf("%s/%s: %s\n", ep.OwnerUsername, ep.Slug, ep.Name)
-}
-if err := iter.Err(); err != nil {
- log.Fatal(err)
-}
-```
-
-### Search Endpoints
-
-```go
-results, err := client.Hub.Search(ctx, "machine learning",
- syfthub.WithTopK(10),
- syfthub.WithMinScore(0.5),
-)
-for _, r := range results {
- fmt.Printf("[%.2f] %s\n", r.RelevanceScore, r.Name)
-}
-```
-
-### Trending Endpoints
-
-```go
-iter := client.Hub.Trending(ctx, syfthub.WithMinStars(10))
-for iter.Next(ctx) {
- ep := iter.Value()
- fmt.Printf("%s - %d stars\n", ep.Name, ep.StarsCount)
-}
-```
-
-### Star/Unstar
-
-```go
-err := client.Hub.Star(ctx, "owner/endpoint")
-err = client.Hub.Unstar(ctx, "owner/endpoint")
-
-starred, err := client.Hub.IsStarred(ctx, "owner/endpoint")
-```
-
-## Endpoint Management
-
-### List My Endpoints
-
-```go
-iter := client.MyEndpoints.List(ctx,
- syfthub.WithVisibility(syfthub.VisibilityPublic),
-)
-for iter.Next(ctx) {
- ep := iter.Value()
- fmt.Println(ep.Name)
-}
-```
-
-### Create Endpoint
-
-```go
-endpoint, err := client.MyEndpoints.Create(ctx, &syfthub.CreateEndpointRequest{
- Name: "My API",
- Type: syfthub.EndpointTypeModel,
- Visibility: syfthub.VisibilityPublic,
- Description: "A cool AI model",
- Readme: "# My API\n\nDocumentation here.",
-})
-```
-
-### Update/Delete
-
-```go
-endpoint, err := client.MyEndpoints.Update(ctx, "owner/slug",
- &syfthub.UpdateEndpointRequest{
- Description: ptr("Updated description"),
- },
-)
-
-err = client.MyEndpoints.Delete(ctx, "owner/slug")
-```
-
-## User Management
-
-### Update Profile
-
-```go
-user, err := client.Users.Update(ctx, &syfthub.UpdateUserRequest{
- FullName: ptr("John Doe"),
-})
-```
-
-### Check Username/Email Availability
-
-```go
-available, err := client.Users.CheckUsername(ctx, "newusername")
-available, err = client.Users.CheckEmail(ctx, "new@example.com")
-```
-
-### Aggregator Configurations
-
-```go
-// List aggregators
-aggregators, err := client.Users.Aggregators.List(ctx)
-
-// Create aggregator
-agg, err := client.Users.Aggregators.Create(ctx,
- "My Aggregator",
- "https://my-aggregator.example.com",
-)
-
-// Set as default
-agg, err = client.Users.Aggregators.SetDefault(ctx, agg.ID)
-
-// Delete
-err = client.Users.Aggregators.Delete(ctx, agg.ID)
-```
-
-## API Tokens
-
-```go
-tokens := client.APITokens()
-
-// Create token (SAVE THE TOKEN - only shown once!)
-result, err := tokens.Create(ctx, &syfthub.CreateAPITokenRequest{
- Name: "CI/CD Pipeline",
- Scopes: []syfthub.APITokenScope{syfthub.APITokenScopeWrite},
-})
-fmt.Println("Token:", result.Token) // Save this!
-
-// List tokens
-response, err := tokens.List(ctx)
-for _, t := range response.Tokens {
- fmt.Printf("%s: %s\n", t.Name, t.TokenPrefix)
-}
-
-// Revoke
-err = tokens.Revoke(ctx, tokenID)
-```
-
-## Accounting (Billing)
-
-```go
-// Get accounting resource (auto-fetches credentials from backend)
-accounting, err := client.Accounting(ctx)
-
-// Check balance
-user, err := accounting.GetUser(ctx)
-fmt.Printf("Balance: %.2f credits\n", user.Balance)
-
-// List transactions
-iter := accounting.GetTransactions(ctx)
-for iter.Next(ctx) {
- tx := iter.Value()
- fmt.Printf("%s: %.2f (%s -> %s)\n",
- tx.Status, tx.Amount, tx.SenderEmail, tx.RecipientEmail)
-}
-
-// Create transaction
-tx, err := accounting.CreateTransaction(ctx, &syfthub.CreateTransactionRequest{
- RecipientEmail: "recipient@example.com",
- Amount: 10.0,
-})
-
-// Confirm transaction
-tx, err = accounting.ConfirmTransaction(ctx, tx.ID)
-```
-
-## Direct SyftAI Queries
-
-For custom RAG pipelines, use the low-level SyftAI resource:
-
-```go
-syftai := client.SyftAI()
-
-// Query data source directly
-docs, err := syftai.QueryDataSource(ctx, &syfthub.QueryDataSourceRequest{
- Endpoint: syfthub.EndpointRef{URL: "http://syftai:8080", Slug: "docs"},
- Query: "What is Python?",
- UserEmail: "user@example.com",
- TopK: 10,
-})
-
-// Query model directly
-response, err := syftai.QueryModel(ctx, &syfthub.QueryModelRequest{
- Endpoint: syfthub.EndpointRef{URL: "http://syftai:8080", Slug: "gpt"},
- Messages: []syfthub.Message{
- {Role: "system", Content: "You are helpful."},
- {Role: "user", Content: "Hello!"},
- },
- UserEmail: "user@example.com",
-})
-
-// Stream model response
-chunks, errChan := syftai.QueryModelStream(ctx, &syfthub.QueryModelRequest{...})
-for chunk := range chunks {
- fmt.Print(chunk)
-}
-```
-
-## Pagination
-
-All list operations return a `PageIterator[T]` for lazy pagination:
-
-```go
-iter := client.Hub.Browse(ctx)
-
-// Iterate through all items
-for iter.Next(ctx) {
- item := iter.Value()
- // ...
-}
-if err := iter.Err(); err != nil {
- log.Fatal(err)
-}
-
-// Or get all items at once
-all, err := iter.All(ctx)
-
-// Or get first N items
-first5, err := iter.Take(ctx, 5)
-
-// Or get first page only
-firstPage, err := iter.FirstPage(ctx)
-
-// Or use callback
-err := iter.ForEach(ctx, func(item T) bool {
- fmt.Println(item)
- return true // continue iteration
-})
-```
-
-## Error Handling
-
-All errors implement the `SyftHubError` interface:
-
-```go
-response, err := chat.Complete(ctx, req)
-if err != nil {
- var authErr *syfthub.AuthenticationError
- var notFound *syfthub.NotFoundError
- var epErr *syfthub.EndpointResolutionError
-
- switch {
- case errors.As(err, &authErr):
- fmt.Println("Authentication failed:", authErr.Message)
- case errors.As(err, ¬Found):
- fmt.Println("Not found:", notFound.Message)
- case errors.As(err, &epErr):
- fmt.Printf("Could not resolve endpoint '%s': %s\n",
- epErr.EndpointPath, epErr.Message)
- default:
- fmt.Println("Error:", err)
- }
-}
-```
-
-### Error Types
-
-| Error | Description |
-|-------|-------------|
-| `AuthenticationError` | Invalid credentials or expired token |
-| `AuthorizationError` | Insufficient permissions |
-| `NotFoundError` | Resource not found |
-| `ValidationError` | Invalid request data |
-| `NetworkError` | Connection failed |
-| `ConfigurationError` | Missing or invalid configuration |
-| `ChatError` | Chat/RAG operation failed |
-| `AggregatorError` | Aggregator service error |
-| `EndpointResolutionError` | Could not resolve endpoint path |
-| `RetrievalError` | Document retrieval failed |
-| `GenerationError` | Model generation failed |
-
-## Examples
-
-See the [examples](examples/) directory for complete working examples:
-
-```bash
-cd examples/demo
-go run . -username alice -password secret123 \
- -model "bob/gpt-model" \
- -data-sources "carol/docs" \
- -prompt "What is machine learning?"
-```
-
-## License
-
-Apache 2.0
diff --git a/sdk/golang/examples/file_based/endpoints/echo-model/README.md b/sdk/golang/examples/file_based/endpoints/echo-model/README.md
deleted file mode 100644
index 58d59237..00000000
--- a/sdk/golang/examples/file_based/endpoints/echo-model/README.md
+++ /dev/null
@@ -1,35 +0,0 @@
----
-slug: echo-model
-type: model
-name: Echos Model
-description: Echos back the user message
-enabled: true
-version: "1.0.1"
-runtime:
- mode: subprocess
- timeout: 30
----
-
-# Echo Model
-
-A simple model endpoint that echoes back the last user message.
-
-## Usage
-
-```bash
-curl -X POST http://localhost:8001/api/v1/endpoints/echo-model/query \
- -H "Content-Type: application/json" \
- -H "Authorization: Bearer test-token" \
- -d '{"messages": [{"role": "user", "content": "Hello!"}]}'
-```
-
-## Response
-
-```json
-{
- "summary": {
- "role": "assistant",
- "content": "Echo: Hello!"
- }
-}
-```
diff --git a/sdk/golang/examples/file_based/endpoints/sample-model/README.md b/sdk/golang/examples/file_based/endpoints/sample-model/README.md
deleted file mode 100644
index 160cf553..00000000
--- a/sdk/golang/examples/file_based/endpoints/sample-model/README.md
+++ /dev/null
@@ -1,37 +0,0 @@
----
-slug: sample-model
-type: model
-name: Sample Model
-description: A sample model endpoint demonstrating file-based configuration
-enabled: true
-version: "1.0.0"
-env:
- required: []
- optional: [DEBUG]
- inherit: [PATH, HOME]
-runtime:
- mode: subprocess
- workers: 1
- timeout: 30
----
-
-# Sample Model Endpoint
-
-This is a sample model endpoint that demonstrates the file-based endpoint
-configuration system.
-
-## Usage
-
-Send a POST request to `/api/v1/endpoints/sample-model/query` with:
-
-```json
-{
- "messages": [
- {"role": "user", "content": "Hello, how are you?"}
- ]
-}
-```
-
-## Response
-
-The model will return a friendly response based on the input.
diff --git a/sdk/golang/examples/file_based/endpoints/simple-search/README.md b/sdk/golang/examples/file_based/endpoints/simple-search/README.md
deleted file mode 100644
index 78b6c726..00000000
--- a/sdk/golang/examples/file_based/endpoints/simple-search/README.md
+++ /dev/null
@@ -1,38 +0,0 @@
----
-slug: simple-search
-type: data_source
-name: Simple Search
-description: A simple search endpoint that returns sample documents
-enabled: true
-version: "1.0.0"
-runtime:
- mode: subprocess
- timeout: 30
----
-
-# Simple Search
-
-A simple data source endpoint that returns sample documents matching the query.
-
-## Usage
-
-```bash
-curl -X POST http://localhost:8001/api/v1/endpoints/simple-search/query \
- -H "Content-Type: application/json" \
- -H "Authorization: Bearer test-token" \
- -d '{"messages": [{"role": "user", "content": "machine learning"}]}'
-```
-
-## Response
-
-```json
-{
- "references": [
- {
- "document_id": "doc-1",
- "content": "...",
- "similarity_score": 0.95
- }
- ]
-}
-```
diff --git a/sdk/golang/syfthub/accounting.go b/sdk/golang/syfthub/accounting.go
index d393e232..de6fa063 100644
--- a/sdk/golang/syfthub/accounting.go
+++ b/sdk/golang/syfthub/accounting.go
@@ -4,11 +4,9 @@ import (
"context"
"encoding/json"
"fmt"
- "io"
"net/http"
"net/url"
"strconv"
- "strings"
"time"
)
@@ -54,143 +52,48 @@ import (
// // Confirm the transaction
// tx, err = accounting.ConfirmTransaction(ctx, tx.ID)
type AccountingResource struct {
- url string
- email string
- password string
- timeout time.Duration
- client *http.Client
+ http *httpClient
+ timeout time.Duration
}
-// newAccountingResource creates a new AccountingResource.
+// newAccountingResource creates a new AccountingResource backed by the unified
+// httpClient with a basicAuth strategy.
func newAccountingResource(accountingURL, email, password string, timeout time.Duration) *AccountingResource {
return &AccountingResource{
- url: strings.TrimSuffix(accountingURL, "/"),
- email: email,
- password: password,
- timeout: timeout,
- client: &http.Client{
- Timeout: timeout,
- },
+ http: newBasicAuthClient(accountingURL, timeout, email, password),
+ timeout: timeout,
}
}
-// request makes an authenticated request to the accounting service.
-func (a *AccountingResource) request(ctx context.Context, method, path string, body interface{}, result interface{}) error {
- var reqBody io.Reader
- if body != nil {
- jsonBody, err := json.Marshal(body)
- if err != nil {
- return err
- }
- reqBody = strings.NewReader(string(jsonBody))
+// do makes a request through the accounting httpClient. When applyAuth is nil,
+// the client's default basic-auth strategy is used; supply a closure to override
+// (e.g. for delegated transactions that authenticate with a Bearer token).
+func (a *AccountingResource) do(ctx context.Context, method, path string, body, result interface{}, applyAuth func(*http.Request)) error {
+ var opts []RequestOption
+ if applyAuth != nil {
+ opts = append(opts, withAuthFunc(applyAuth))
}
-
- req, err := http.NewRequestWithContext(ctx, method, a.url+path, reqBody)
+ respBody, err := a.http.Request(ctx, method, path, body, opts...)
if err != nil {
return err
}
-
- req.SetBasicAuth(a.email, a.password)
- req.Header.Set("Content-Type", "application/json")
-
- resp, err := a.client.Do(req)
- if err != nil {
- return newAPIError(0, fmt.Sprintf("Accounting request failed: %v", err))
- }
- defer resp.Body.Close()
-
- respBody, err := io.ReadAll(resp.Body)
- if err != nil {
- return newAPIError(resp.StatusCode, fmt.Sprintf("Failed to read response: %v", err))
- }
-
- if resp.StatusCode >= 400 {
- return a.handleErrorResponse(resp.StatusCode, respBody)
+ if result != nil && len(respBody) > 0 {
+ return json.Unmarshal(respBody, result)
}
-
- if result != nil && resp.StatusCode != 204 && len(respBody) > 0 {
- if err := json.Unmarshal(respBody, result); err != nil {
- return err
- }
- }
-
return nil
}
-// requestWithToken makes a request using Bearer token auth (for delegated transactions).
-func (a *AccountingResource) requestWithToken(ctx context.Context, method, path, token string, body interface{}, result interface{}) error {
- var reqBody io.Reader
- if body != nil {
- jsonBody, err := json.Marshal(body)
- if err != nil {
- return err
- }
- reqBody = strings.NewReader(string(jsonBody))
- }
-
- req, err := http.NewRequestWithContext(ctx, method, a.url+path, reqBody)
- if err != nil {
- return err
- }
-
- req.Header.Set("Authorization", "Bearer "+token)
- req.Header.Set("Content-Type", "application/json")
-
- resp, err := a.client.Do(req)
- if err != nil {
- return newAPIError(0, fmt.Sprintf("Accounting request failed: %v", err))
- }
- defer resp.Body.Close()
-
- respBody, err := io.ReadAll(resp.Body)
- if err != nil {
- return newAPIError(resp.StatusCode, fmt.Sprintf("Failed to read response: %v", err))
- }
-
- if resp.StatusCode >= 400 {
- return a.handleErrorResponse(resp.StatusCode, respBody)
- }
-
- if result != nil && resp.StatusCode != 204 && len(respBody) > 0 {
- if err := json.Unmarshal(respBody, result); err != nil {
- return err
- }
- }
-
- return nil
+// request makes a request with the default basic-auth strategy.
+func (a *AccountingResource) request(ctx context.Context, method, path string, body, result interface{}) error {
+ return a.do(ctx, method, path, body, result, nil)
}
-// handleErrorResponse handles HTTP error responses from accounting service.
-func (a *AccountingResource) handleErrorResponse(statusCode int, body []byte) error {
- var detail string
- var errorBody map[string]interface{}
- if err := json.Unmarshal(body, &errorBody); err == nil {
- if d, ok := errorBody["detail"].(string); ok {
- detail = d
- } else if m, ok := errorBody["message"].(string); ok {
- detail = m
- } else {
- detail = string(body)
- }
- } else {
- detail = string(body)
- if detail == "" {
- detail = fmt.Sprintf("HTTP %d", statusCode)
- }
- }
-
- switch statusCode {
- case 401:
- return newAuthenticationError(fmt.Sprintf("Authentication failed: %s", detail))
- case 403:
- return newAuthorizationError(fmt.Sprintf("Permission denied: %s", detail))
- case 404:
- return newNotFoundError(fmt.Sprintf("Not found: %s", detail))
- case 422:
- return newValidationError(fmt.Sprintf("Validation error: %s", detail), nil)
- default:
- return newAPIError(statusCode, fmt.Sprintf("Accounting API error: %s", detail))
- }
+// requestWithToken makes a request authenticated with a Bearer token (used for
+// delegated transactions). It shares the httpClient connection pool.
+func (a *AccountingResource) requestWithToken(ctx context.Context, method, path, token string, body, result interface{}) error {
+ return a.do(ctx, method, path, body, result, func(r *http.Request) {
+ r.Header.Set("Authorization", "Bearer "+token)
+ })
}
// =========================================================================
@@ -446,7 +349,7 @@ func (a *AccountingResource) CreateDelegatedTransaction(ctx context.Context, req
// Close closes the HTTP client and releases resources.
func (a *AccountingResource) Close() {
- if a.client != nil {
- a.client.CloseIdleConnections()
+ if a.http != nil {
+ a.http.Close()
}
}
diff --git a/sdk/golang/syfthub/accounting_test.go b/sdk/golang/syfthub/accounting_test.go
index 50841ce5..cf861d6e 100644
--- a/sdk/golang/syfthub/accounting_test.go
+++ b/sdk/golang/syfthub/accounting_test.go
@@ -13,19 +13,23 @@ import (
func TestNewAccountingResource(t *testing.T) {
ar := newAccountingResource("https://accounting.example.com/", "user@example.com", "password123", 30*time.Second)
- if ar.url != "https://accounting.example.com" {
- t.Errorf("url = %q, trailing slash should be trimmed", ar.url)
+ if ar.http.baseURL != "https://accounting.example.com" {
+ t.Errorf("baseURL = %q, trailing slash should be trimmed", ar.http.baseURL)
}
- if ar.email != "user@example.com" {
- t.Errorf("email = %q", ar.email)
+ auth, ok := ar.http.auth.(*basicAuth)
+ if !ok {
+ t.Fatalf("auth strategy = %T, want *basicAuth", ar.http.auth)
}
- if ar.password != "password123" {
- t.Errorf("password = %q", ar.password)
+ if auth.username != "user@example.com" {
+ t.Errorf("username = %q", auth.username)
+ }
+ if auth.password != "password123" {
+ t.Errorf("password = %q", auth.password)
}
if ar.timeout != 30*time.Second {
t.Errorf("timeout = %v", ar.timeout)
}
- if ar.client == nil {
+ if ar.http.client == nil {
t.Error("client should be initialized")
}
}
diff --git a/sdk/golang/syfthub/chat.go b/sdk/golang/syfthub/chat.go
index ed465ee7..30e1a544 100644
--- a/sdk/golang/syfthub/chat.go
+++ b/sdk/golang/syfthub/chat.go
@@ -83,23 +83,42 @@ type chatPrepared struct {
aggregatorURL string
}
-// prepareRequest resolves endpoints, fetches tokens, and builds the aggregator request body.
-// It is called by both Complete (stream=false) and streamInternal (stream=true) to eliminate
-// the ~73 lines of setup code that would otherwise be duplicated.
-func (c *ChatResource) prepareRequest(ctx context.Context, req *ChatCompleteRequest, stream bool) (*chatPrepared, error) {
- // Apply defaults
- if req.TopK == 0 {
- req.TopK = 5
+// resolvedDefaults holds zero-value defaults resolved as locals so prepareRequest
+// never mutates the caller's *ChatCompleteRequest.
+type resolvedDefaults struct {
+ topK int
+ maxTokens int
+ temperature float64
+ similarityThreshold float64
+}
+
+func resolveDefaults(req *ChatCompleteRequest) resolvedDefaults {
+ r := resolvedDefaults{
+ topK: req.TopK,
+ maxTokens: req.MaxTokens,
+ temperature: req.Temperature,
+ similarityThreshold: req.SimilarityThreshold,
}
- if req.MaxTokens == 0 {
- req.MaxTokens = 1024
+ if r.topK == 0 {
+ r.topK = 5
}
- if req.Temperature == 0 {
- req.Temperature = 0.7
+ if r.maxTokens == 0 {
+ r.maxTokens = 1024
}
- if req.SimilarityThreshold == 0 {
- req.SimilarityThreshold = 0.5
+ if r.temperature == 0 {
+ r.temperature = 0.7
}
+ if r.similarityThreshold == 0 {
+ r.similarityThreshold = 0.5
+ }
+ return r
+}
+
+// prepareRequest resolves endpoints, fetches tokens, and builds the aggregator request body.
+// It is called by both Complete (stream=false) and streamInternal (stream=true) to eliminate
+// the ~73 lines of setup code that would otherwise be duplicated.
+func (c *ChatResource) prepareRequest(ctx context.Context, req *ChatCompleteRequest, stream bool) (*chatPrepared, error) {
+ defaults := resolveDefaults(req)
// Resolve aggregator URL
aggregatorURL := c.aggregatorURL
@@ -147,7 +166,10 @@ func (c *ChatResource) prepareRequest(ctx context.Context, req *ChatCompleteRequ
}
// Auto-fetch peer token if tunneling endpoints detected
- var peerToken, peerChannel string
+ tokens := chatTokens{
+ endpoint: endpointTokens,
+ transaction: transactionTokens.Tokens,
+ }
tunnelingUsernames := c.collectTunnelingUsernames(modelRef, dsRefs)
if len(tunnelingUsernames) > 0 {
var peerResponse *PeerTokenResponse
@@ -157,26 +179,12 @@ func (c *ChatResource) prepareRequest(ctx context.Context, req *ChatCompleteRequ
peerResponse, err = c.auth.GetPeerToken(ctx, tunnelingUsernames)
}
if err == nil {
- peerToken = peerResponse.PeerToken
- peerChannel = peerResponse.PeerChannel
+ tokens.peerToken = peerResponse.PeerToken
+ tokens.peerChannel = peerResponse.PeerChannel
}
}
- requestBody := c.buildRequestBody(
- req.Prompt,
- modelRef,
- dsRefs,
- endpointTokens,
- transactionTokens.Tokens,
- req.TopK,
- req.MaxTokens,
- req.Temperature,
- req.SimilarityThreshold,
- stream,
- req.Messages,
- peerToken,
- peerChannel,
- )
+ requestBody := c.buildRequestBody(req, modelRef, dsRefs, defaults, tokens, stream)
return &chatPrepared{requestBody: requestBody, aggregatorURL: aggregatorURL}, nil
}
@@ -517,24 +525,26 @@ func (c *ChatResource) collectTunnelingUsernames(modelRef *EndpointRef, dsRefs [
return usernames
}
-// buildRequestBody builds the request body for the aggregator.
+// chatTokens bundles the token maps + peer info passed to buildRequestBody.
+type chatTokens struct {
+ endpoint map[string]string
+ transaction map[string]string
+ peerToken string
+ peerChannel string
+}
+
+// buildRequestBody serializes the aggregator request body. It is a pure serializer —
+// defaults are resolved upstream in resolveDefaults, and the caller's *req is never mutated.
func (c *ChatResource) buildRequestBody(
- prompt string,
+ req *ChatCompleteRequest,
modelRef *EndpointRef,
dsRefs []EndpointRef,
- endpointTokens map[string]string,
- transactionTokens map[string]string,
- topK int,
- maxTokens int,
- temperature float64,
- similarityThreshold float64,
+ defaults resolvedDefaults,
+ tokens chatTokens,
stream bool,
- messages []Message,
- peerToken string,
- peerChannel string,
) map[string]interface{} {
body := map[string]interface{}{
- "prompt": prompt,
+ "prompt": req.Prompt,
"model": map[string]interface{}{
"url": modelRef.URL,
"slug": modelRef.Slug,
@@ -543,12 +553,12 @@ func (c *ChatResource) buildRequestBody(
"owner_username": modelRef.OwnerUsername,
},
"data_sources": make([]map[string]interface{}, 0, len(dsRefs)),
- "endpoint_tokens": endpointTokens,
- "transaction_tokens": transactionTokens,
- "top_k": topK,
- "max_tokens": maxTokens,
- "temperature": temperature,
- "similarity_threshold": similarityThreshold,
+ "endpoint_tokens": tokens.endpoint,
+ "transaction_tokens": tokens.transaction,
+ "top_k": defaults.topK,
+ "max_tokens": defaults.maxTokens,
+ "temperature": defaults.temperature,
+ "similarity_threshold": defaults.similarityThreshold,
"stream": stream,
}
@@ -562,15 +572,15 @@ func (c *ChatResource) buildRequestBody(
})
}
- if len(messages) > 0 {
- body["messages"] = messages
+ if len(req.Messages) > 0 {
+ body["messages"] = req.Messages
}
- if peerToken != "" {
- body["peer_token"] = peerToken
+ if tokens.peerToken != "" {
+ body["peer_token"] = tokens.peerToken
}
- if peerChannel != "" {
- body["peer_channel"] = peerChannel
+ if tokens.peerChannel != "" {
+ body["peer_channel"] = tokens.peerChannel
}
return body
diff --git a/sdk/golang/syfthub/chat_test.go b/sdk/golang/syfthub/chat_test.go
index 4c32462e..cfaba15f 100644
--- a/sdk/golang/syfthub/chat_test.go
+++ b/sdk/golang/syfthub/chat_test.go
@@ -707,21 +707,23 @@ func TestBuildRequestBody(t *testing.T) {
transactionTokens := map[string]string{"bob": "tx_bob"}
messages := []Message{{Role: "user", Content: "Hello"}}
- body := chat.buildRequestBody(
- "Test prompt",
- modelRef,
- dsRefs,
- endpointTokens,
- transactionTokens,
- 10,
- 2048,
- 0.8,
- 0.7,
- true,
- messages,
- "peer_token",
- "peer_channel",
- )
+ req := &ChatCompleteRequest{
+ Prompt: "Test prompt",
+ Messages: messages,
+ }
+ defaults := resolvedDefaults{
+ topK: 10,
+ maxTokens: 2048,
+ temperature: 0.8,
+ similarityThreshold: 0.7,
+ }
+ tokens := chatTokens{
+ endpoint: endpointTokens,
+ transaction: transactionTokens,
+ peerToken: "peer_token",
+ peerChannel: "peer_channel",
+ }
+ body := chat.buildRequestBody(req, modelRef, dsRefs, defaults, tokens, true)
if body["prompt"] != "Test prompt" {
t.Errorf("prompt = %v", body["prompt"])
diff --git a/sdk/golang/syfthub/client.go b/sdk/golang/syfthub/client.go
index da0d5be9..a78111f5 100644
--- a/sdk/golang/syfthub/client.go
+++ b/sdk/golang/syfthub/client.go
@@ -57,10 +57,16 @@ const (
// // ... save tokens to file/db ...
// // Later:
// client.SetTokens(tokens)
+//
+// Client is the main SyftHub client. Options are applied as pure data
+// (they only set fields like apiToken); anything that depends on the HTTP
+// client is applied once in NewClient after c.http is constructed. Any
+// future option that needs c.http should follow the same pattern.
type Client struct {
baseURL string
aggregatorURL string
timeout time.Duration
+ apiToken string
http *httpClient
// Eagerly-initialized resources
@@ -109,9 +115,7 @@ func WithAggregatorURL(url string) Option {
// When provided, the client will be authenticated immediately without needing to call Login().
func WithAPIToken(token string) Option {
return func(c *Client) error {
- if c.http != nil {
- c.http.SetAPIToken(token)
- }
+ c.apiToken = token
return nil
}
}
@@ -153,18 +157,13 @@ func NewClient(opts ...Option) (*Client, error) {
// Create HTTP client
c.http = newHTTPClient(c.baseURL, c.timeout)
- // Check for API token from environment
- if envToken := os.Getenv(EnvAPIToken); envToken != "" {
+ // Apply API token: explicit option takes precedence over environment.
+ if c.apiToken != "" {
+ c.http.SetAPIToken(c.apiToken)
+ } else if envToken := os.Getenv(EnvAPIToken); envToken != "" {
c.http.SetAPIToken(envToken)
}
- // Re-apply WithAPIToken option if it was passed (after http client is created)
- for _, opt := range opts {
- if err := opt(c); err != nil {
- return nil, err
- }
- }
-
// Create eagerly-initialized resources
c.Auth = newAuthResource(c.http)
c.Users = newUsersResource(c.http)
diff --git a/sdk/golang/syfthub/http.go b/sdk/golang/syfthub/http.go
index 3ab6ec21..d935ba75 100644
--- a/sdk/golang/syfthub/http.go
+++ b/sdk/golang/syfthub/http.go
@@ -18,37 +18,89 @@ type HTTPDoer interface {
Do(req *http.Request) (*http.Response, error)
}
+// authStrategy applies authentication credentials to an outgoing request.
+// Implementations: bearerAuth (Hub JWT/API tokens, refreshable), basicAuth
+// (accounting service).
+type authStrategy interface {
+ apply(req *http.Request)
+ // canRefresh reports whether a 401 response should trigger a token refresh
+ // and retry. Only bearer auth supports refresh today.
+ canRefresh() bool
+}
+
+// bearerAuth sends "Authorization: Bearer " where the token is resolved
+// lazily via tokenProvider so tokens can rotate without reconstructing the
+// strategy.
+type bearerAuth struct {
+ tokenProvider func() string
+}
+
+func (b *bearerAuth) apply(req *http.Request) {
+ if t := b.tokenProvider(); t != "" {
+ req.Header.Set("Authorization", "Bearer "+t)
+ }
+}
+func (b *bearerAuth) canRefresh() bool { return true }
+
+// basicAuth sends HTTP Basic auth. Used by the accounting service.
+type basicAuth struct {
+ username string
+ password string
+}
+
+func (b *basicAuth) apply(req *http.Request) { req.SetBasicAuth(b.username, b.password) }
+func (b *basicAuth) canRefresh() bool { return false }
+
// httpClient is the internal HTTP client with automatic token management.
type httpClient struct {
baseURL string
timeout time.Duration
client HTTPDoer
+ auth authStrategy
- // Token storage (protected by mutex)
+ // Token storage for bearerAuth (protected by mutex).
mu sync.RWMutex
accessToken string
refreshToken string
apiToken string
}
-// newHTTPClient creates a new HTTP client.
+// newHTTPClient creates a new HTTP client with bearer-token auth.
func newHTTPClient(baseURL string, timeout time.Duration) *httpClient {
- return &httpClient{
+ h := &httpClient{
baseURL: strings.TrimRight(baseURL, "/"),
timeout: timeout,
client: &http.Client{
Timeout: timeout,
},
}
+ h.auth = &bearerAuth{tokenProvider: h.getBearerToken}
+ return h
+}
+
+// newBasicAuthClient creates an HTTP client that authenticates with HTTP Basic auth.
+// Used by the accounting service which does not participate in JWT refresh.
+func newBasicAuthClient(baseURL string, timeout time.Duration, username, password string) *httpClient {
+ h := &httpClient{
+ baseURL: strings.TrimRight(baseURL, "/"),
+ timeout: timeout,
+ client: &http.Client{
+ Timeout: timeout,
+ },
+ }
+ h.auth = &basicAuth{username: username, password: password}
+ return h
}
// newHTTPClientWithDoer creates a new HTTP client with a custom HTTPDoer (for testing).
func newHTTPClientWithDoer(baseURL string, timeout time.Duration, doer HTTPDoer) *httpClient {
- return &httpClient{
+ h := &httpClient{
baseURL: strings.TrimRight(baseURL, "/"),
timeout: timeout,
client: doer,
}
+ h.auth = &bearerAuth{tokenProvider: h.getBearerToken}
+ return h
}
// Close closes the HTTP client.
@@ -128,6 +180,7 @@ type requestOptions struct {
retryOn401 bool
formData url.Values
query url.Values
+ authFunc func(*http.Request) // overrides client's auth strategy when non-nil
}
// RequestOption is a function that modifies request options.
@@ -161,6 +214,13 @@ func WithQuery(params url.Values) RequestOption {
}
}
+// withAuthFunc overrides the client's default auth strategy for a single call.
+func withAuthFunc(fn func(*http.Request)) RequestOption {
+ return func(o *requestOptions) {
+ o.authFunc = fn
+ }
+}
+
// Request makes an HTTP request and returns the response body.
func (h *httpClient) Request(ctx context.Context, method, path string, body interface{}, opts ...RequestOption) ([]byte, error) {
// Apply default options
@@ -206,10 +266,10 @@ func (h *httpClient) Request(ctx context.Context, method, path string, body inte
}
req.Header.Set("Accept", "application/json")
- if options.includeAuth {
- if token := h.getBearerToken(); token != "" {
- req.Header.Set("Authorization", "Bearer "+token)
- }
+ if options.authFunc != nil {
+ options.authFunc(req)
+ } else if options.includeAuth {
+ h.auth.apply(req)
}
// Make request
@@ -225,8 +285,8 @@ func (h *httpClient) Request(ctx context.Context, method, path string, body inte
return nil, newNetworkError(fmt.Errorf("failed to read response body: %w", err))
}
- // Handle 401 with token refresh
- if resp.StatusCode == 401 && options.retryOn401 && options.includeAuth && h.attemptRefresh(ctx) {
+ // Handle 401 with token refresh (bearer auth only).
+ if resp.StatusCode == 401 && options.retryOn401 && options.includeAuth && h.auth.canRefresh() && h.attemptRefresh(ctx) {
// Retry with new token
return h.Request(ctx, method, path, body, append(opts, WithNoRetry())...)
}
@@ -476,9 +536,7 @@ func (h *httpClient) StreamRequest(ctx context.Context, method, path string, bod
req.Header.Set("Accept", "text/event-stream")
if options.includeAuth {
- if token := h.getBearerToken(); token != "" {
- req.Header.Set("Authorization", "Bearer "+token)
- }
+ h.auth.apply(req)
}
// Make request
@@ -496,117 +554,3 @@ func (h *httpClient) StreamRequest(ctx context.Context, method, path string, bod
return resp, nil
}
-
-// basicAuthHTTPClient wraps httpClient with Basic authentication for accounting service.
-type basicAuthHTTPClient struct {
- *httpClient
- username string
- password string
-}
-
-// newBasicAuthHTTPClient creates a new HTTP client with Basic authentication.
-func newBasicAuthHTTPClient(baseURL string, timeout time.Duration, username, password string) *basicAuthHTTPClient {
- return &basicAuthHTTPClient{
- httpClient: newHTTPClient(baseURL, timeout),
- username: username,
- password: password,
- }
-}
-
-// Request makes an HTTP request with Basic authentication.
-func (h *basicAuthHTTPClient) Request(ctx context.Context, method, path string, body interface{}, opts ...RequestOption) ([]byte, error) {
- // Apply default options
- options := &requestOptions{
- includeAuth: true,
- retryOn401: false, // No token refresh for Basic auth
- }
- for _, opt := range opts {
- opt(options)
- }
-
- // Build URL
- reqURL := h.baseURL + path
- if options.query != nil {
- reqURL += "?" + options.query.Encode()
- }
-
- // Build request body
- var bodyReader io.Reader
- if body != nil {
- bodyBytes, err := json.Marshal(body)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal request body: %w", err)
- }
- bodyReader = bytes.NewReader(bodyBytes)
- }
-
- // Create request
- req, err := http.NewRequestWithContext(ctx, method, reqURL, bodyReader)
- if err != nil {
- return nil, newNetworkError(fmt.Errorf("failed to create request: %w", err))
- }
-
- // Set headers
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("Accept", "application/json")
-
- if options.includeAuth {
- req.SetBasicAuth(h.username, h.password)
- }
-
- // Make request
- resp, err := h.client.Do(req)
- if err != nil {
- return nil, newNetworkError(err)
- }
- defer resp.Body.Close()
-
- // Read response body
- respBody, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, newNetworkError(fmt.Errorf("failed to read response body: %w", err))
- }
-
- // Handle errors
- if resp.StatusCode >= 400 {
- return nil, h.handleError(resp.StatusCode, respBody)
- }
-
- return respBody, nil
-}
-
-// Get makes a GET request with Basic authentication.
-func (h *basicAuthHTTPClient) Get(ctx context.Context, path string, result interface{}, opts ...RequestOption) error {
- body, err := h.Request(ctx, "GET", path, nil, opts...)
- if err != nil {
- return err
- }
- if result != nil {
- return json.Unmarshal(body, result)
- }
- return nil
-}
-
-// Post makes a POST request with Basic authentication.
-func (h *basicAuthHTTPClient) Post(ctx context.Context, path string, body, result interface{}, opts ...RequestOption) error {
- respBody, err := h.Request(ctx, "POST", path, body, opts...)
- if err != nil {
- return err
- }
- if result != nil {
- return json.Unmarshal(respBody, result)
- }
- return nil
-}
-
-// Patch makes a PATCH request with Basic authentication.
-func (h *basicAuthHTTPClient) Patch(ctx context.Context, path string, body, result interface{}, opts ...RequestOption) error {
- respBody, err := h.Request(ctx, "PATCH", path, body, opts...)
- if err != nil {
- return err
- }
- if result != nil {
- return json.Unmarshal(respBody, result)
- }
- return nil
-}
diff --git a/sdk/golang/syfthub/http_test.go b/sdk/golang/syfthub/http_test.go
index 67ed7b08..8c97e142 100644
--- a/sdk/golang/syfthub/http_test.go
+++ b/sdk/golang/syfthub/http_test.go
@@ -638,7 +638,7 @@ func TestBasicAuthHTTPClient(t *testing.T) {
}))
defer server.Close()
- client := newBasicAuthHTTPClient(server.URL, 30*time.Second, "user@example.com", "secret123")
+ client := newBasicAuthClient(server.URL, 30*time.Second, "user@example.com", "secret123")
_, err := client.Request(context.Background(), "GET", "/api/test", nil)
if err != nil {
t.Fatalf("Request error: %v", err)
@@ -655,7 +655,7 @@ func TestBasicAuthHTTPClient(t *testing.T) {
}))
defer server.Close()
- client := newBasicAuthHTTPClient(server.URL, 30*time.Second, "user", "pass")
+ client := newBasicAuthClient(server.URL, 30*time.Second, "user", "pass")
var result map[string]int
err := client.Get(context.Background(), "/user", &result)
if err != nil {
@@ -672,7 +672,7 @@ func TestBasicAuthHTTPClient(t *testing.T) {
}))
defer server.Close()
- client := newBasicAuthHTTPClient(server.URL, 30*time.Second, "user", "pass")
+ client := newBasicAuthClient(server.URL, 30*time.Second, "user", "pass")
var result map[string]string
err := client.Post(context.Background(), "/transactions", map[string]interface{}{"amount": 10.0}, &result)
if err != nil {
@@ -692,7 +692,7 @@ func TestBasicAuthHTTPClient(t *testing.T) {
}))
defer server.Close()
- client := newBasicAuthHTTPClient(server.URL, 30*time.Second, "user", "pass")
+ client := newBasicAuthClient(server.URL, 30*time.Second, "user", "pass")
var result map[string]string
err := client.Patch(context.Background(), "/user", map[string]string{"name": "new name"}, &result)
if err != nil {
diff --git a/sdk/golang/syfthubapi/README.md b/sdk/golang/syfthubapi/README.md
deleted file mode 100644
index ba8d5194..00000000
--- a/sdk/golang/syfthubapi/README.md
+++ /dev/null
@@ -1,342 +0,0 @@
-# SyftHub API - Go SDK
-
-A Go framework for building SyftHub Spaces with a FastAPI-like interface. This is a 1:1 feature-complete port of the Python `syfthub-api` package.
-
-## Features
-
-- **Declarative endpoint registration** via builder pattern
-- **Two execution modes**: HTTP direct and NATS tunneling
-- **File-based endpoint configuration** with hot-reload
-- **Policy enforcement framework** (pre/post execution hooks)
-- **Heartbeat mechanism** for availability signaling
-- **JWT token verification** via SyftHub backend
-- **Middleware support** for request/response processing
-- **Python subprocess execution** for file-based endpoints
-
-## Installation
-
-```bash
-go get github.com/openmined/syfthub/sdk/golang/syfthubapi
-```
-
-## Quick Start
-
-### Basic HTTP Server
-
-```go
-package main
-
-import (
- "context"
- "log"
-
- "github.com/openmined/syfthub/sdk/golang/syfthubapi"
-)
-
-func main() {
- app := syfthubapi.New()
-
- // Register a data source endpoint
- app.DataSource("papers").
- Name("Research Papers").
- Description("Search through research papers").
- Handler(func(ctx context.Context, query string, reqCtx *syfthubapi.RequestContext) ([]syfthubapi.Document, error) {
- return []syfthubapi.Document{
- {DocumentID: "1", Content: "...", SimilarityScore: 0.95},
- }, nil
- })
-
- // Register a model endpoint
- app.Model("chat").
- Name("Chat Assistant").
- Description("An AI chat assistant").
- Handler(func(ctx context.Context, messages []syfthubapi.Message, reqCtx *syfthubapi.RequestContext) (string, error) {
- return "Hello! How can I help?", nil
- })
-
- // Run the server
- if err := app.Run(context.Background()); err != nil {
- log.Fatal(err)
- }
-}
-```
-
-### Configuration
-
-Configuration is loaded from environment variables:
-
-| Variable | Description | Required |
-|----------|-------------|----------|
-| `SYFTHUB_URL` | SyftHub backend URL | Yes |
-| `SYFTHUB_API_KEY` | API token (PAT) for authentication | Yes |
-| `SPACE_URL` | Public URL or `tunneling:username` | Yes |
-| `LOG_LEVEL` | Logging level (DEBUG, INFO, WARNING, ERROR) | No |
-| `SERVER_HOST` | HTTP server bind address | No |
-| `SERVER_PORT` | HTTP server port (default: 8000) | No |
-| `HEARTBEAT_ENABLED` | Enable heartbeat (default: true) | No |
-| `HEARTBEAT_TTL_SECONDS` | Heartbeat TTL (default: 300) | No |
-| `ENDPOINTS_PATH` | Path to file-based endpoints | No |
-| `WATCH_ENABLED` | Enable hot-reload (default: true) | No |
-
-Or use functional options:
-
-```go
-app := syfthubapi.New(
- syfthubapi.WithSyftHubURL("https://syfthub.example.com"),
- syfthubapi.WithAPIKey("syft_pat_xxx"),
- syfthubapi.WithSpaceURL("http://localhost:8001"),
- syfthubapi.WithLogLevel("DEBUG"),
- syfthubapi.WithServerPort(8001),
- syfthubapi.WithHeartbeatEnabled(true),
- syfthubapi.WithEndpointsPath("./endpoints"),
-)
-```
-
-## Endpoint Types
-
-### Data Source
-
-Data sources return documents based on a search query:
-
-```go
-app.DataSource("slug").
- Name("Display Name").
- Description("Brief description").
- Version("1.0.0").
- Handler(func(ctx context.Context, query string, reqCtx *syfthubapi.RequestContext) ([]syfthubapi.Document, error) {
- // Return relevant documents
- return []syfthubapi.Document{...}, nil
- })
-```
-
-### Model
-
-Models process messages and return a response:
-
-```go
-app.Model("slug").
- Name("Display Name").
- Description("Brief description").
- Version("1.0.0").
- Handler(func(ctx context.Context, messages []syfthubapi.Message, reqCtx *syfthubapi.RequestContext) (string, error) {
- // Process messages and return response
- return "response", nil
- })
-```
-
-## Execution Modes
-
-### HTTP Mode (Default)
-
-Set `SPACE_URL` to an HTTP URL:
-
-```bash
-export SPACE_URL=http://localhost:8001
-```
-
-The server listens directly on the specified host and port.
-
-### Tunnel Mode
-
-Set `SPACE_URL` to use NATS tunneling:
-
-```bash
-export SPACE_URL=tunneling:my-username
-```
-
-The server connects to NATS and receives requests via pub/sub.
-
-## File-Based Endpoints
-
-Endpoints can be defined via directory structure:
-
-```
-endpoints/
-├── my-model/
-│ ├── README.md # YAML frontmatter + docs
-│ ├── runner.py # Python handler
-│ ├── .env # Environment variables
-│ ├── pyproject.toml # Dependencies
-│ └── policy/
-│ └── rate_limit.yaml
-```
-
-### README.md Frontmatter
-
-```yaml
----
-slug: my-model
-type: model # or "data_source"
-name: My Model
-description: Description here
-enabled: true
-version: "1.0.0"
-env:
- required: [API_KEY]
- optional: [DEBUG]
- inherit: [PATH, HOME]
-runtime:
- mode: subprocess
- workers: 1
- timeout: 30
----
-
-# Documentation
-```
-
-### runner.py Handler
-
-```python
-def handler(messages: list[dict], context: dict = None) -> str:
- """Handle model requests."""
- return "response"
-
-# For data sources:
-def handler(query: str, context: dict = None) -> list[dict]:
- """Handle data source requests."""
- return [{"document_id": "1", "content": "...", "similarity_score": 0.9}]
-```
-
-## Policy Framework
-
-### Built-in Policies
-
-```go
-import "github.com/openmined/syfthub/sdk/golang/syfthubapi/policy"
-
-// Rate limiting
-rateLimit := policy.NewRateLimitPolicy("rate-limit", 100, 3600) // 100 requests per hour
-
-// Access control
-accessPolicy := policy.NewAccessGroupPolicy("access",
- []string{"alice", "bob"}, // allowed users
- []string{"admin"}, // allowed roles
- nil, // denied users
- nil, // denied roles
-)
-
-// Time window
-timeWindow := policy.NewTimeWindowPolicy("business-hours", 9, 17, nil, nil)
-
-// Add to endpoint
-app.Model("premium").
- Policies(rateLimit, accessPolicy).
- Handler(...)
-```
-
-### YAML Policy Configuration
-
-```yaml
-# policy/rate_limit.yaml
-type: rate_limit
-name: rate-limit
-args:
- max_requests: 100
- window_seconds: 3600
-```
-
-### Composite Policies
-
-```go
-// All policies must pass
-allOf := policy.NewAllOfPolicy("all", policy1, policy2)
-
-// At least one must pass
-anyOf := policy.NewAnyOfPolicy("any", policy1, policy2)
-
-// Negate a policy
-not := policy.NewNotPolicy("not-admin", adminPolicy)
-```
-
-## Middleware
-
-```go
-// Built-in middleware
-app.Use(syfthubapi.LoggingMiddleware(logger))
-app.Use(syfthubapi.RecoveryMiddleware(logger))
-app.Use(syfthubapi.TimeoutMiddleware(30 * time.Second))
-
-// Custom middleware
-app.Use(func(next syfthubapi.RequestHandler) syfthubapi.RequestHandler {
- return func(ctx context.Context, req *syfthubapi.TunnelRequest) (*syfthubapi.TunnelResponse, error) {
- // Pre-processing
- resp, err := next(ctx, req)
- // Post-processing
- return resp, err
- }
-})
-```
-
-## Lifecycle Hooks
-
-```go
-app.OnStartup(func(ctx context.Context) error {
- // Initialize database connections, etc.
- return nil
-})
-
-app.OnShutdown(func(ctx context.Context) error {
- // Clean up resources
- return nil
-})
-```
-
-## Error Handling
-
-All errors implement `error` and can be checked with `errors.Is()`:
-
-```go
-import "errors"
-
-if errors.Is(err, syfthubapi.ErrPolicyDenied) {
- // Handle policy denial
-}
-
-if errors.Is(err, syfthubapi.ErrAuthentication) {
- // Handle auth error
-}
-```
-
-## Package Structure
-
-```
-syfthubapi/
-├── api.go # Main SyftAPI struct
-├── config.go # Configuration management
-├── endpoint.go # Endpoint types and builder
-├── schemas.go # Request/Response types
-├── errors.go # Error types
-├── middleware.go # Middleware chain
-├── auth.go # Authentication
-├── transport/
-│ ├── transport.go # Transport interface
-│ ├── http.go # HTTP transport
-│ └── nats.go # NATS transport
-├── heartbeat/
-│ └── heartbeat.go # Heartbeat manager
-├── policy/
-│ ├── policy.go # Policy interface
-│ ├── loader.go # YAML loading
-│ └── builtin.go # Built-in policies
-└── filemode/
- ├── provider.go # File provider
- ├── loader.go # README parsing
- ├── watcher.go # File watching
- ├── executor.go # Subprocess execution
- └── venv.go # Virtual env management
-```
-
-## Comparison with Python SDK
-
-| Feature | Python | Go |
-|---------|--------|-----|
-| Endpoint registration | `@app.datasource()` decorator | `app.DataSource().Handler()` builder |
-| Async handlers | `async def` | Goroutines + context |
-| Error handling | Exceptions | Error returns |
-| Configuration | Pydantic Settings | Functional options |
-| Hot-reload | watchdog | fsnotify |
-| Subprocess execution | loky | os/exec |
-
-## License
-
-Apache 2.0
diff --git a/sdk/golang/syfthubapi/REFACTORING_PLAN.md b/sdk/golang/syfthubapi/REFACTORING_PLAN.md
deleted file mode 100644
index 24786b69..00000000
--- a/sdk/golang/syfthubapi/REFACTORING_PLAN.md
+++ /dev/null
@@ -1,694 +0,0 @@
-# SyftHub Go SDK Refactoring Plan
-
-## Overview
-
-This plan addresses all identified architectural issues and implements P0, P1, and P2 recommendations from the SDK evaluation.
-
-**Estimated Total Effort**: 8-10 hours
-**Risk Level**: Medium (significant refactoring with proper safety measures)
-
----
-
-## Phase 0: Preparation (Before Any Changes)
-
-### 0.1 Create Test Foundation
-Before refactoring, create basic tests to ensure behavior preservation.
-
-**Test Files to Create**:
-| File | Coverage |
-|------|----------|
-| `config_test.go` | LoadFromEnv, Validate, IsTunnelMode, DeriveNATSWebSocketURL |
-| `endpoint_test.go` | Builders, registry, invocation |
-| `api_test.go` | Request handling, policy execution |
-| `auth_test.go` | Auth and sync clients with mock HTTP |
-| `middleware_test.go` | Middleware chain behavior |
-
-### 0.2 Safety Rules
-- Each commit leaves code in working state
-- Use Strangler Fig pattern: add new code alongside old, switch, remove old
-- Run tests after each step
-- Use `go test -race` to detect race conditions
-
----
-
-## Phase 1: P0 Critical Security Fixes
-
-### Step 1: Fix Token Verification (CRITICAL)
-
-**Problem**: `api.go:565-580` returns hardcoded user for ANY non-empty token, bypassing all authentication.
-
-**Files Changed**: `api.go`
-
-**Changes**:
-
-1. Add `authClient` field to `SyftAPI` struct:
-```go
-type SyftAPI struct {
- // ... existing fields
- authClient *AuthClient // NEW
-}
-```
-
-2. Initialize in `New()`:
-```go
-func New(opts ...Option) *SyftAPI {
- // ... existing setup
- slogLogger := NewSlogLogger(logger)
- authClient := NewAuthClient(config.SyftHubURL, config.APIKey, slogLogger)
-
- return &SyftAPI{
- // ... existing fields
- authClient: authClient,
- }
-}
-```
-
-3. Replace `verifyToken` implementation:
-```go
-func (api *SyftAPI) verifyToken(ctx context.Context, token string) (*UserContext, error) {
- if api.authClient == nil {
- return nil, &AuthenticationError{Message: "auth client not initialized"}
- }
- return api.authClient.VerifyToken(ctx, token)
-}
-```
-
-**Risk**: Medium - Changes authentication behavior
-**Test**: Verify with real backend or mock AuthClient
-
----
-
-### Step 2: Fix Race Condition in Policy Execution
-
-**Problem**: `runPreExecutePolicies` and `runPostExecutePolicies` read `globalPolicies` without holding a lock while `AddPolicy` writes with a lock.
-
-**Files Changed**: `api.go`
-
-**Changes**:
-
-1. Update `runPreExecutePolicies` (line 583):
-```go
-func (api *SyftAPI) runPreExecutePolicies(ctx context.Context, reqCtx *RequestContext, endpoint *Endpoint) error {
- // Copy reference under read lock
- api.mu.RLock()
- policies := api.globalPolicies
- api.mu.RUnlock()
-
- // Run global policies first
- for _, p := range policies {
- if err := p.PreExecute(ctx, reqCtx); err != nil {
- return &PolicyDeniedError{
- Policy: p.Name(),
- Reason: err.Error(),
- User: reqCtx.User.Username,
- Endpoint: endpoint.Slug,
- }
- }
- }
-
- // Endpoint policies don't need lock (immutable after registration)
- for _, p := range endpoint.policies {
- // ... existing logic
- }
-
- return nil
-}
-```
-
-2. Update `runPostExecutePolicies` (line 612) similarly.
-
-**Risk**: Low - Adds safety without changing behavior
-**Test**: Run with `go test -race ./...`
-
----
-
-## Phase 2: P1 Correctness Fixes
-
-### Step 3: Replace panic() with Error Return
-
-**Problem**: `DeriveNATSWebSocketURL` panics on invalid input instead of returning error.
-
-**Files Changed**: `config.go`, `auth.go`
-
-**Changes in config.go**:
-
-```go
-// DeriveNATSWebSocketURL derives the NATS WebSocket URL from a SyftHub URL.
-// Returns error if URL scheme is not http:// or https://.
-func DeriveNATSWebSocketURL(syfthubURL string) (string, error) {
- if strings.HasPrefix(syfthubURL, "https://") {
- host := strings.TrimRight(syfthubURL[len("https://"):], "/")
- if !strings.Contains(host, ":") {
- host += ":443"
- }
- return "wss://" + host, nil
- }
- if strings.HasPrefix(syfthubURL, "http://") {
- host := strings.TrimRight(syfthubURL[len("http://"):], "/")
- if !strings.Contains(host, ":") {
- host += ":80"
- }
- return "ws://" + host, nil
- }
- return "", fmt.Errorf("cannot derive NATS URL from %q: must start with http:// or https://", syfthubURL)
-}
-```
-
-**Changes in auth.go** (line 178):
-```go
-func (c *AuthClient) GetNATSCredentials(ctx context.Context, username string) (*NATSCredentials, error) {
- // ... existing code
-
- natsURL, err := DeriveNATSWebSocketURL(c.baseURL)
- if err != nil {
- return nil, &AuthenticationError{
- Message: "failed to derive NATS URL",
- Cause: err,
- }
- }
-
- // ... rest of function
-}
-```
-
-**Risk**: Low - API change but callers updated
-**Test**: Unit test with invalid URLs
-
----
-
-### Step 4: Handle LoadFromEnv Error
-
-**Problem**: Error from `config.LoadFromEnv()` is silently ignored.
-
-**Files Changed**: `api.go`
-
-**Changes** (line 86):
-```go
-func New(opts ...Option) *SyftAPI {
- config := DefaultConfig()
-
- // Log warning but don't fail - env vars are optional
- if err := config.LoadFromEnv(); err != nil {
- slog.Warn("failed to load config from environment", "error", err)
- }
-
- // ... rest of function
-}
-```
-
-**Risk**: Very low
-**Test**: Set invalid env var, check warning logged
-
----
-
-### Step 5: Add Comprehensive Unit Tests
-
-**New Files**:
-- `config_test.go`
-- `endpoint_test.go`
-- `api_test.go`
-- `auth_test.go`
-- `middleware_test.go`
-
-Each test file should cover:
-- Happy path
-- Error cases
-- Edge cases
-- Concurrency (where applicable)
-
----
-
-## Phase 3: P2 Design Improvements
-
-### Step 6: Create PolicyExecutor (Extract Class)
-
-**Problem**: SyftAPI is a God Object with too many responsibilities.
-
-**New File**: `policy_executor.go`
-
-```go
-package syfthubapi
-
-import (
- "context"
- "log/slog"
- "sync"
-)
-
-// PolicyExecutor manages policy evaluation for requests.
-type PolicyExecutor struct {
- globalPolicies []Policy
- mu sync.RWMutex
- logger *slog.Logger
-}
-
-// NewPolicyExecutor creates a new policy executor.
-func NewPolicyExecutor(logger *slog.Logger) *PolicyExecutor {
- return &PolicyExecutor{
- logger: logger,
- }
-}
-
-// AddGlobalPolicy adds a policy that applies to all endpoints.
-func (e *PolicyExecutor) AddGlobalPolicy(p Policy) {
- e.mu.Lock()
- defer e.mu.Unlock()
- e.globalPolicies = append(e.globalPolicies, p)
-}
-
-// GlobalPolicies returns a copy of global policies (thread-safe).
-func (e *PolicyExecutor) GlobalPolicies() []Policy {
- e.mu.RLock()
- defer e.mu.RUnlock()
- result := make([]Policy, len(e.globalPolicies))
- copy(result, e.globalPolicies)
- return result
-}
-
-// RunPreExecute runs pre-execution policies.
-func (e *PolicyExecutor) RunPreExecute(ctx context.Context, reqCtx *RequestContext, endpoint *Endpoint) error {
- e.mu.RLock()
- policies := e.globalPolicies
- e.mu.RUnlock()
-
- // Run global policies first
- for _, p := range policies {
- if err := p.PreExecute(ctx, reqCtx); err != nil {
- return &PolicyDeniedError{
- Policy: p.Name(),
- Reason: err.Error(),
- User: reqCtx.User.Username,
- Endpoint: endpoint.Slug,
- }
- }
- }
-
- // Run endpoint-specific policies
- for _, p := range endpoint.policies {
- if err := p.PreExecute(ctx, reqCtx); err != nil {
- return &PolicyDeniedError{
- Policy: p.Name(),
- Reason: err.Error(),
- User: reqCtx.User.Username,
- Endpoint: endpoint.Slug,
- }
- }
- }
-
- return nil
-}
-
-// RunPostExecute runs post-execution policies in reverse order.
-func (e *PolicyExecutor) RunPostExecute(ctx context.Context, reqCtx *RequestContext, endpoint *Endpoint, result any) error {
- // Run endpoint policies in reverse order
- for i := len(endpoint.policies) - 1; i >= 0; i-- {
- p := endpoint.policies[i]
- if err := p.PostExecute(ctx, reqCtx, result); err != nil {
- return &PolicyDeniedError{
- Policy: p.Name(),
- Reason: err.Error(),
- User: reqCtx.User.Username,
- Endpoint: endpoint.Slug,
- }
- }
- }
-
- // Run global policies in reverse order
- e.mu.RLock()
- policies := e.globalPolicies
- e.mu.RUnlock()
-
- for i := len(policies) - 1; i >= 0; i-- {
- p := policies[i]
- if err := p.PostExecute(ctx, reqCtx, result); err != nil {
- return &PolicyDeniedError{
- Policy: p.Name(),
- Reason: err.Error(),
- User: reqCtx.User.Username,
- Endpoint: endpoint.Slug,
- }
- }
- }
-
- return nil
-}
-```
-
----
-
-### Step 7: Create RequestProcessor (Extract Class)
-
-**New File**: `processor.go`
-
-```go
-package syfthubapi
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "log/slog"
- "time"
-)
-
-// RequestProcessor handles the execution of endpoint requests.
-type RequestProcessor struct {
- registry *EndpointRegistry
- policyExecutor *PolicyExecutor
- authClient *AuthClient
- logger *slog.Logger
-}
-
-// ProcessorConfig holds configuration for RequestProcessor.
-type ProcessorConfig struct {
- Registry *EndpointRegistry
- PolicyExecutor *PolicyExecutor
- AuthClient *AuthClient
- Logger *slog.Logger
-}
-
-// NewRequestProcessor creates a new request processor.
-func NewRequestProcessor(cfg *ProcessorConfig) *RequestProcessor {
- return &RequestProcessor{
- registry: cfg.Registry,
- policyExecutor: cfg.PolicyExecutor,
- authClient: cfg.AuthClient,
- logger: cfg.Logger,
- }
-}
-
-// Process handles an incoming tunnel request.
-func (p *RequestProcessor) Process(ctx context.Context, req *TunnelRequest) (*TunnelResponse, error) {
- startTime := time.Now()
-
- p.logger.Debug("processing request",
- "correlation_id", req.CorrelationID,
- "endpoint", req.Endpoint.Slug,
- "endpoint_type", req.Endpoint.Type,
- )
-
- // Create request context
- reqCtx := NewRequestContext()
- reqCtx.EndpointSlug = req.Endpoint.Slug
- reqCtx.EndpointType = EndpointType(req.Endpoint.Type)
-
- // Verify token
- userCtx, err := p.authClient.VerifyToken(ctx, req.SatelliteToken)
- if err != nil {
- return p.errorResponse(req, TunnelErrorCodeAuthFailed, err.Error()), nil
- }
- reqCtx.User = userCtx
-
- // Get endpoint
- endpoint, ok := p.registry.Get(req.Endpoint.Slug)
- if !ok {
- return p.errorResponse(req, TunnelErrorCodeEndpointNotFound,
- fmt.Sprintf("endpoint not found: %s", req.Endpoint.Slug)), nil
- }
-
- if !endpoint.Enabled {
- return p.errorResponse(req, TunnelErrorCodeEndpointDisabled,
- fmt.Sprintf("endpoint disabled: %s", req.Endpoint.Slug)), nil
- }
-
- // Run pre-execution policies
- if err := p.policyExecutor.RunPreExecute(ctx, reqCtx, endpoint); err != nil {
- return p.errorResponse(req, TunnelErrorCodePolicyDenied, err.Error()), nil
- }
-
- // Execute handler using invoker pattern
- result, err := p.invokeEndpoint(ctx, req, endpoint, reqCtx)
- if err != nil {
- return p.errorResponse(req, TunnelErrorCodeExecutionFailed, err.Error()), nil
- }
-
- // Run post-execution policies
- reqCtx.Output = result
- if err := p.policyExecutor.RunPostExecute(ctx, reqCtx, endpoint, result); err != nil {
- return p.errorResponse(req, TunnelErrorCodePolicyDenied, err.Error()), nil
- }
-
- // Serialize response
- payload, err := json.Marshal(result)
- if err != nil {
- return p.errorResponse(req, TunnelErrorCodeInternalError,
- fmt.Sprintf("failed to serialize response: %v", err)), nil
- }
-
- processedAt := time.Now()
- return &TunnelResponse{
- Protocol: "syfthub-tunnel/v1",
- Type: "endpoint_response",
- CorrelationID: req.CorrelationID,
- Status: "success",
- EndpointSlug: req.Endpoint.Slug,
- Payload: payload,
- Timing: &TunnelTiming{
- ReceivedAt: startTime,
- ProcessedAt: processedAt,
- DurationMs: processedAt.Sub(startTime).Milliseconds(),
- },
- }, nil
-}
-
-// invokeEndpoint executes the endpoint handler based on type.
-func (p *RequestProcessor) invokeEndpoint(ctx context.Context, req *TunnelRequest, endpoint *Endpoint, reqCtx *RequestContext) (any, error) {
- endpointType := EndpointType(req.Endpoint.Type)
-
- switch endpointType {
- case EndpointTypeDataSource:
- var dsReq DataSourceQueryRequest
- if err := json.Unmarshal(req.Payload, &dsReq); err != nil {
- return nil, fmt.Errorf("invalid request payload: %w", err)
- }
- reqCtx.Input = dsReq.GetQuery()
- docs, err := endpoint.InvokeDataSource(ctx, dsReq.GetQuery(), reqCtx)
- if err != nil {
- return nil, err
- }
- return DataSourceQueryResponse{
- References: DataSourceReferences{Documents: docs},
- }, nil
-
- case EndpointTypeModel:
- var modelReq ModelQueryRequest
- if err := json.Unmarshal(req.Payload, &modelReq); err != nil {
- return nil, fmt.Errorf("invalid request payload: %w", err)
- }
- reqCtx.Input = modelReq.Messages
- response, err := endpoint.InvokeModel(ctx, modelReq.Messages, reqCtx)
- if err != nil {
- return nil, err
- }
- return ModelQueryResponse{
- Summary: ModelSummary{
- Message: ModelSummaryMessage{Content: response},
- },
- }, nil
-
- default:
- return nil, fmt.Errorf("unknown endpoint type: %s", req.Endpoint.Type)
- }
-}
-
-// errorResponse creates an error tunnel response.
-func (p *RequestProcessor) errorResponse(req *TunnelRequest, code TunnelErrorCode, message string) *TunnelResponse {
- p.logger.Debug("returning error response",
- "correlation_id", req.CorrelationID,
- "code", code,
- "message", message,
- )
- return &TunnelResponse{
- Protocol: "syfthub-tunnel/v1",
- Type: "endpoint_response",
- CorrelationID: req.CorrelationID,
- Status: "error",
- EndpointSlug: req.Endpoint.Slug,
- Error: &TunnelError{
- Code: code,
- Message: message,
- },
- }
-}
-```
-
----
-
-### Step 8: Update SyftAPI to Use Extracted Components
-
-**File Changed**: `api.go`
-
-**Updated struct**:
-```go
-type SyftAPI struct {
- config *Config
- logger *slog.Logger
- registry *EndpointRegistry
- transport Transport
- heartbeatManager HeartbeatManager
- fileProvider FileProvider
-
- // Extracted components
- processor *RequestProcessor
- policyExecutor *PolicyExecutor
- authClient *AuthClient
- syncClient *SyncClient
-
- // Lifecycle
- middleware []Middleware
- startupHooks []LifecycleHook
- shutdownHooks []LifecycleHook
- shutdownCh chan struct{}
- shutdownWg sync.WaitGroup
-
- mu sync.RWMutex // For middleware/hooks only
-}
-```
-
-**Updated New()**:
-```go
-func New(opts ...Option) *SyftAPI {
- config := DefaultConfig()
- if err := config.LoadFromEnv(); err != nil {
- slog.Warn("failed to load config from environment", "error", err)
- }
-
- for _, opt := range opts {
- opt(config)
- }
-
- logger := setupLogger(config.LogLevel)
- slogLogger := NewSlogLogger(logger)
-
- registry := NewEndpointRegistry()
- authClient := NewAuthClient(config.SyftHubURL, config.APIKey, slogLogger)
- syncClient := NewSyncClient(config.SyftHubURL, config.APIKey, slogLogger)
- policyExecutor := NewPolicyExecutor(logger)
-
- processor := NewRequestProcessor(&ProcessorConfig{
- Registry: registry,
- PolicyExecutor: policyExecutor,
- AuthClient: authClient,
- Logger: logger,
- })
-
- return &SyftAPI{
- config: config,
- logger: logger,
- registry: registry,
- authClient: authClient,
- syncClient: syncClient,
- policyExecutor: policyExecutor,
- processor: processor,
- shutdownCh: make(chan struct{}),
- }
-}
-```
-
-**Delegate methods**:
-```go
-func (api *SyftAPI) AddPolicy(policy Policy) {
- api.policyExecutor.AddGlobalPolicy(policy)
-}
-
-func (api *SyftAPI) handleRequest(ctx context.Context, req *TunnelRequest) (*TunnelResponse, error) {
- return api.processor.Process(ctx, req)
-}
-```
-
----
-
-### Step 9: Remove Duplicate Policy Interface
-
-**File Changed**: `policy/policy.go`
-
-**Changes**:
-```go
-package policy
-
-import (
- "context"
- "github.com/openmined/syfthub/sdk/golang/syfthubapi"
-)
-
-// Policy is an alias for the canonical Policy interface in syfthubapi.
-type Policy = syfthubapi.Policy
-
-// Compile-time interface checks
-var (
- _ syfthubapi.Policy = (*BasePolicy)(nil)
- _ syfthubapi.Policy = (*CompositePolicy)(nil)
- _ syfthubapi.Policy = (*NotPolicy)(nil)
-)
-
-// ... rest of file unchanged
-```
-
----
-
-### Step 10: Delete WorkerPoolExecutor (YAGNI)
-
-**File Changed**: `filemode/executor.go`
-
-**Delete lines 228-330** (WorkerPoolExecutor and related types).
-
-**Update CreateExecutor**:
-```go
-func CreateExecutor(cfg *ExecutorConfig, runtime *RuntimeConfig) (syfthubapi.Executor, error) {
- venvPython := filepath.Join(cfg.WorkDir, ".venv", "bin", "python")
- if _, err := os.Stat(venvPython); err == nil {
- cfg.PythonPath = venvPython
- }
-
- if runtime.Mode != "" && runtime.Mode != "subprocess" {
- cfg.Logger.Warn("unsupported runtime mode, using subprocess", "mode", runtime.Mode)
- }
-
- return NewSubprocessExecutor(cfg)
-}
-```
-
----
-
-## Dependency Graph
-
-```
-Step 1 (auth fix) ─┐
-Step 2 (race fix) ─┼─→ Step 6 (PolicyExecutor) ─→ Step 7 (RequestProcessor) ─┬→ Step 9 (invokers)
-Step 3 (panic fix) ─┘ └→ Step 10 (inject sync)
-Step 4 (env error) ─→ independent
-Step 5 (tests) ─→ continuous
-Step 8 (Policy iface) ─→ independent
-```
-
-**Critical Path**: Steps 1, 2 → Step 6 → Step 7 → Steps 9, 10
-
----
-
-## Testing Strategy
-
-After each step:
-1. Run `go build ./...` - Compile check
-2. Run `go test ./...` - Unit tests
-3. Run `go test -race ./...` - Race detection
-4. Manual test with example app
-
----
-
-## Rollback Plan
-
-Each step is a separate commit. If issues arise:
-1. `git revert ` for the problematic step
-2. Fix the issue
-3. Re-apply
-
----
-
-## Success Criteria
-
-- [ ] All tests pass
-- [ ] No race conditions detected
-- [ ] Token verification uses real AuthClient
-- [ ] SyftAPI struct has ≤10 fields
-- [ ] No panics in normal code paths
-- [ ] All P0/P1/P2 issues resolved
diff --git a/sdk/python/README.md b/sdk/python/README.md
deleted file mode 100644
index 3a22d42a..00000000
--- a/sdk/python/README.md
+++ /dev/null
@@ -1,203 +0,0 @@
-# SyftHub SDK
-
-Python SDK for interacting with the SyftHub API programmatically.
-
-## Installation
-
-```bash
-# Using pip
-pip install syfthub-sdk
-
-# Using uv
-uv add syfthub-sdk
-
-# From source
-cd sdk
-uv sync
-```
-
-## Quick Start
-
-```python
-from syfthub_sdk import SyftHubClient
-
-# Initialize client
-client = SyftHubClient(base_url="https://hub.syft.com")
-
-# Register a new user
-user = client.auth.register(
- username="john",
- email="john@example.com",
- password="secret123",
- full_name="John Doe"
-)
-
-# Login
-user = client.auth.login(username="john", password="secret123")
-print(f"Logged in as {user.username}")
-
-# Get current user
-me = client.auth.me()
-```
-
-## Managing Your Endpoints
-
-```python
-# List your endpoints (with lazy pagination)
-for endpoint in client.my_endpoints.list():
- print(f"{endpoint.name} ({endpoint.visibility})")
-
-# Get just the first page
-first_page = client.my_endpoints.list().first_page()
-
-# Create an endpoint
-endpoint = client.my_endpoints.create(
- name="My Cool API",
- visibility="public",
- description="A really cool API",
- readme="# My API\n\nThis is my API documentation."
-)
-print(f"Created: {endpoint.slug}")
-
-# Update an endpoint
-endpoint = client.my_endpoints.update(
- endpoint_id=endpoint.id,
- description="Updated description"
-)
-
-# Delete an endpoint
-client.my_endpoints.delete(endpoint_id=endpoint.id)
-```
-
-## Browsing the Hub
-
-```python
-# Browse public endpoints
-for endpoint in client.hub.browse():
- print(f"{endpoint.path}: {endpoint.name}")
-
-# Get trending endpoints
-for endpoint in client.hub.trending(min_stars=10):
- print(f"{endpoint.name} - {endpoint.stars_count} stars")
-
-# Get a specific endpoint by path
-endpoint = client.hub.get("alice/cool-api")
-print(endpoint.readme)
-
-# Star/unstar endpoints (requires auth)
-client.hub.star("alice/cool-api")
-client.hub.unstar("alice/cool-api")
-
-# Check if you've starred an endpoint
-if client.hub.is_starred("alice/cool-api"):
- print("You've starred this!")
-```
-
-## User Profile
-
-```python
-# Update profile
-user = client.users.update(
- full_name="John D.",
- avatar_url="https://example.com/avatar.png"
-)
-
-# Check username availability
-if client.users.check_username("newusername"):
- print("Username is available!")
-
-# Change password
-client.auth.change_password(
- current_password="old123",
- new_password="new456"
-)
-```
-
-## Accounting
-
-```python
-# Get account balance
-balance = client.accounting.balance()
-print(f"Credits: {balance.credits} {balance.currency}")
-
-# List transactions
-for tx in client.accounting.transactions():
- print(f"{tx.created_at}: {tx.amount} - {tx.description}")
-```
-
-## Token Persistence
-
-```python
-# Get tokens for saving
-tokens = client.get_tokens()
-if tokens:
- # Save to file, database, etc.
- save_tokens(tokens.access_token, tokens.refresh_token)
-
-# Later, restore session
-from syfthub_sdk import AuthTokens
-
-tokens = AuthTokens(
- access_token=load_access_token(),
- refresh_token=load_refresh_token()
-)
-client.set_tokens(tokens)
-```
-
-## Environment Variables
-
-| Variable | Description |
-|----------|-------------|
-| `SYFTHUB_URL` | SyftHub API base URL |
-
-## Error Handling
-
-```python
-from syfthub_sdk import (
- SyftHubError,
- AuthenticationError,
- AuthorizationError,
- NotFoundError,
- ValidationError,
- ConfigurationError,
-)
-
-try:
- client.auth.login(username="john", password="wrong")
-except AuthenticationError as e:
- print(f"Login failed: {e}")
-except SyftHubError as e:
- print(f"API error [{e.status_code}]: {e.message}")
-```
-
-## Context Manager
-
-```python
-with SyftHubClient(base_url="https://hub.syft.com") as client:
- client.auth.login(username="john", password="secret123")
- # ... do work ...
-# Client is automatically closed
-```
-
-## Pagination
-
-All list methods return a `PageIterator` for lazy pagination:
-
-```python
-# Iterate through all items (fetches pages as needed)
-for endpoint in client.my_endpoints.list():
- print(endpoint.name)
-
-# Get just the first page
-first_page = client.my_endpoints.list().first_page()
-
-# Get all items as a list
-all_items = client.my_endpoints.list().all()
-
-# Get first N items
-top_10 = client.my_endpoints.list().take(10)
-```
-
-## License
-
-MIT
diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml
index 42866a0a..8d0af970 100644
--- a/sdk/python/pyproject.toml
+++ b/sdk/python/pyproject.toml
@@ -2,7 +2,6 @@
name = "syfthub-sdk"
version = "0.1.1"
description = "Python SDK for interacting with SyftHub API"
-readme = "README.md"
license = { text = "Apache-2.0" }
requires-python = ">=3.10"
authors = [{ name = "SyftHub Team" }]
diff --git a/sdk/python/src/syfthub_sdk/accounting.py b/sdk/python/src/syfthub_sdk/accounting.py
index 3af2e9a1..dc63377c 100644
--- a/sdk/python/src/syfthub_sdk/accounting.py
+++ b/sdk/python/src/syfthub_sdk/accounting.py
@@ -150,14 +150,19 @@ def _request(
*,
json: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
+ token: str | None = None,
) -> dict[str, Any] | list[Any]:
- """Make an authenticated request to the accounting service.
+ """Make a request to the accounting service.
+
+ When `token` is provided, a per-request Bearer header overrides the
+ client's Basic auth for that call (used for delegated transactions).
Args:
method: HTTP method (GET, POST, PUT, DELETE, etc.)
path: API path (e.g., "/user", "/transactions")
json: JSON body for POST/PUT requests
params: Query parameters
+ token: Optional Bearer token overriding Basic auth for this call
Returns:
Parsed JSON response
@@ -168,9 +173,12 @@ def _request(
APIError: On other errors
"""
client = self._get_client()
+ headers = {"Authorization": f"Bearer {token}"} if token else None
try:
- response = client.request(method, path, json=json, params=params)
+ response = client.request(
+ method, path, json=json, params=params, headers=headers
+ )
_handle_response_error(response)
if response.status_code == 204:
@@ -181,47 +189,6 @@ def _request(
except httpx.RequestError as e:
raise APIError(f"Accounting request failed: {e}") from e
- def _request_with_token(
- self,
- method: str,
- path: str,
- token: str,
- *,
- json: dict[str, Any] | None = None,
- ) -> dict[str, Any] | list[Any]:
- """Make a request using Bearer token auth (for delegated transactions).
-
- Args:
- method: HTTP method
- path: API path
- token: Bearer token for authentication
- json: JSON body
-
- Returns:
- Parsed JSON response
- """
- try:
- # Create a separate client without Basic auth
- with httpx.Client(
- base_url=self._url,
- timeout=self._timeout,
- ) as client:
- response = client.request(
- method,
- path,
- json=json,
- headers={"Authorization": f"Bearer {token}"},
- )
- _handle_response_error(response)
-
- if response.status_code == 204:
- return {}
-
- return response.json() # type: ignore[no-any-return]
-
- except httpx.RequestError as e:
- raise APIError(f"Accounting request failed: {e}") from e
-
# =========================================================================
# User Operations
# =========================================================================
@@ -554,14 +521,14 @@ def create_delegated_transaction(
if amount <= 0:
raise ValidationError("Amount must be greater than 0")
- response = self._request_with_token(
+ response = self._request(
"POST",
"/transactions",
- token,
json={
"senderEmail": sender_email,
"amount": amount,
},
+ token=token,
)
data = response if isinstance(response, dict) else {}
return Transaction.model_validate(data)
diff --git a/sdk/python/src/syfthub_sdk/auth.py b/sdk/python/src/syfthub_sdk/auth.py
index dfcd65d1..409108ac 100644
--- a/sdk/python/src/syfthub_sdk/auth.py
+++ b/sdk/python/src/syfthub_sdk/auth.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import concurrent.futures
+from collections.abc import Callable
from typing import TYPE_CHECKING
from syfthub_sdk.models import (
@@ -404,25 +405,14 @@ def get_satellite_token(self, audience: str) -> SatelliteTokenResponse:
data = response if isinstance(response, dict) else {}
return SatelliteTokenResponse.model_validate(data)
- def get_satellite_tokens(self, audiences: list[str]) -> dict[str, str]:
- """Get satellite tokens for multiple audiences in parallel.
-
- This is useful when making requests to endpoints owned by different users.
- Tokens are cached and reused where possible.
-
- Args:
- audiences: List of audience identifiers (usernames)
-
- Returns:
- Dict mapping audience to satellite token
-
- Raises:
- AuthenticationError: If not authenticated
+ def _parallel_fetch_tokens(
+ self,
+ audiences: list[str],
+ fetch_one: Callable[[str], SatelliteTokenResponse],
+ ) -> dict[str, str]:
+ """Fetch tokens for multiple audiences in parallel.
- Example:
- # Get tokens for multiple endpoint owners
- tokens = client.auth.get_satellite_tokens(["alice", "bob"])
- print(f"Got {len(tokens)} tokens")
+ Failures are silently skipped — the aggregator handles missing tokens.
"""
unique_audiences = list(set(audiences))
token_map: dict[str, str] = {}
@@ -430,28 +420,45 @@ def get_satellite_tokens(self, audiences: list[str]) -> dict[str, str]:
if not unique_audiences:
return token_map
- def fetch_token(aud: str) -> tuple[str, str | None]:
- """Fetch a single token, returning None on failure."""
+ def fetch(aud: str) -> tuple[str, str | None]:
try:
- response = self.get_satellite_token(aud)
- return (aud, response.target_token)
+ return (aud, fetch_one(aud).target_token)
except Exception:
- # Failed tokens are silently skipped - the aggregator will handle missing tokens
return (aud, None)
- # Fetch tokens in parallel using ThreadPoolExecutor
with concurrent.futures.ThreadPoolExecutor(
max_workers=min(len(unique_audiences), 10)
) as executor:
- results = list(executor.map(fetch_token, unique_audiences))
+ results = list(executor.map(fetch, unique_audiences))
- # Collect successful results
for aud, token in results:
if token is not None:
token_map[aud] = token
return token_map
+ def get_satellite_tokens(self, audiences: list[str]) -> dict[str, str]:
+ """Get satellite tokens for multiple audiences in parallel.
+
+ This is useful when making requests to endpoints owned by different users.
+ Tokens are cached and reused where possible.
+
+ Args:
+ audiences: List of audience identifiers (usernames)
+
+ Returns:
+ Dict mapping audience to satellite token
+
+ Raises:
+ AuthenticationError: If not authenticated
+
+ Example:
+ # Get tokens for multiple endpoint owners
+ tokens = client.auth.get_satellite_tokens(["alice", "bob"])
+ print(f"Got {len(tokens)} tokens")
+ """
+ return self._parallel_fetch_tokens(audiences, self.get_satellite_token)
+
def get_guest_satellite_token(self, audience: str) -> SatelliteTokenResponse:
"""Get a guest satellite token for a specific audience without authentication.
@@ -480,29 +487,7 @@ def get_guest_satellite_tokens(self, audiences: list[str]) -> dict[str, str]:
Returns:
Dict mapping audience to satellite token
"""
- unique_audiences = list(set(audiences))
- token_map: dict[str, str] = {}
-
- if not unique_audiences:
- return token_map
-
- def fetch_token(aud: str) -> tuple[str, str | None]:
- try:
- response = self.get_guest_satellite_token(aud)
- return (aud, response.target_token)
- except Exception:
- return (aud, None)
-
- with concurrent.futures.ThreadPoolExecutor(
- max_workers=min(len(unique_audiences), 10)
- ) as executor:
- results = list(executor.map(fetch_token, unique_audiences))
-
- for aud, token in results:
- if token is not None:
- token_map[aud] = token
-
- return token_map
+ return self._parallel_fetch_tokens(audiences, self.get_guest_satellite_token)
def get_peer_token(self, target_usernames: list[str]) -> PeerTokenResponse:
"""Get a peer token for NATS communication with tunneling spaces.
diff --git a/sdk/python/src/syfthub_sdk/chat.py b/sdk/python/src/syfthub_sdk/chat.py
index ab8f2973..6e1c0677 100644
--- a/sdk/python/src/syfthub_sdk/chat.py
+++ b/sdk/python/src/syfthub_sdk/chat.py
@@ -242,6 +242,10 @@ def __init__(
# Separate client for aggregator with longer timeout (LLM can be slow)
self._agg_client = httpx.Client(timeout=120.0)
+ def close(self) -> None:
+ """Close the aggregator HTTP client."""
+ self._agg_client.close()
+
@staticmethod
def _type_matches(actual_type: str, expected_type: str) -> bool:
"""Check if an endpoint type matches the expected type.
diff --git a/sdk/python/src/syfthub_sdk/client.py b/sdk/python/src/syfthub_sdk/client.py
index 0e8b8eb7..92ba1577 100644
--- a/sdk/python/src/syfthub_sdk/client.py
+++ b/sdk/python/src/syfthub_sdk/client.py
@@ -341,6 +341,10 @@ def close(self) -> None:
self._http.close()
if self._accounting is not None:
self._accounting.close()
+ if self._chat is not None:
+ self._chat.close()
+ if self._syftai is not None:
+ self._syftai.close()
def __enter__(self) -> Self:
"""Enter context manager."""
diff --git a/sdk/python/src/syfthub_sdk/syftai.py b/sdk/python/src/syfthub_sdk/syftai.py
index 8e9a7460..620c38c8 100644
--- a/sdk/python/src/syfthub_sdk/syftai.py
+++ b/sdk/python/src/syfthub_sdk/syftai.py
@@ -98,6 +98,10 @@ def __init__(
# Client for SyftAI-Space with reasonable timeout
self._client = httpx.Client(timeout=60.0)
+ def close(self) -> None:
+ """Close the SyftAI-Space HTTP client."""
+ self._client.close()
+
def _build_headers(
self,
tenant_name: str | None = None,
@@ -110,6 +114,54 @@ def _build_headers(
headers["X-Tenant-Name"] = tenant_name
return headers
+ @staticmethod
+ def _endpoint_query_url(endpoint: EndpointRef) -> str:
+ return f"{endpoint.url.rstrip('/')}/api/v1/endpoints/{endpoint.slug}/query"
+
+ @staticmethod
+ def _extract_error_message(response: httpx.Response) -> str:
+ """Extract a human-readable error message from a response body."""
+ try:
+ error_data = response.json()
+ return str(
+ error_data.get("detail", error_data.get("message", str(error_data)))
+ )
+ except Exception:
+ return response.text or f"HTTP {response.status_code}"
+
+ def _post_endpoint(
+ self,
+ endpoint: EndpointRef,
+ body: dict[str, object],
+ *,
+ error_cls: type[RetrievalError | GenerationError],
+ error_prefix: str,
+ **error_kwargs: str,
+ ) -> httpx.Response:
+ """POST to an endpoint, mapping connection/HTTP errors to error_cls."""
+ try:
+ response = self._client.post(
+ self._endpoint_query_url(endpoint),
+ json=body,
+ headers=self._build_headers(endpoint.tenant_name),
+ )
+ except httpx.RequestError as e:
+ raise error_cls(
+ f"Failed to connect to {error_prefix} '{endpoint.slug}': {e}",
+ detail=str(e),
+ **error_kwargs,
+ ) from e
+
+ if response.status_code >= 400:
+ message = self._extract_error_message(response)
+ raise error_cls(
+ f"{error_prefix.capitalize()} query failed: {message}",
+ detail=response.text,
+ **error_kwargs,
+ )
+
+ return response
+
def query_data_source(
self,
endpoint: EndpointRef,
@@ -146,8 +198,6 @@ def query_data_source(
for doc in docs:
print(f"[{doc.score:.2f}] {doc.content[:100]}...")
"""
- url = f"{endpoint.url.rstrip('/')}/api/v1/endpoints/{endpoint.slug}/query"
-
request_body = {
"user_email": user_email,
"messages": query, # SyftAI-Space expects "messages" for query text
@@ -155,33 +205,13 @@ def query_data_source(
"similarity_threshold": similarity_threshold,
}
- try:
- response = self._client.post(
- url,
- json=request_body,
- headers=self._build_headers(endpoint.tenant_name),
- )
- except httpx.RequestError as e:
- raise RetrievalError(
- f"Failed to connect to data source '{endpoint.slug}': {e}",
- source_path=endpoint.slug,
- detail=str(e),
- ) from e
-
- if response.status_code >= 400:
- try:
- error_data = response.json()
- message = error_data.get(
- "detail", error_data.get("message", str(error_data))
- )
- except Exception:
- message = response.text or f"HTTP {response.status_code}"
-
- raise RetrievalError(
- f"Data source query failed: {message}",
- source_path=endpoint.slug,
- detail=response.text,
- )
+ response = self._post_endpoint(
+ endpoint,
+ request_body,
+ error_cls=RetrievalError,
+ error_prefix="data source",
+ source_path=endpoint.slug,
+ )
data = response.json()
documents = []
@@ -235,8 +265,6 @@ def query_model(
)
print(response)
"""
- url = f"{endpoint.url.rstrip('/')}/api/v1/endpoints/{endpoint.slug}/query"
-
request_body = {
"user_email": user_email,
"messages": [
@@ -247,33 +275,13 @@ def query_model(
"stream": False,
}
- try:
- response = self._client.post(
- url,
- json=request_body,
- headers=self._build_headers(endpoint.tenant_name),
- )
- except httpx.RequestError as e:
- raise GenerationError(
- f"Failed to connect to model '{endpoint.slug}': {e}",
- model_slug=endpoint.slug,
- detail=str(e),
- ) from e
-
- if response.status_code >= 400:
- try:
- error_data = response.json()
- message = error_data.get(
- "detail", error_data.get("message", str(error_data))
- )
- except Exception:
- message = response.text or f"HTTP {response.status_code}"
-
- raise GenerationError(
- f"Model query failed: {message}",
- model_slug=endpoint.slug,
- detail=response.text,
- )
+ response = self._post_endpoint(
+ endpoint,
+ request_body,
+ error_cls=GenerationError,
+ error_prefix="model",
+ model_slug=endpoint.slug,
+ )
data = response.json()
@@ -317,8 +325,6 @@ def query_model_stream(
):
print(chunk, end="", flush=True)
"""
- url = f"{endpoint.url.rstrip('/')}/api/v1/endpoints/{endpoint.slug}/query"
-
request_body = {
"user_email": user_email,
"messages": [
@@ -332,7 +338,7 @@ def query_model_stream(
try:
with self._client.stream(
"POST",
- url,
+ self._endpoint_query_url(endpoint),
json=request_body,
headers={
**self._build_headers(endpoint.tenant_name),
@@ -341,13 +347,7 @@ def query_model_stream(
) as response:
if response.status_code >= 400:
response.read()
- try:
- error_data = json.loads(response.text)
- message = error_data.get(
- "detail", error_data.get("message", str(error_data))
- )
- except Exception:
- message = response.text or f"HTTP {response.status_code}"
+ message = self._extract_error_message(response)
raise GenerationError(
f"Model stream failed: {message}",
diff --git a/sdk/python/uv.lock b/sdk/python/uv.lock
index 8d0cb631..46aad4e8 100644
--- a/sdk/python/uv.lock
+++ b/sdk/python/uv.lock
@@ -522,7 +522,7 @@ wheels = [
[[package]]
name = "pytest"
-version = "8.4.2"
+version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -533,9 +533,9 @@ dependencies = [
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md
deleted file mode 100644
index edca1adf..00000000
--- a/sdk/typescript/README.md
+++ /dev/null
@@ -1,276 +0,0 @@
-# SyftHub TypeScript SDK
-
-TypeScript SDK for interacting with the SyftHub API programmatically.
-
-## Installation
-
-```bash
-# Using npm
-npm install @syfthub/sdk
-
-# Using yarn
-yarn add @syfthub/sdk
-
-# Using pnpm
-pnpm add @syfthub/sdk
-```
-
-## Quick Start
-
-```typescript
-import { SyftHubClient } from '@syfthub/sdk';
-
-// Initialize client
-const client = new SyftHubClient({ baseUrl: 'https://hub.syft.com' });
-
-// Register a new user
-const user = await client.auth.register({
- username: 'john',
- email: 'john@example.com',
- password: 'secret123',
- fullName: 'John Doe',
-});
-
-// Login
-const loggedIn = await client.auth.login('john', 'secret123');
-console.log(`Logged in as ${loggedIn.username}`);
-
-// Get current user
-const me = await client.auth.me();
-```
-
-## Managing Your Endpoints
-
-```typescript
-import { EndpointType, Visibility } from '@syfthub/sdk';
-
-// List your endpoints (with lazy pagination)
-for await (const endpoint of client.myEndpoints.list()) {
- console.log(`${endpoint.name} (${endpoint.visibility})`);
-}
-
-// Get just the first page
-const firstPage = await client.myEndpoints.list().firstPage();
-
-// Create an endpoint
-const endpoint = await client.myEndpoints.create({
- name: 'My Cool API',
- type: EndpointType.MODEL,
- visibility: Visibility.PUBLIC,
- description: 'A really cool API',
- readme: '# My API\n\nThis is my API documentation.',
-});
-console.log(`Created: ${endpoint.slug}`);
-
-// Update an endpoint
-const updated = await client.myEndpoints.update('john/my-cool-api', {
- description: 'Updated description',
-});
-
-// Delete an endpoint
-await client.myEndpoints.delete('john/my-cool-api');
-```
-
-## Browsing the Hub
-
-```typescript
-// Browse public endpoints
-for await (const endpoint of client.hub.browse()) {
- console.log(`${endpoint.ownerUsername}/${endpoint.slug}: ${endpoint.name}`);
-}
-
-// Get trending endpoints
-for await (const endpoint of client.hub.trending({ minStars: 10 })) {
- console.log(`${endpoint.name} - ${endpoint.starsCount} stars`);
-}
-
-// Get a specific endpoint by path
-const endpoint = await client.hub.get('alice/cool-api');
-console.log(endpoint.readme);
-
-// Star/unstar endpoints (requires auth)
-await client.hub.star('alice/cool-api');
-await client.hub.unstar('alice/cool-api');
-
-// Check if you've starred an endpoint
-if (await client.hub.isStarred('alice/cool-api')) {
- console.log("You've starred this!");
-}
-```
-
-## User Profile
-
-```typescript
-// Update profile
-const user = await client.users.update({
- fullName: 'John D.',
- avatarUrl: 'https://example.com/avatar.png',
-});
-
-// Check username availability
-if (await client.users.checkUsername('newusername')) {
- console.log('Username is available!');
-}
-
-// Change password
-await client.auth.changePassword('old123', 'new456');
-```
-
-## Accounting
-
-```typescript
-// Get account balance
-const balance = await client.accounting.balance();
-console.log(`Credits: ${balance.credits} ${balance.currency}`);
-
-// List transactions
-for await (const tx of client.accounting.transactions()) {
- console.log(`${tx.createdAt}: ${tx.amount} - ${tx.description}`);
-}
-```
-
-## Token Persistence
-
-```typescript
-// Get tokens for saving
-const tokens = client.getTokens();
-if (tokens) {
- // Save to localStorage, database, etc.
- localStorage.setItem('syfthub_tokens', JSON.stringify(tokens));
-}
-
-// Later, restore session
-const saved = localStorage.getItem('syfthub_tokens');
-if (saved) {
- const tokens = JSON.parse(saved);
- client.setTokens(tokens);
-}
-
-// Check if authenticated
-if (client.isAuthenticated) {
- console.log('Session restored!');
-}
-```
-
-## Environment Variables
-
-| Variable | Description |
-|----------|-------------|
-| `SYFTHUB_URL` | SyftHub API base URL |
-
-## Error Handling
-
-```typescript
-import {
- SyftHubError,
- AuthenticationError,
- AuthorizationError,
- NotFoundError,
- ValidationError,
- NetworkError,
-} from '@syfthub/sdk';
-
-try {
- await client.auth.login('john', 'wrong');
-} catch (error) {
- if (error instanceof AuthenticationError) {
- console.log(`Login failed: ${error.message}`);
- } else if (error instanceof NotFoundError) {
- console.log('User not found');
- } else if (error instanceof ValidationError) {
- console.log(`Validation error: ${error.message}`);
- console.log('Field errors:', error.errors);
- } else if (error instanceof NetworkError) {
- console.log(`Network error: ${error.message}`);
- } else if (error instanceof SyftHubError) {
- console.log(`API error: ${error.message}`);
- }
-}
-```
-
-## Pagination
-
-All list methods return a `PageIterator` for lazy async pagination:
-
-```typescript
-// Iterate through all items (fetches pages as needed)
-for await (const endpoint of client.myEndpoints.list()) {
- console.log(endpoint.name);
-}
-
-// Get just the first page
-const firstPage = await client.myEndpoints.list().firstPage();
-
-// Get all items as an array (loads all into memory)
-const allItems = await client.myEndpoints.list().all();
-
-// Get first N items
-const top10 = await client.myEndpoints.list().take(10);
-```
-
-## TypeScript Support
-
-This SDK is written in TypeScript and provides full type safety:
-
-```typescript
-import {
- // Client
- SyftHubClient,
- SyftHubClientOptions,
-
- // Enums
- Visibility,
- EndpointType,
- UserRole,
-
- // Types
- User,
- Endpoint,
- EndpointPublic,
- Policy,
- Connection,
- AuthTokens,
-
- // Input types
- UserRegisterInput,
- EndpointCreateInput,
- EndpointUpdateInput,
-
- // Errors
- SyftHubError,
- AuthenticationError,
- ValidationError,
-
- // Utilities
- PageIterator,
- getEndpointPublicPath,
-} from '@syfthub/sdk';
-
-// All types are properly inferred
-const endpoint: Endpoint = await client.myEndpoints.create({
- name: 'My API',
- type: EndpointType.MODEL,
-});
-```
-
-## Comparison with Python SDK
-
-| Python | TypeScript |
-|--------|------------|
-| `client.auth.login(username, password)` | `client.auth.login(username, password)` |
-| `client.my_endpoints.list()` | `client.myEndpoints.list()` |
-| `for ep in client.hub.browse()` | `for await (const ep of client.hub.browse())` |
-| `client.get_tokens()` | `client.getTokens()` |
-| `client.set_tokens(tokens)` | `client.setTokens(tokens)` |
-| `client.is_authenticated` | `client.isAuthenticated` |
-
-The TypeScript SDK follows JavaScript/TypeScript conventions (camelCase) while providing the same functionality as the Python SDK.
-
-## Requirements
-
-- Node.js 18+ (for native `fetch` support)
-- Or any modern browser
-
-## License
-
-MIT
diff --git a/sdk/typescript/src/http.ts b/sdk/typescript/src/http.ts
index 7edf4a01..8cdf51b7 100644
--- a/sdk/typescript/src/http.ts
+++ b/sdk/typescript/src/http.ts
@@ -7,6 +7,7 @@ import {
InvalidAccountingPasswordError,
NetworkError,
NotFoundError,
+ SyftHubError,
UserAlreadyExistsError,
ValidationError,
} from './errors.js';
@@ -471,6 +472,3 @@ export class HTTPClient {
await this.refreshPromise;
}
}
-
-// Import SyftHubError for type checking
-import { SyftHubError } from './errors.js';
diff --git a/sdk/typescript/src/resources/chat.ts b/sdk/typescript/src/resources/chat.ts
index fb6c6f56..6ec70d73 100644
--- a/sdk/typescript/src/resources/chat.ts
+++ b/sdk/typescript/src/resources/chat.ts
@@ -37,6 +37,7 @@ import type {
TokenUsage,
} from '../models/chat.js';
import { SyftHubError } from '../errors.js';
+import { readSSEEvents } from '../utils.js';
import type { HubResource } from './hub.js';
import type { AuthResource } from './auth.js';
import { EndpointType } from '../models/index.js';
@@ -567,51 +568,17 @@ export class ChatResource {
throw new AggregatorError('No response body from aggregator');
}
- const reader = response.body.getReader();
- const decoder = new TextDecoder();
- let buffer = '';
- let currentEvent: string | null = null;
- let currentData = '';
-
- try {
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
-
- buffer += decoder.decode(value, { stream: true });
- const lines = buffer.split('\n');
- buffer = lines.pop() ?? '';
-
- for (const line of lines) {
- const trimmedLine = line.trim();
-
- if (!trimmedLine) {
- // Empty line = end of event
- if (currentEvent && currentData) {
- try {
- const data = JSON.parse(currentData) as Record;
- const event = this.parseSSEEvent(currentEvent, data);
- if (event) {
- yield event;
- }
- } catch {
- yield { type: 'error', message: `Failed to parse SSE data: ${currentData}` };
- }
- }
- currentEvent = null;
- currentData = '';
- continue;
- }
-
- if (trimmedLine.startsWith('event:')) {
- currentEvent = trimmedLine.slice(6).trim();
- } else if (trimmedLine.startsWith('data:')) {
- currentData = trimmedLine.slice(5).trim();
- }
+ for await (const { event: eventName, data: dataStr } of readSSEEvents(response)) {
+ if (eventName === 'message') continue; // chat protocol always names events
+ try {
+ const data = JSON.parse(dataStr) as Record;
+ const event = this.parseSSEEvent(eventName, data);
+ if (event) {
+ yield event;
}
+ } catch {
+ yield { type: 'error', message: `Failed to parse SSE data: ${dataStr}` };
}
- } finally {
- reader.releaseLock();
}
}
diff --git a/sdk/typescript/src/resources/syftai.ts b/sdk/typescript/src/resources/syftai.ts
index f7cbccef..cfe5f414 100644
--- a/sdk/typescript/src/resources/syftai.ts
+++ b/sdk/typescript/src/resources/syftai.ts
@@ -25,6 +25,7 @@
import type { Document, QueryDataSourceOptions, QueryModelOptions } from '../models/chat.js';
import { SyftHubError } from '../errors.js';
+import { readSSEEvents } from '../utils.js';
/**
* Error thrown when data source retrieval fails.
@@ -253,55 +254,27 @@ export class SyftAIResource {
throw new GenerationError('No response body from model', endpoint.slug);
}
- const reader = response.body.getReader();
- const decoder = new TextDecoder();
- let buffer = '';
+ for await (const { data: dataStr } of readSSEEvents(response)) {
+ if (dataStr === '[DONE]') return;
- try {
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
-
- buffer += decoder.decode(value, { stream: true });
- const lines = buffer.split('\n');
- buffer = lines.pop() ?? '';
-
- for (const line of lines) {
- const trimmedLine = line.trim();
-
- if (!trimmedLine || trimmedLine.startsWith('event:')) {
- continue;
- }
-
- if (trimmedLine.startsWith('data:')) {
- const dataStr = trimmedLine.slice(5).trim();
- if (dataStr === '[DONE]') {
- return;
- }
-
- try {
- const data = JSON.parse(dataStr) as Record;
-
- // Extract content from various response formats
- if (typeof data['content'] === 'string') {
- yield data['content'];
- } else if (Array.isArray(data['choices'])) {
- // OpenAI-style response
- for (const choice of data['choices'] as Record[]) {
- const delta = choice['delta'] as Record | undefined;
- if (delta && typeof delta['content'] === 'string') {
- yield delta['content'];
- }
- }
- }
- } catch {
- // Skip malformed data
+ try {
+ const data = JSON.parse(dataStr) as Record;
+
+ // Extract content from various response formats
+ if (typeof data['content'] === 'string') {
+ yield data['content'];
+ } else if (Array.isArray(data['choices'])) {
+ // OpenAI-style response
+ for (const choice of data['choices'] as Record[]) {
+ const delta = choice['delta'] as Record | undefined;
+ if (delta && typeof delta['content'] === 'string') {
+ yield delta['content'];
}
}
}
+ } catch {
+ // Skip malformed data
}
- } finally {
- reader.releaseLock();
}
}
}
diff --git a/sdk/typescript/src/utils.ts b/sdk/typescript/src/utils.ts
index 1423df32..ccd207d8 100644
--- a/sdk/typescript/src/utils.ts
+++ b/sdk/typescript/src/utils.ts
@@ -1,3 +1,9 @@
+// Per-key caches. Every request runs these over every key; the key-set is
+// small (tens to low hundreds across a process lifetime) so an unbounded Map
+// is fine and saves repeated regex work on hot paths.
+const snakeToCamelCache = new Map();
+const camelToSnakeCache = new Map();
+
/**
* Convert a snake_case string to camelCase.
*
@@ -6,7 +12,11 @@
* snakeToCamel('full_name') // 'fullName'
*/
export function snakeToCamel(str: string): string {
- return str.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase());
+ const cached = snakeToCamelCache.get(str);
+ if (cached !== undefined) return cached;
+ const result = str.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase());
+ snakeToCamelCache.set(str, result);
+ return result;
}
/**
@@ -17,7 +27,11 @@ export function snakeToCamel(str: string): string {
* camelToSnake('fullName') // 'full_name'
*/
export function camelToSnake(str: string): string {
- return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
+ const cached = camelToSnakeCache.get(str);
+ if (cached !== undefined) return cached;
+ const result = str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
+ camelToSnakeCache.set(str, result);
+ return result;
}
/**
@@ -109,3 +123,79 @@ export function buildSearchParams(params: Record): URLSearchPar
return searchParams;
}
+
+/**
+ * Parse a Server-Sent Events stream into event/data pairs.
+ *
+ * - Yields `{event, data}` on blank-line boundaries (SSE framing) OR after any
+ * `data:` line when no preceding `event:` has been seen (tolerates servers
+ * that emit only `data:` lines — fall back to `"message"`).
+ * - Does NOT JSON.parse; callers parse their own schema.
+ * - Flushes any pending event when the stream ends.
+ */
+export async function* readSSEEvents(
+ response: Response
+): AsyncGenerator<{ event: string; data: string }> {
+ if (!response.body) return;
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = '';
+ let currentEvent: string | null = null;
+ let currentData = '';
+
+ const flush = function* (): Generator<{ event: string; data: string }> {
+ if (currentData) {
+ yield { event: currentEvent ?? 'message', data: currentData };
+ }
+ currentEvent = null;
+ currentData = '';
+ };
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split('\n');
+ buffer = lines.pop() ?? '';
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+
+ if (!trimmed) {
+ yield* flush();
+ continue;
+ }
+
+ if (trimmed.startsWith('event:')) {
+ currentEvent = trimmed.slice(6).trim();
+ } else if (trimmed.startsWith('data:')) {
+ // If we already have buffered data without a blank-line terminator,
+ // emit it now so data-only streams (no event: header) still flow.
+ if (currentData && currentEvent === null) {
+ yield* flush();
+ }
+ currentData = trimmed.slice(5).trim();
+ }
+ }
+ }
+
+ // Process any trailing line still in the buffer.
+ const trailing = buffer.trim();
+ if (trailing) {
+ if (trailing.startsWith('event:')) {
+ currentEvent = trailing.slice(6).trim();
+ } else if (trailing.startsWith('data:')) {
+ if (currentData && currentEvent === null) {
+ yield* flush();
+ }
+ currentData = trailing.slice(5).trim();
+ }
+ }
+ yield* flush();
+ } finally {
+ reader.releaseLock();
+ }
+}
diff --git a/skills/README.md b/skills/README.md
deleted file mode 100644
index 2f6f503d..00000000
--- a/skills/README.md
+++ /dev/null
@@ -1,38 +0,0 @@
-# SyftHub Skills
-
-This directory contains Claude Code skills for working with SyftHub.
-
-## Available Skills
-
-| Skill | Description |
-|-------|-------------|
-| [syfthub-cli](./syfthub-cli/) | CLI commands for authentication, endpoint discovery, RAG queries, and configuration |
-
-## Installation
-
-### Option 1: Copy to Claude Code skills directory
-
-```bash
-# Clone or navigate to the syfthub repo
-cp -r skills/syfthub-cli ~/.claude/skills/
-```
-
-### Option 2: Symlink (for development)
-
-```bash
-ln -s "$(pwd)/skills/syfthub-cli" ~/.claude/skills/syfthub-cli
-```
-
-### Option 3: One-liner install
-
-```bash
-curl -fsSL https://raw.githubusercontent.com/OpenMined/syfthub/main/skills/syfthub-cli/SKILL.md -o ~/.claude/skills/syfthub-cli/SKILL.md --create-dirs
-```
-
-## Verify Installation
-
-After installation, the skill will automatically trigger when you ask Claude Code about:
-- SyftHub CLI commands (`syft login`, `syft ls`, `syft query`, etc.)
-- Browsing or listing AI endpoints
-- RAG queries with SyftHub
-- Managing aggregator configurations