Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions contracts/core-client.v1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"name": "openlinker-core-client",
"version": "v1",
"package": "openlinker",
"scope": "core",
"rules": {
"single_package_first": true,
"forbidden_path_prefixes": [
"/api/v1/cloud",
"/api/v1/tasks",
"/api/v1/task-templates",
"/api/v1/workflows",
"/api/v1/workflow-runs",
"/api/v1/billing",
"/api/v1/wallet",
"/api/v1/stripe"
]
},
"endpoints": [
{"client_method":"list_agents","http_method":"GET","path":"/api/v1/agents","auth":"none"},
{"client_method":"get_agent","http_method":"GET","path":"/api/v1/agents/{slug}","auth":"none"},
{"client_method":"get_agent_card","http_method":"GET","path":"/api/v1/agents/{slug}/agent-card.json","auth":"none"},
{"client_method":"get_agent_card","http_method":"GET","path":"/api/v1/agents/{slug}/agent-card.extended.json","auth":"none"},
{"client_method":"run_agent","http_method":"POST","path":"/api/v1/run","auth":"access_token","required_scope":"agents:run","required_headers":["Idempotency-Key"],"success_statuses":[200,201,202],"response_headers":["Location","Idempotency-Replayed"],"response_fields":["agent_connection_mode","runtime_transport","runtime_transport_reason","runtime_transport_changed_at","dispatch_state","attempt_count","max_attempts","started_at","replayed"]},
{"client_method":"start_agent_run","http_method":"POST","path":"/api/v1/runs","auth":"access_token","required_scope":"agents:run","required_headers":["Idempotency-Key"],"success_statuses":[200,201,202],"response_headers":["Location","Idempotency-Replayed"],"response_fields":["agent_connection_mode","runtime_transport","runtime_transport_reason","runtime_transport_changed_at","dispatch_state","attempt_count","max_attempts","started_at","replayed"]},
{"client_method":"get_run","http_method":"GET","path":"/api/v1/runs/{id}","auth":"access_token","required_scope":"runs:read","response_fields":["agent_connection_mode","runtime_transport","runtime_transport_reason","runtime_transport_changed_at","dispatch_state","attempt_count","max_attempts","started_at","replayed"]},
{"client_method":"list_run_events","http_method":"GET","path":"/api/v1/runs/{id}/events","auth":"access_token","required_scope":"runs:read","response_fields":["items","meta.requested_after_sequence","meta.effective_after_sequence","meta.retained_through_sequence","meta.earliest_available_sequence","meta.latest_available_sequence","meta.retention_gap","meta.terminal","meta.stream_complete"]},
{"client_method":"list_run_children","http_method":"GET","path":"/api/v1/runs/{id}/children","auth":"access_token","required_scope":"runs:read","response_fields":["parent_run_id","items[].child_run_id","items[].caller_agent_id","items[].target_agent_id","items[].status","items[].children"]},
{"client_method":"list_run_artifacts","http_method":"GET","path":"/api/v1/runs/{id}/artifacts","auth":"access_token","required_scope":"runs:read"},
{"client_method":"list_run_messages","http_method":"GET","path":"/api/v1/runs/{id}/messages","auth":"access_token","required_scope":"runs:read"},
{"client_method":"stream_run_events","http_method":"GET","path":"/api/v1/runs/{id}/stream","auth":"access_token","required_scope":"runs:read"}
]
}
16 changes: 16 additions & 0 deletions contracts/core-registration.v1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "openlinker-registration",
"version": "v1",
"scope": "core-registration",
"endpoints": [
{"client_method":"create_agent","http_method":"POST","path":"/api/v1/creator/agents","auth":"user_token"},
{"client_method":"list_my_agents","http_method":"GET","path":"/api/v1/creator/agents","auth":"user_token"},
{"client_method":"get_my_agent","http_method":"GET","path":"/api/v1/creator/agents/{id}","auth":"user_token"},
{"client_method":"get_my_agent_by_slug","http_method":"GET","path":"/api/v1/creator/agents/by-slug/{slug}","auth":"user_token"},
{"client_method":"update_agent","http_method":"PATCH","path":"/api/v1/creator/agents/{id}","auth":"user_token"},
{"client_method":"create_agent_token","http_method":"POST","path":"/api/v1/creator/agent-tokens","auth":"user_token"},
{"client_method":"list_agent_tokens","http_method":"GET","path":"/api/v1/creator/agent-tokens","auth":"user_token"},
{"client_method":"revoke_agent_token","http_method":"DELETE","path":"/api/v1/creator/agent-tokens/{id}","auth":"user_token"},
{"client_method":"register_agent_via_token","http_method":"POST","path":"/api/v1/agent-registration/agents","auth":"agent_token"}
]
}
4 changes: 2 additions & 2 deletions src/openlinker/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
from . import client, runtime
from . import client, registration, runtime

