From 2087a037ae69533de7e8f73a4aa50d35e4a919a4 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Tue, 31 Mar 2026 22:15:49 +0000 Subject: [PATCH] feat(rust,python,typescript): add connections API (set/list/remove) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add native client.connections() methods across all three SDKs so consumers don't need raw HTTP calls for connection management. Endpoints: - POST /v1/user/connections/:provider — set API key connection - GET /v1/user/connections — list connections - DELETE /v1/user/connections/:provider — remove connection Closes #66 --- python/everruns_sdk/__init__.py | 2 + python/everruns_sdk/client.py | 39 ++++++++++++++++ python/everruns_sdk/models.py | 11 +++++ python/tests/test_client.py | 70 +++++++++++++++++++++++++++++ rust/src/client.rs | 32 +++++++++++++ rust/src/models.rs | 25 +++++++++++ rust/tests/client_test.rs | 79 ++++++++++++++++++++++++++++++++- typescript/src/client.ts | 30 +++++++++++++ typescript/src/models.ts | 9 ++++ typescript/tests/client.test.ts | 78 ++++++++++++++++++++++++++++++++ 10 files changed, 374 insertions(+), 1 deletion(-) diff --git a/python/everruns_sdk/__init__.py b/python/everruns_sdk/__init__.py index cd7e94a..a594de1 100644 --- a/python/everruns_sdk/__init__.py +++ b/python/everruns_sdk/__init__.py @@ -21,6 +21,7 @@ Agent, AgentCapabilityConfig, CapabilityInfo, + Connection, ContentPart, Controls, DeleteFileResponse, @@ -51,6 +52,7 @@ "Agent", "AgentCapabilityConfig", "CapabilityInfo", + "Connection", "DeleteFileResponse", "FileInfo", "FileStat", diff --git a/python/everruns_sdk/client.py b/python/everruns_sdk/client.py index 4708a09..6b090bb 100644 --- a/python/everruns_sdk/client.py +++ b/python/everruns_sdk/client.py @@ -13,6 +13,7 @@ Agent, AgentCapabilityConfig, CapabilityInfo, + Connection, ContentPart, Controls, CreateAgentRequest, @@ -120,6 +121,11 @@ def session_files(self) -> "SessionFilesClient": """Get the session files client.""" return SessionFilesClient(self) + @property + def connections(self) -> "ConnectionsClient": + """Get the connections client.""" + return ConnectionsClient(self) + def _url(self, path: str) -> str: # Use relative path (no leading slash) for correct joining with base URL. # The path parameter starts with "/" (e.g., "/agents"), so we strip it. @@ -722,3 +728,36 @@ async def stat(self, session_id: str, path: str) -> FileStat: """ resp = await self._client._post(f"/sessions/{session_id}/fs/_/stat", {"path": path}) return FileStat(**resp) + + +class ConnectionsClient: + """Client for user connection operations.""" + + def __init__(self, client: Everruns): + self._client = client + + async def set(self, provider: str, api_key: str) -> Connection: + """Set an API key connection for a provider. + + Args: + provider: Provider name (e.g. "daytona"). + api_key: API key for the provider. + """ + resp = await self._client._post( + f"/user/connections/{provider}", + {"api_key": api_key}, + ) + return Connection(**resp) + + async def list(self) -> list[Connection]: + """List all connections.""" + resp = await self._client._get("/user/connections") + return [Connection(**c) for c in resp.get("data", [])] + + async def remove(self, provider: str) -> None: + """Remove a connection. + + Args: + provider: Provider name (e.g. "daytona"). + """ + await self._client._delete(f"/user/connections/{provider}") diff --git a/python/everruns_sdk/models.py b/python/everruns_sdk/models.py index df760c4..22b19de 100644 --- a/python/everruns_sdk/models.py +++ b/python/everruns_sdk/models.py @@ -325,6 +325,17 @@ class DeleteFileResponse(BaseModel): deleted: bool +# --- Connections Models --- + + +class Connection(BaseModel): + """A user connection to an external provider.""" + + provider: str + created_at: str + updated_at: str + + def extract_tool_calls(data: dict[str, Any]) -> list[ToolCallInfo]: """Extract tool call info from event data (``data.message.content``).""" message = data.get("message") diff --git a/python/tests/test_client.py b/python/tests/test_client.py index 5599b0a..526ab0e 100644 --- a/python/tests/test_client.py +++ b/python/tests/test_client.py @@ -950,3 +950,73 @@ def test_list_response_with_pagination_fields(): assert resp.total == 10 assert resp.offset == 5 assert resp.limit == 25 + + +# --- Connections Tests --- + +CONN_RESPONSE = { + "provider": "daytona", + "created_at": "2026-03-31T00:00:00Z", + "updated_at": "2026-03-31T00:00:00Z", +} + + +@pytest.mark.asyncio +@respx.mock +async def test_connections_set(): + route = respx.post("https://custom.example.com/api/v1/user/connections/daytona").mock( + return_value=httpx.Response(200, json=CONN_RESPONSE) + ) + + client = Everruns(api_key="evr_test_key") + try: + conn = await client.connections.set("daytona", "dtn_secret_key") + finally: + await client.close() + + assert conn.provider == "daytona" + assert route.called + body = json.loads(route.calls[0].request.content) + assert body["api_key"] == "dtn_secret_key" + + +@pytest.mark.asyncio +@respx.mock +async def test_connections_list(): + route = respx.get("https://custom.example.com/api/v1/user/connections").mock( + return_value=httpx.Response( + 200, + json={ + "data": [CONN_RESPONSE], + "total": 1, + "offset": 0, + "limit": 100, + }, + ) + ) + + client = Everruns(api_key="evr_test_key") + try: + connections = await client.connections.list() + finally: + await client.close() + + assert len(connections) == 1 + assert connections[0].provider == "daytona" + assert route.called + + +@pytest.mark.asyncio +@respx.mock +async def test_connections_remove(): + route = respx.delete("https://custom.example.com/api/v1/user/connections/daytona").mock( + return_value=httpx.Response(204) + ) + + client = Everruns(api_key="evr_test_key") + try: + await client.connections.remove("daytona") + finally: + await client.close() + + assert route.called diff --git a/rust/src/client.rs b/rust/src/client.rs index 9602535..85825e6 100644 --- a/rust/src/client.rs +++ b/rust/src/client.rs @@ -97,6 +97,11 @@ impl Everruns { SessionFilesClient { client: self } } + /// Get the connections client + pub fn connections(&self) -> ConnectionsClient<'_> { + ConnectionsClient { client: self } + } + pub(crate) fn url(&self, path: &str) -> Url { // Use relative path (no leading slash) for correct joining with base URL. // The path parameter starts with "/" (e.g., "/agents"), so we strip it. @@ -763,6 +768,33 @@ impl<'a> SessionFilesClient<'a> { } } +/// Client for user connection operations +pub struct ConnectionsClient<'a> { + client: &'a Everruns, +} + +impl<'a> ConnectionsClient<'a> { + /// Set an API key connection for a provider + pub async fn set(&self, provider: &str, api_key: &str) -> Result { + let req = SetConnectionRequest::new(api_key); + self.client + .post(&format!("/user/connections/{}", provider), &req) + .await + } + + /// List all connections + pub async fn list(&self) -> Result> { + self.client.get("/user/connections").await + } + + /// Remove a connection + pub async fn remove(&self, provider: &str) -> Result<()> { + self.client + .delete(&format!("/user/connections/{}", provider)) + .await + } +} + impl std::fmt::Debug for Everruns { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Everruns") diff --git a/rust/src/models.rs b/rust/src/models.rs index 1de2a8a..41bfdc4 100644 --- a/rust/src/models.rs +++ b/rust/src/models.rs @@ -928,6 +928,31 @@ pub struct DeleteResponse { pub deleted: bool, } +// --- Connections Models --- + +/// A user connection to an external provider +#[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] +pub struct Connection { + pub provider: String, + pub created_at: String, + pub updated_at: String, +} + +/// Request to set a connection API key +#[derive(Debug, Clone, Serialize)] +pub struct SetConnectionRequest { + pub api_key: String, +} + +impl SetConnectionRequest { + pub fn new(api_key: impl Into) -> Self { + Self { + api_key: api_key.into(), + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/rust/tests/client_test.rs b/rust/tests/client_test.rs index f22b93f..510ef71 100644 --- a/rust/tests/client_test.rs +++ b/rust/tests/client_test.rs @@ -2,7 +2,7 @@ use everruns_sdk::{ CreateAgentRequest, CreateFileRequest, CreateSessionRequest, Everruns, InitialFile, - UpdateFileRequest, + SetConnectionRequest, UpdateFileRequest, }; use wiremock::{ Mock, MockServer, ResponseTemplate, @@ -547,3 +547,80 @@ async fn test_session_files_stat() { assert_eq!(stat.size_bytes, 5); assert!(!stat.is_directory); } + +// --- Connections Tests --- + +#[tokio::test] +async fn test_connections_set() { + let server = MockServer::start().await; + let client = Everruns::with_base_url("evr_test_key", &server.uri()).expect("client"); + + Mock::given(method("POST")) + .and(path("/v1/user/connections/daytona")) + .and(body_json(serde_json::json!({ + "api_key": "dtn_secret_key" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "provider": "daytona", + "created_at": "2026-03-31T00:00:00Z", + "updated_at": "2026-03-31T00:00:00Z" + }))) + .mount(&server) + .await; + + let conn = client + .connections() + .set("daytona", "dtn_secret_key") + .await + .expect("set connection should succeed"); + + assert_eq!(conn.provider, "daytona"); +} + +#[tokio::test] +async fn test_connections_list() { + let server = MockServer::start().await; + let client = Everruns::with_base_url("evr_test_key", &server.uri()).expect("client"); + + Mock::given(method("GET")) + .and(path("/v1/user/connections")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": [{ + "provider": "daytona", + "created_at": "2026-03-31T00:00:00Z", + "updated_at": "2026-03-31T00:00:00Z" + }], + "total": 1, + "offset": 0, + "limit": 100 + }))) + .mount(&server) + .await; + + let connections = client + .connections() + .list() + .await + .expect("list connections should succeed"); + + assert_eq!(connections.data.len(), 1); + assert_eq!(connections.data[0].provider, "daytona"); +} + +#[tokio::test] +async fn test_connections_remove() { + let server = MockServer::start().await; + let client = Everruns::with_base_url("evr_test_key", &server.uri()).expect("client"); + + Mock::given(method("DELETE")) + .and(path("/v1/user/connections/daytona")) + .respond_with(ResponseTemplate::new(204)) + .mount(&server) + .await; + + client + .connections() + .remove("daytona") + .await + .expect("remove connection should succeed"); +} diff --git a/typescript/src/client.ts b/typescript/src/client.ts index f1f281d..65b2287 100644 --- a/typescript/src/client.ts +++ b/typescript/src/client.ts @@ -5,6 +5,7 @@ import { ApiKey } from "./auth.js"; import { Agent, CapabilityInfo, + Connection, ContentPart, CreateAgentRequest, DeleteFileResponse, @@ -44,6 +45,7 @@ export class Everruns { readonly events: EventsClient; readonly capabilities: CapabilitiesClient; readonly sessionFiles: SessionFilesClient; + readonly connections: ConnectionsClient; constructor(options: EverrunsOptions = {}) { if (options.apiKey instanceof ApiKey) { @@ -66,6 +68,7 @@ export class Everruns { this.events = new EventsClient(this); this.capabilities = new CapabilitiesClient(this); this.sessionFiles = new SessionFilesClient(this); + this.connections = new ConnectionsClient(this); } /** @@ -578,6 +581,33 @@ class SessionFilesClient { } } +class ConnectionsClient { + constructor(private readonly client: Everruns) {} + + /** Set an API key connection for a provider. */ + async set(provider: string, apiKey: string): Promise { + return this.client.fetch(`/user/connections/${provider}`, { + method: "POST", + body: JSON.stringify({ api_key: apiKey }), + }); + } + + /** List all connections. */ + async list(): Promise { + const response = await this.client.fetch<{ data: Connection[] }>( + "/user/connections", + ); + return response.data; + } + + /** Remove a connection. */ + async remove(provider: string): Promise { + await this.client.fetch(`/user/connections/${provider}`, { + method: "DELETE", + }); + } +} + /** Build the JSON body for agent creation from a CreateAgentRequest. */ function toAgentBody(request: CreateAgentRequest): Record { const body: Record = { diff --git a/typescript/src/models.ts b/typescript/src/models.ts index 62e8693..49aab87 100644 --- a/typescript/src/models.ts +++ b/typescript/src/models.ts @@ -287,6 +287,15 @@ export interface DeleteFileResponse { deleted: boolean; } +// --- Connections Models --- + +/** A user connection to an external provider */ +export interface Connection { + provider: string; + createdAt: string; + updatedAt: string; +} + /** Paginated list response */ export interface ListResponse { data: T[]; diff --git a/typescript/tests/client.test.ts b/typescript/tests/client.test.ts index 4e1106e..e4faf4d 100644 --- a/typescript/tests/client.test.ts +++ b/typescript/tests/client.test.ts @@ -9,6 +9,7 @@ import { toolError, type AgentCapabilityConfig, type CapabilityInfo, + type Connection, type CreateAgentRequest, type CreateMessageRequest, type CreateSessionRequest, @@ -938,3 +939,80 @@ describe("SessionFilesClient", () => { expect(response.limit).toBe(20); }); }); + +// --- Connections Tests --- + +const CONN_RESPONSE = { + provider: "daytona", + created_at: "2026-03-31T00:00:00Z", + updated_at: "2026-03-31T00:00:00Z", +}; + +describe("ConnectionsClient", () => { + it("should have connections on client", () => { + const client = new Everruns({ apiKey: "evr_test_key" }); + expect(client.connections).toBeDefined(); + }); + + it("should set a connection", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => CONN_RESPONSE, + }); + vi.stubGlobal("fetch", fetchMock); + + const client = new Everruns({ apiKey: "evr_test_key" }); + const conn = await client.connections.set("daytona", "dtn_secret_key"); + + expect(conn.provider).toBe("daytona"); + expect(fetchMock).toHaveBeenCalledWith( + "https://custom.example.com/api/v1/user/connections/daytona", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ api_key: "dtn_secret_key" }), + }), + ); + }); + + it("should list connections", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + data: [CONN_RESPONSE], + total: 1, + offset: 0, + limit: 100, + }), + }); + vi.stubGlobal("fetch", fetchMock); + + const client = new Everruns({ apiKey: "evr_test_key" }); + const connections = await client.connections.list(); + + expect(connections).toHaveLength(1); + expect(connections[0].provider).toBe("daytona"); + expect(fetchMock).toHaveBeenCalledWith( + "https://custom.example.com/api/v1/user/connections", + expect.objectContaining({ headers: expect.any(Object) }), + ); + }); + + it("should remove a connection", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 204, + json: async () => undefined, + }); + vi.stubGlobal("fetch", fetchMock); + + const client = new Everruns({ apiKey: "evr_test_key" }); + await client.connections.remove("daytona"); + + expect(fetchMock).toHaveBeenCalledWith( + "https://custom.example.com/api/v1/user/connections/daytona", + expect.objectContaining({ method: "DELETE" }), + ); + }); +});