__all__ = ["client", "runtime"]
__all__ = ["client", "registration", "runtime"]
169 changes: 138 additions & 31 deletions src/openlinker/client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import asyncio
import inspect
import json
import secrets
from collections.abc import AsyncIterator, Awaitable
from datetime import datetime, timezone, timedelta
from email.utils import parsedate_to_datetime
Expand Down Expand Up @@ -30,6 +31,8 @@
ListRunEventsResponse,
MarketListResponse,
PlatformCallbackOptions,
RegisterAgentViaTokenRequest,
RegisterAgentViaTokenResponse,
RunAgentRequest,
RunArtifactResponse,
RunMessageResponse,
Expand Down Expand Up @@ -92,6 +95,24 @@ def _quote(value: str) -> str:
return quote(value, safe="")


def _resolve_run_idempotency_key(value: str | None) -> str:
key = secrets.token_hex(32) if value is None else value
if (
not isinstance(key, str)
or not 1 <= len(key) <= 255
or any(ord(char) < 0x20 or ord(char) > 0x7E for char in key)
):
raise ValueError("openlinker: idempotency_key must be 1-255 printable ASCII characters")
return key


def _normalize_registration_connection_mode(value: str | None) -> str:
normalized = _clean(value).lower()
if normalized in {"", "runtime", "runtime_ws", "runtime_pull", "agent_node"}:
return "runtime"
return _clean(value)


def _maybe_await(value: Any) -> Awaitable[Any]:
if inspect.isawaitable(value):
return value
Expand Down Expand Up @@ -124,6 +145,24 @@ def _retry_after(headers: httpx.Headers) -> timedelta | None:
return None


async def _read_response_body(response: httpx.Response) -> bytes:
declared = response.headers.get("Content-Length")
if declared:
try:
declared_length = int(declared)
except ValueError:
declared_length = 0
if declared_length > MAX_RESPONSE_BODY_BYTES:
raise ValueError(f"openlinker: response body exceeds {MAX_RESPONSE_BODY_BYTES} bytes")

body = bytearray()
async for chunk in response.aiter_bytes():
if len(body) + len(chunk) > MAX_RESPONSE_BODY_BYTES:
raise ValueError(f"openlinker: response body exceeds {MAX_RESPONSE_BODY_BYTES} bytes")
body.extend(chunk)
return bytes(body)


class Client:
def __init__(
self,
Expand Down Expand Up @@ -200,16 +239,20 @@ async def _request(
body: Any = None,
accept: str = "application/json",
token: str = "",
headers: dict[str, str] | None = None,
) -> httpx.Response:
content = None
if body is not None:
content = json.dumps(to_json_value(body)).encode()
return await self._client.request(
request_headers = self._request_headers(accept, token, body is not None)
request_headers.update(headers or {})
request = self._client.build_request(
method,
self.endpoint(path, query),
content=content,
headers=self._request_headers(accept, token, body is not None),
headers=request_headers,
)
return await self._client.send(request, stream=True)

async def _do(
self,
Expand All @@ -219,34 +262,60 @@ async def _do(
query: dict[str, Any] | None = None,
body: Any = None,
out: type[T] | None = None,
headers: dict[str, str] | None = None,
token: str | None = None,
) -> T | dict[str, Any] | None:
response = await self._request(
result, _ = await self._do_with_response(
method,
path,
query=query,
body=body,
token=self.user_token,
out=out,
headers=headers,
token=token,
)
if response.status_code < 200 or response.status_code >= 300:
raise self._parse_error(response)
if response.status_code == 204:
return None
raw = response.content
if len(raw) > MAX_RESPONSE_BODY_BYTES:
raise ValueError(f"openlinker: response body exceeds {MAX_RESPONSE_BODY_BYTES} bytes")
if not raw:
return None
data = response.json()
if out is None:
return data
return out.from_dict(data)
return result

def _parse_error(self, response: httpx.Response) -> OpenLinkerError:
raw = response.content[:MAX_RESPONSE_BODY_BYTES]
async def _do_with_response(
self,
method: str,
path: str,
*,
query: dict[str, Any] | None = None,
body: Any = None,
out: type[T] | None = None,
headers: dict[str, str] | None = None,
token: str | None = None,
) -> tuple[T | dict[str, Any] | None, httpx.Headers]:
response = await self._request(
method,
path,
query=query,
body=body,
token=self.user_token if token is None else token,
headers=headers,
)
try:
raw = b"" if response.status_code == 204 else await _read_response_body(response)
if response.status_code < 200 or response.status_code >= 300:
raise self._parse_error(response, raw)
response_headers = httpx.Headers(response.headers)
if response.status_code == 204 or not raw:
return None, response_headers
data = json.loads(raw)
if out is None:
return data, response_headers
return out.from_dict(data), response_headers
finally:
await response.aclose()

def _parse_error(self, response: httpx.Response, raw: bytes | None = None) -> OpenLinkerError:
if raw is None:
raw = response.content[:MAX_RESPONSE_BODY_BYTES]
parsed: dict[str, Any] = {}
try:
parsed = response.json()
except ValueError:
parsed = json.loads(raw)
except (TypeError, ValueError):
pass
err = parsed.get("error") if isinstance(parsed, dict) else None
err = err if isinstance(err, dict) else {}
Expand Down Expand Up @@ -280,14 +349,29 @@ async def get_agent_card(self, slug: str, extended: bool = False):
return await self._do("GET", f"/agents/{_quote(slug)}/{suffix}", out=AgentCardResponse)

async def run_agent(self, req: RunAgentRequest | dict[str, Any]):
return await self._do(
"POST", "/run", body=maybe_model(req, RunAgentRequest), out=RunResponse
)
return await self._create_run("/run", req)

async def start_agent_run(self, req: RunAgentRequest | dict[str, Any]):
return await self._do(
"POST", "/runs", body=maybe_model(req, RunAgentRequest), out=RunResponse
return await self._create_run("/runs", req)

async def _create_run(self, path: str, req: RunAgentRequest | dict[str, Any]):
request = maybe_model(req, RunAgentRequest)
key = _resolve_run_idempotency_key(request.idempotency_key)
body = request.to_dict()
body.pop("idempotency_key", None)
result, headers = await self._do_with_response(
"POST",
path,
body=body,
out=RunResponse,
headers={"Idempotency-Key": key},
)
if (
isinstance(result, RunResponse)
and headers.get("Idempotency-Replayed", "").lower() == "true"
):
result.replayed = True
return result

async def get_run(self, run_id: str):
return await self._do("GET", f"/runs/{_quote(run_id)}", out=RunResponse)
Expand Down Expand Up @@ -330,10 +414,8 @@ async def stream_run_events(
headers=self._request_headers("text/event-stream", self.user_token),
) as response:
if response.status_code < 200 or response.status_code >= 300:
body = await response.aread()
raise self._parse_error(
httpx.Response(response.status_code, headers=response.headers, content=body)
)
body = await _read_response_body(response)
raise self._parse_error(response, body)
async for event in read_sse(response.aiter_lines()):
yield event

Expand All @@ -342,7 +424,9 @@ async def run_agent_with_callbacks(
):
started = await self.start_agent_run(req)
await self._stream_platform_callbacks(started.run_id, opts, until_terminal=True)
return await self.get_run(started.run_id)
result = await self.get_run(started.run_id)
result.replayed = result.replayed or started.replayed
return result

async def start_agent_run_with_callbacks(
self, req: RunAgentRequest | dict[str, Any], opts: PlatformCallbackOptions
Expand Down Expand Up @@ -431,6 +515,28 @@ async def list_agent_tokens(self, params: ListAgentTokensParams | dict[str, Any]
async def revoke_agent_token(self, token_id: str) -> None:
await self._do("DELETE", f"/creator/agent-tokens/{_quote(token_id)}")

async def register_agent_via_token(
self,
agent_token: str,
req: RegisterAgentViaTokenRequest | dict[str, Any],
):
token = _clean(agent_token)
if not token:
raise ValueError("openlinker: Agent Token is required for registration")
request = maybe_model(req, RegisterAgentViaTokenRequest)
body = request.to_dict()
body["visibility"] = request.visibility or "private"
body["connection_mode"] = _normalize_registration_connection_mode(
request.connection_mode
)
return await self._do(
"POST",
"/agent-registration/agents",
body=body,
out=RegisterAgentViaTokenResponse,
token=token,
)

def a2a_agent(self, slug: str):
from ..a2a import A2AClient

Expand Down Expand Up @@ -464,6 +570,7 @@ def a2a_agent(self, slug: str):
CreateAgentToken = create_agent_token
ListAgentTokens = list_agent_tokens
RevokeAgentToken = revoke_agent_token
RegisterAgentViaToken = register_agent_via_token
A2AAgent = a2a_agent


Expand Down
Loading
Loading